abstract-gpt 0.1.0__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.
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: abstract-gpt
3
+ Version: 0.1.0
4
+ Summary: Codex login and session management for the abstract toolserver
5
+ Author: jrputkey
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Provides-Extra: mcp
9
+ Requires-Dist: abstract-claude>=0.1.30; extra == "mcp"
10
+
11
+ # abstract-gpt
12
+
13
+ Codex login and session management, usable as a CLI and as the optional `/gpt/*`
14
+ category in `abstract_toolserver`. Requires Python 3.11+, Linux (for HTTP login
15
+ locking), and an installed Codex CLI.
16
+
17
+ ```bash
18
+ pip install -e /srv/pyit/dev/abstract_gpt
19
+ abstract-gpt oauth-status
20
+ abstract-gpt login # device URL + code; approve in your browser
21
+ abstract-gpt launch -- # new interactive conversation
22
+ abstract-gpt exec -- "Explain this repository" # ephemeral noninteractive session
23
+ ```
24
+
25
+ `login --browser` uses normal browser login. `login --with-api-key` reads a key
26
+ from stdin via Codex itself. API key usage has separate API billing. No key is
27
+ required for the ChatGPT subscription login flow.
28
+
29
+ ## Toolserver
30
+
31
+ Install in the toolserver's Python environment, then add alongside its Claude merge:
32
+
33
+ ```python
34
+ _merge_optional_toolset("abstract_gpt.toolset:get_toolset")
35
+ ```
36
+
37
+ Restart the service to register these routes. They use the toolserver's existing
38
+ authentication and execute as its OS user (`vm_mgr` on this host):
39
+
40
+ | Route | Purpose |
41
+ | --- | --- |
42
+ | `/gpt/state` | Wrapper configuration and local login status |
43
+ | `/gpt/oauth_status` | Local credential availability; not a live entitlement probe |
44
+ | `/gpt/oauth_solution` | Login instructions |
45
+ | `/gpt/login_start` | Start device login, or return an existing attempt |
46
+ | `/gpt/login_poll` | Poll state, verification URL and one-time code |
47
+ | `/gpt/save_template` | Save config.toml privately |
48
+ | `/gpt/restore` | Restore config only when missing |
49
+ | `/gpt/set_model` | Set `default_model`; null clears wrapper default |
50
+
51
+ Use POST for mutations. Device login runs in a detached subprocess with a
52
+ 15-minute timeout; a file lock prevents duplicate flows across gunicorn workers.
53
+ Poll until `authenticated`, `failed`, `expired`, or `interrupted`. After service
54
+ restart, an interrupted attempt can be started again. Failed login output remains in the private `AG_ROOT/login-output.txt` for local
55
+ diagnostics, and is cleared at the next attempt. Raw login output and stored
56
+ credentials are never returned by the HTTP tools. The device code is sensitive:
57
+ use the existing authenticated toolserver connection.
58
+
59
+ ## Storage and sessions
60
+
61
+ - `CODEX_HOME`: existing Codex home, default `~/.codex`. Login and launch use the
62
+ same store so Codex manages token refresh. No credentials are copied between users.
63
+ - `AG_ROOT`: wrapper storage, default `~/.local/share/abstract_gpt`, private mode 0700.
64
+ - `AG_CODEX_BIN`: optional executable path. Otherwise `~/.local/bin/codex`, then PATH. The user-local binary takes priority
65
+ to avoid selecting a Snap launcher inside a restricted systemd service.
66
+ - `abstract-gpt init`, `state`, `save-template`, `restore`, `set-model MODEL`,
67
+ `login-start`, `login-poll`, and `oauth-solution` mirror their actions.
68
+ - `launch` starts a new conversation by default; explicit Codex resume flags still
69
+ work. `exec` adds `--ephemeral`. Neither deletes existing sessions or changes
70
+ sandbox/approval settings. Explicit model flags override the wrapper default.
71
+ - A config template can contain user-configured secrets; it stays private and is
72
+ never returned through the API. It does not include auth.json or transcripts.
73
+
74
+ This package does not emulate Claude's durable token export, destructive reset,
75
+ or automatic quota fallback. Codex owns its cached authentication and renewal.
76
+ A service login applies to that service account; other OS users log in separately.
77
+
78
+ ## Toolserver tools inside Codex
79
+
80
+ Optional `pip install 'abstract-gpt[mcp]'` reuses the existing protocol-neutral
81
+ `abstract_claude.mcp` bridge:
82
+
83
+ ```bash
84
+ codex mcp add toolserver -- abstract-gpt mcp
85
+ ```
86
+
87
+ The bridge honors `TOOLSERVER_URL` and the existing Hugpy operator credentials.
88
+
89
+ ## Validation
90
+
91
+ ```bash
92
+ PYTHONPATH=src python -m unittest discover -s tests -v
93
+ ```
94
+
95
+ Tests use a fake Codex executable; they never consume model credits or change real
96
+ credentials. Authentication behavior follows the
97
+ [official Codex documentation](https://developers.openai.com/codex/auth/).
@@ -0,0 +1,87 @@
1
+ # abstract-gpt
2
+
3
+ Codex login and session management, usable as a CLI and as the optional `/gpt/*`
4
+ category in `abstract_toolserver`. Requires Python 3.11+, Linux (for HTTP login
5
+ locking), and an installed Codex CLI.
6
+
7
+ ```bash
8
+ pip install -e /srv/pyit/dev/abstract_gpt
9
+ abstract-gpt oauth-status
10
+ abstract-gpt login # device URL + code; approve in your browser
11
+ abstract-gpt launch -- # new interactive conversation
12
+ abstract-gpt exec -- "Explain this repository" # ephemeral noninteractive session
13
+ ```
14
+
15
+ `login --browser` uses normal browser login. `login --with-api-key` reads a key
16
+ from stdin via Codex itself. API key usage has separate API billing. No key is
17
+ required for the ChatGPT subscription login flow.
18
+
19
+ ## Toolserver
20
+
21
+ Install in the toolserver's Python environment, then add alongside its Claude merge:
22
+
23
+ ```python
24
+ _merge_optional_toolset("abstract_gpt.toolset:get_toolset")
25
+ ```
26
+
27
+ Restart the service to register these routes. They use the toolserver's existing
28
+ authentication and execute as its OS user (`vm_mgr` on this host):
29
+
30
+ | Route | Purpose |
31
+ | --- | --- |
32
+ | `/gpt/state` | Wrapper configuration and local login status |
33
+ | `/gpt/oauth_status` | Local credential availability; not a live entitlement probe |
34
+ | `/gpt/oauth_solution` | Login instructions |
35
+ | `/gpt/login_start` | Start device login, or return an existing attempt |
36
+ | `/gpt/login_poll` | Poll state, verification URL and one-time code |
37
+ | `/gpt/save_template` | Save config.toml privately |
38
+ | `/gpt/restore` | Restore config only when missing |
39
+ | `/gpt/set_model` | Set `default_model`; null clears wrapper default |
40
+
41
+ Use POST for mutations. Device login runs in a detached subprocess with a
42
+ 15-minute timeout; a file lock prevents duplicate flows across gunicorn workers.
43
+ Poll until `authenticated`, `failed`, `expired`, or `interrupted`. After service
44
+ restart, an interrupted attempt can be started again. Failed login output remains in the private `AG_ROOT/login-output.txt` for local
45
+ diagnostics, and is cleared at the next attempt. Raw login output and stored
46
+ credentials are never returned by the HTTP tools. The device code is sensitive:
47
+ use the existing authenticated toolserver connection.
48
+
49
+ ## Storage and sessions
50
+
51
+ - `CODEX_HOME`: existing Codex home, default `~/.codex`. Login and launch use the
52
+ same store so Codex manages token refresh. No credentials are copied between users.
53
+ - `AG_ROOT`: wrapper storage, default `~/.local/share/abstract_gpt`, private mode 0700.
54
+ - `AG_CODEX_BIN`: optional executable path. Otherwise `~/.local/bin/codex`, then PATH. The user-local binary takes priority
55
+ to avoid selecting a Snap launcher inside a restricted systemd service.
56
+ - `abstract-gpt init`, `state`, `save-template`, `restore`, `set-model MODEL`,
57
+ `login-start`, `login-poll`, and `oauth-solution` mirror their actions.
58
+ - `launch` starts a new conversation by default; explicit Codex resume flags still
59
+ work. `exec` adds `--ephemeral`. Neither deletes existing sessions or changes
60
+ sandbox/approval settings. Explicit model flags override the wrapper default.
61
+ - A config template can contain user-configured secrets; it stays private and is
62
+ never returned through the API. It does not include auth.json or transcripts.
63
+
64
+ This package does not emulate Claude's durable token export, destructive reset,
65
+ or automatic quota fallback. Codex owns its cached authentication and renewal.
66
+ A service login applies to that service account; other OS users log in separately.
67
+
68
+ ## Toolserver tools inside Codex
69
+
70
+ Optional `pip install 'abstract-gpt[mcp]'` reuses the existing protocol-neutral
71
+ `abstract_claude.mcp` bridge:
72
+
73
+ ```bash
74
+ codex mcp add toolserver -- abstract-gpt mcp
75
+ ```
76
+
77
+ The bridge honors `TOOLSERVER_URL` and the existing Hugpy operator credentials.
78
+
79
+ ## Validation
80
+
81
+ ```bash
82
+ PYTHONPATH=src python -m unittest discover -s tests -v
83
+ ```
84
+
85
+ Tests use a fake Codex executable; they never consume model credits or change real
86
+ credentials. Authentication behavior follows the
87
+ [official Codex documentation](https://developers.openai.com/codex/auth/).
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+ [project]
5
+ name = "abstract-gpt"
6
+ version = "0.1.0"
7
+ description = "Codex login and session management for the abstract toolserver"
8
+ readme = "README.md"
9
+ requires-python = ">=3.11"
10
+ authors = [{name = "jrputkey"}]
11
+ dependencies = []
12
+ [project.optional-dependencies]
13
+ mcp = ["abstract-claude>=0.1.30"]
14
+ [project.scripts]
15
+ abstract-gpt = "abstract_gpt.cli:main"
16
+ [tool.setuptools.packages.find]
17
+ where = ["src"]
18
+ [tool.pypit]
19
+ github_push = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """Codex authentication and launch integration."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
@@ -0,0 +1,197 @@
1
+ """JSON actions; credentials remain in Codex's own store."""
2
+ import fcntl
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import time
12
+ import threading
13
+
14
+
15
+ def root():
16
+ return Path(os.environ.get("AG_ROOT", "~/.local/share/abstract_gpt")).expanduser()
17
+
18
+
19
+ def codex_home():
20
+ return Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
21
+
22
+
23
+ def binary():
24
+ candidate = Path.home() / ".local/bin/codex"
25
+ found = os.environ.get("AG_CODEX_BIN")
26
+ if not found and candidate.is_file():
27
+ found = str(candidate)
28
+ if not found:
29
+ found = shutil.which("codex")
30
+ if not found:
31
+ raise FileNotFoundError("Install Codex CLI or set AG_CODEX_BIN")
32
+ return found
33
+
34
+
35
+ def private_dir(path):
36
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
37
+ path.chmod(0o700)
38
+
39
+
40
+ def write(path, text):
41
+ private_dir(path.parent)
42
+ fd, tmp = tempfile.mkstemp(dir=path.parent)
43
+ try:
44
+ with os.fdopen(fd, "w") as stream:
45
+ stream.write(text)
46
+ os.replace(tmp, path)
47
+ finally:
48
+ if os.path.exists(tmp):
49
+ os.unlink(tmp)
50
+
51
+
52
+ def read_json(path):
53
+ try:
54
+ return json.loads(path.read_text())
55
+ except FileNotFoundError:
56
+ return {}
57
+
58
+
59
+ def env():
60
+ result = os.environ.copy()
61
+ result["CODEX_HOME"] = str(codex_home())
62
+ return result
63
+
64
+
65
+ def auth_status():
66
+ """Check local login status without returning credentials or account data."""
67
+ try:
68
+ result = subprocess.run([binary(), "login", "status"], env=env(),
69
+ capture_output=True, text=True, timeout=20, cwd=Path.home())
70
+ return {"ok": True, "authenticated": result.returncode == 0,
71
+ "codex_home": str(codex_home())}
72
+ except (OSError, subprocess.TimeoutExpired) as exc:
73
+ return {"ok": False, "authenticated": False, "error": str(exc)}
74
+
75
+
76
+ def build_state():
77
+ """Show wrapper configuration and local authentication status, never tokens."""
78
+ return {"root": str(root()), "auth": auth_status(),
79
+ "config": read_json(root() / "config.json")}
80
+
81
+
82
+ def init_root():
83
+ """Create private wrapper storage; leave existing Codex state intact."""
84
+ private_dir(root())
85
+ if not (root() / "config.json").exists():
86
+ write(root() / "config.json", '{}\n')
87
+ return {"ok": True, "root": str(root())}
88
+
89
+
90
+ def set_model(default_model=None):
91
+ """Set the wrapper launch default; null clears it. Explicit CLI flags win."""
92
+ if default_model is not None and (not isinstance(default_model, str) or not default_model.strip()):
93
+ raise ValueError("default_model must be a nonempty string or null")
94
+ cfg = read_json(root() / "config.json")
95
+ cfg["default_model"] = default_model
96
+ write(root() / "config.json", json.dumps(cfg, indent=2) + "\n")
97
+ return {"ok": True, "default_model": default_model}
98
+
99
+
100
+ def save_template():
101
+ """Save config.toml only, privately; auth and transcripts are never copied."""
102
+ source = codex_home() / "config.toml"
103
+ if not source.is_file():
104
+ return {"ok": False, "error": "No Codex config.toml to save"}
105
+ write(root() / "template/config.toml", source.read_text())
106
+ return {"ok": True}
107
+
108
+
109
+ def restore():
110
+ """Restore saved config only when no live config exists."""
111
+ target = codex_home() / "config.toml"
112
+ source = root() / "template/config.toml"
113
+ if target.exists():
114
+ return {"ok": True, "changed": False}
115
+ if not source.exists():
116
+ return {"ok": False, "error": "No saved template"}
117
+ private_dir(target.parent)
118
+ try:
119
+ fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
120
+ except FileExistsError:
121
+ return {"ok": True, "changed": False}
122
+ with os.fdopen(fd, "w") as stream:
123
+ stream.write(source.read_text())
124
+ return {"ok": True, "changed": True}
125
+
126
+
127
+ def auth_solution():
128
+ """Describe supported login paths for this service's own OS account."""
129
+ return {"status": auth_status(), "steps": [
130
+ "POST /gpt/login_start, then poll /gpt/login_poll for the URL and one-time code.",
131
+ "Approve in your browser. Enable device login in ChatGPT security settings first.",
132
+ "Alternatively run abstract-gpt login --browser as the service OS user.",
133
+ "For API billing: pipe a key to abstract-gpt login --with-api-key."],
134
+ "documentation": "https://developers.openai.com/codex/auth/"}
135
+
136
+
137
+ def login_start():
138
+ """Start device login, or return the current attempt shared across workers."""
139
+ init_root()
140
+ lock = open(root() / "login.lock", "a+")
141
+ try:
142
+ try:
143
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
144
+ except BlockingIOError:
145
+ return login_poll()
146
+ status = auth_status()
147
+ if not status["ok"]:
148
+ return status
149
+ if status["authenticated"]:
150
+ return {"ok": True, "state": "authenticated"}
151
+ write(root() / "login-output.txt", "")
152
+ write(root() / "login.json", json.dumps({"state": "pending", "started": time.time()}))
153
+ worker = subprocess.Popen([sys.executable, "-m", "abstract_gpt.login_worker", str(lock.fileno())],
154
+ env=env(), pass_fds=(lock.fileno(),), start_new_session=True,
155
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
156
+ stderr=subprocess.DEVNULL)
157
+ threading.Thread(target=worker.wait, daemon=True).start()
158
+ return {"ok": True, "state": "pending", "poll": "/gpt/login_poll"}
159
+ finally:
160
+ lock.close()
161
+
162
+
163
+ def login_poll():
164
+ """Return only device URL/code and completion state, never raw CLI output."""
165
+ state = read_json(root() / "login.json") or {"state": "idle"}
166
+ if state["state"] == "pending":
167
+ with open(root() / "login.lock", "a+") as lock:
168
+ try:
169
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
170
+ state["state"] = "interrupted"
171
+ except BlockingIOError:
172
+ pass
173
+ if state["state"] == "pending":
174
+ try:
175
+ with open(root() / "login-output.txt") as stream:
176
+ output = stream.read(16384)
177
+ except FileNotFoundError:
178
+ output = ""
179
+ output = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", output)
180
+ url = re.search(r"https://(?:auth\.openai\.com|chatgpt\.com)/[\w/.-]*device[\w/.-]*", output)
181
+ code = re.search(r"\b[A-Z0-9]{4,6}-[A-Z0-9]{4,6}\b", output)
182
+ if url and code:
183
+ state.update(verification_url=url.group(), user_code=code.group())
184
+ return {"ok": state["state"] not in ("failed", "expired", "interrupted"), **state}
185
+
186
+
187
+ def launch(args, execute=False):
188
+ """New conversation, same refreshable credential store; exec is ephemeral."""
189
+ cfg = read_json(root() / "config.json")
190
+ command = [binary()]
191
+ if execute:
192
+ command += ["exec", "--ephemeral"]
193
+ if cfg.get("default_model") and not any(
194
+ a in ("-m", "--model") or a.startswith(("--model=", "-m")) for a in args
195
+ ):
196
+ command += ["--model", cfg["default_model"]]
197
+ return subprocess.call(command + args, env=env())
@@ -0,0 +1,49 @@
1
+ """Command-line entry point."""
2
+ import argparse
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from . import actions
8
+
9
+
10
+ def main():
11
+ parser = argparse.ArgumentParser(description="Codex login and session manager")
12
+ sub = parser.add_subparsers(dest="command", required=True)
13
+ for name in ("init", "state", "oauth-status", "oauth-solution", "login-start",
14
+ "login-poll", "save-template", "restore", "mcp"):
15
+ sub.add_parser(name)
16
+ login = sub.add_parser("login")
17
+ methods = login.add_mutually_exclusive_group()
18
+ methods.add_argument("--browser", action="store_true")
19
+ methods.add_argument("--with-api-key", action="store_true")
20
+ sub.add_parser("set-model").add_argument("model", nargs="?")
21
+ for name in ("launch", "exec"):
22
+ sub.add_parser(name).add_argument("args", nargs=argparse.REMAINDER)
23
+ opts = parser.parse_args()
24
+ try:
25
+ if opts.command in ("launch", "exec"):
26
+ args = opts.args[1:] if opts.args[:1] == ["--"] else opts.args
27
+ return actions.launch(args, execute=opts.command == "exec")
28
+ if opts.command == "login":
29
+ flags = ["--with-api-key"] if opts.with_api_key else ([] if opts.browser else ["--device-auth"])
30
+ return subprocess.call([actions.binary(), "login"] + flags, env=actions.env(), cwd=Path.home())
31
+ if opts.command == "mcp":
32
+ try:
33
+ from abstract_claude.mcp import serve_mcp as bridge
34
+ except ImportError:
35
+ parser.error("Install abstract-gpt[mcp] to use the toolserver MCP bridge")
36
+ return bridge() or 0
37
+ if opts.command == "set-model":
38
+ result = actions.set_model(opts.model)
39
+ else:
40
+ names = {"init": "init_root", "state": "build_state", "oauth-status": "auth_status",
41
+ "oauth-solution": "auth_solution"}
42
+ result = getattr(actions, names.get(opts.command, opts.command.replace("-", "_")))()
43
+ print(json.dumps(result, indent=2))
44
+ return 0 if result.get("ok", True) else 1
45
+ except KeyboardInterrupt:
46
+ return 130
47
+ except (OSError, ValueError, subprocess.SubprocessError) as exc:
48
+ print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
49
+ return 1
@@ -0,0 +1,35 @@
1
+ """Detached login worker; parent passes a held flock descriptor."""
2
+ import json
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from . import actions
8
+
9
+
10
+ def main():
11
+ lock_fd = int(sys.argv[1])
12
+ state = {"state": "failed", "message": "Run abstract-gpt login in a terminal for diagnostics."}
13
+ try:
14
+ with open(actions.root() / "login-output.txt", "w") as output:
15
+ process = subprocess.Popen([actions.binary(), "login", "--device-auth"],
16
+ env=actions.env(), stdin=subprocess.DEVNULL,
17
+ stdout=output, stderr=subprocess.STDOUT, cwd=Path.home())
18
+ try:
19
+ code = process.wait(timeout=900)
20
+ except subprocess.TimeoutExpired:
21
+ process.kill()
22
+ process.wait()
23
+ state = {"state": "expired"}
24
+ else:
25
+ if code == 0:
26
+ state = {"state": "authenticated"}
27
+ finally:
28
+ actions.write(actions.root() / "login.json", json.dumps(state))
29
+ if state["state"] == "authenticated":
30
+ (actions.root() / "login-output.txt").unlink(missing_ok=True)
31
+ os.close(lock_fd)
32
+
33
+
34
+ if __name__ == "__main__":
35
+ main()
@@ -0,0 +1,18 @@
1
+ """Optional abstract_toolserver /gpt/* category."""
2
+ from . import actions
3
+
4
+
5
+ def get_toolset():
6
+ return {"gpt": {
7
+ "state": actions.build_state,
8
+ "oauth_status": actions.auth_status,
9
+ "oauth_solution": actions.auth_solution,
10
+ "login_start": actions.login_start,
11
+ "login_poll": actions.login_poll,
12
+ "save_template": actions.save_template,
13
+ "restore": actions.restore,
14
+ "set_model": actions.set_model,
15
+ }}
16
+
17
+
18
+ TOOLSET = get_toolset()
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: abstract-gpt
3
+ Version: 0.1.0
4
+ Summary: Codex login and session management for the abstract toolserver
5
+ Author: jrputkey
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Provides-Extra: mcp
9
+ Requires-Dist: abstract-claude>=0.1.30; extra == "mcp"
10
+
11
+ # abstract-gpt
12
+
13
+ Codex login and session management, usable as a CLI and as the optional `/gpt/*`
14
+ category in `abstract_toolserver`. Requires Python 3.11+, Linux (for HTTP login
15
+ locking), and an installed Codex CLI.
16
+
17
+ ```bash
18
+ pip install -e /srv/pyit/dev/abstract_gpt
19
+ abstract-gpt oauth-status
20
+ abstract-gpt login # device URL + code; approve in your browser
21
+ abstract-gpt launch -- # new interactive conversation
22
+ abstract-gpt exec -- "Explain this repository" # ephemeral noninteractive session
23
+ ```
24
+
25
+ `login --browser` uses normal browser login. `login --with-api-key` reads a key
26
+ from stdin via Codex itself. API key usage has separate API billing. No key is
27
+ required for the ChatGPT subscription login flow.
28
+
29
+ ## Toolserver
30
+
31
+ Install in the toolserver's Python environment, then add alongside its Claude merge:
32
+
33
+ ```python
34
+ _merge_optional_toolset("abstract_gpt.toolset:get_toolset")
35
+ ```
36
+
37
+ Restart the service to register these routes. They use the toolserver's existing
38
+ authentication and execute as its OS user (`vm_mgr` on this host):
39
+
40
+ | Route | Purpose |
41
+ | --- | --- |
42
+ | `/gpt/state` | Wrapper configuration and local login status |
43
+ | `/gpt/oauth_status` | Local credential availability; not a live entitlement probe |
44
+ | `/gpt/oauth_solution` | Login instructions |
45
+ | `/gpt/login_start` | Start device login, or return an existing attempt |
46
+ | `/gpt/login_poll` | Poll state, verification URL and one-time code |
47
+ | `/gpt/save_template` | Save config.toml privately |
48
+ | `/gpt/restore` | Restore config only when missing |
49
+ | `/gpt/set_model` | Set `default_model`; null clears wrapper default |
50
+
51
+ Use POST for mutations. Device login runs in a detached subprocess with a
52
+ 15-minute timeout; a file lock prevents duplicate flows across gunicorn workers.
53
+ Poll until `authenticated`, `failed`, `expired`, or `interrupted`. After service
54
+ restart, an interrupted attempt can be started again. Failed login output remains in the private `AG_ROOT/login-output.txt` for local
55
+ diagnostics, and is cleared at the next attempt. Raw login output and stored
56
+ credentials are never returned by the HTTP tools. The device code is sensitive:
57
+ use the existing authenticated toolserver connection.
58
+
59
+ ## Storage and sessions
60
+
61
+ - `CODEX_HOME`: existing Codex home, default `~/.codex`. Login and launch use the
62
+ same store so Codex manages token refresh. No credentials are copied between users.
63
+ - `AG_ROOT`: wrapper storage, default `~/.local/share/abstract_gpt`, private mode 0700.
64
+ - `AG_CODEX_BIN`: optional executable path. Otherwise `~/.local/bin/codex`, then PATH. The user-local binary takes priority
65
+ to avoid selecting a Snap launcher inside a restricted systemd service.
66
+ - `abstract-gpt init`, `state`, `save-template`, `restore`, `set-model MODEL`,
67
+ `login-start`, `login-poll`, and `oauth-solution` mirror their actions.
68
+ - `launch` starts a new conversation by default; explicit Codex resume flags still
69
+ work. `exec` adds `--ephemeral`. Neither deletes existing sessions or changes
70
+ sandbox/approval settings. Explicit model flags override the wrapper default.
71
+ - A config template can contain user-configured secrets; it stays private and is
72
+ never returned through the API. It does not include auth.json or transcripts.
73
+
74
+ This package does not emulate Claude's durable token export, destructive reset,
75
+ or automatic quota fallback. Codex owns its cached authentication and renewal.
76
+ A service login applies to that service account; other OS users log in separately.
77
+
78
+ ## Toolserver tools inside Codex
79
+
80
+ Optional `pip install 'abstract-gpt[mcp]'` reuses the existing protocol-neutral
81
+ `abstract_claude.mcp` bridge:
82
+
83
+ ```bash
84
+ codex mcp add toolserver -- abstract-gpt mcp
85
+ ```
86
+
87
+ The bridge honors `TOOLSERVER_URL` and the existing Hugpy operator credentials.
88
+
89
+ ## Validation
90
+
91
+ ```bash
92
+ PYTHONPATH=src python -m unittest discover -s tests -v
93
+ ```
94
+
95
+ Tests use a fake Codex executable; they never consume model credits or change real
96
+ credentials. Authentication behavior follows the
97
+ [official Codex documentation](https://developers.openai.com/codex/auth/).
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/abstract_gpt/__init__.py
4
+ src/abstract_gpt/__main__.py
5
+ src/abstract_gpt/actions.py
6
+ src/abstract_gpt/cli.py
7
+ src/abstract_gpt/login_worker.py
8
+ src/abstract_gpt/toolset.py
9
+ src/abstract_gpt.egg-info/PKG-INFO
10
+ src/abstract_gpt.egg-info/SOURCES.txt
11
+ src/abstract_gpt.egg-info/dependency_links.txt
12
+ src/abstract_gpt.egg-info/entry_points.txt
13
+ src/abstract_gpt.egg-info/requires.txt
14
+ src/abstract_gpt.egg-info/top_level.txt
15
+ tests/test_actions.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ abstract-gpt = abstract_gpt.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [mcp]
3
+ abstract-claude>=0.1.30
@@ -0,0 +1 @@
1
+ abstract_gpt
@@ -0,0 +1,95 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ import stat
5
+ import tempfile
6
+ import time
7
+ import unittest
8
+ from unittest.mock import patch
9
+ from abstract_gpt import actions
10
+
11
+
12
+ class ActionsTest(unittest.TestCase):
13
+ def setUp(self):
14
+ self.tmp = tempfile.TemporaryDirectory()
15
+ self.base = Path(self.tmp.name)
16
+ self.fake = self.base / 'codex'
17
+ self.fake.write_text('''#!/usr/bin/env python3
18
+ import os, pathlib, sys, time
19
+ home = pathlib.Path(os.environ['CODEX_HOME'])
20
+ if sys.argv[1:] == ['login', 'status']:
21
+ print('secret-status-output')
22
+ sys.exit(0 if (home / 'authenticated').exists() else 1)
23
+ print('https://auth.openai.com/codex/device', flush=True)
24
+ print('ABCD-12345', flush=True)
25
+ print('secret-refresh-token', flush=True)
26
+ time.sleep(0.4)
27
+ home.mkdir(exist_ok=True)
28
+ (home / 'authenticated').touch()
29
+ ''')
30
+ self.fake.chmod(0o700)
31
+ self.env = patch.dict(os.environ, {'AG_ROOT': str(self.base / 'data'),
32
+ 'CODEX_HOME': str(self.base / 'home'), 'AG_CODEX_BIN': str(self.fake)})
33
+ self.env.start()
34
+
35
+ def tearDown(self):
36
+ self.env.stop()
37
+ self.tmp.cleanup()
38
+
39
+ def test_device_login_shared_worker_and_redaction(self):
40
+ self.assertEqual(actions.login_start()['state'], 'pending')
41
+ self.assertEqual(actions.login_start()['state'], 'pending')
42
+ seen_code = False
43
+ for _ in range(100):
44
+ result = actions.login_poll()
45
+ self.assertNotIn('secret', json.dumps(result))
46
+ seen_code |= result.get('user_code') == 'ABCD-12345'
47
+ if result['state'] != 'pending':
48
+ break
49
+ time.sleep(0.03)
50
+ self.assertTrue(seen_code)
51
+ self.assertEqual(result['state'], 'authenticated')
52
+ self.assertTrue(actions.auth_status()['authenticated'])
53
+ self.assertFalse((actions.root() / 'login-output.txt').exists())
54
+ self.assertEqual(stat.S_IMODE(actions.root().stat().st_mode), 0o700)
55
+
56
+ def test_user_binary_preferred_over_snap(self):
57
+ local = self.base / '.local/bin/codex'
58
+ local.parent.mkdir(parents=True)
59
+ local.touch()
60
+ with patch.dict(os.environ, {'AG_CODEX_BIN': ''}), \
61
+ patch('pathlib.Path.home', return_value=self.base), \
62
+ patch('shutil.which', return_value='/snap/bin/codex'):
63
+ self.assertEqual(actions.binary(), str(local))
64
+
65
+ def test_dead_worker(self):
66
+ actions.init_root()
67
+ actions.write(actions.root() / 'login.json', '{"state":"pending"}')
68
+ self.assertEqual(actions.login_poll()['state'], 'interrupted')
69
+
70
+ def test_template_preserves_auth_and_live_config(self):
71
+ home = actions.codex_home()
72
+ home.mkdir()
73
+ (home / 'auth.json').write_text('secret')
74
+ (home / 'config.toml').write_text('model = "example"\n')
75
+ actions.save_template()
76
+ self.assertFalse((actions.root() / 'template/auth.json').exists())
77
+ (home / 'config.toml').write_text('model = "changed"\n')
78
+ self.assertFalse(actions.restore()['changed'])
79
+ (home / 'config.toml').unlink()
80
+ self.assertTrue(actions.restore()['changed'])
81
+ self.assertEqual((home / 'auth.json').read_text(), 'secret')
82
+
83
+ def test_launch_flags_and_status_redaction(self):
84
+ actions.set_model('configured')
85
+ with patch('subprocess.call', return_value=7) as call:
86
+ self.assertEqual(actions.launch(['--model', 'explicit', 'hello'], True), 7)
87
+ self.assertEqual(call.call_args.args[0],
88
+ [str(self.fake), 'exec', '--ephemeral', '--model', 'explicit', 'hello'])
89
+ actions.launch(['hello'])
90
+ self.assertEqual(call.call_args.args[0], [str(self.fake), '--model', 'configured', 'hello'])
91
+ self.assertNotIn('secret', json.dumps(actions.auth_status()))
92
+
93
+
94
+ if __name__ == '__main__':
95
+ unittest.main()