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.
- abstract_gpt-0.1.0/PKG-INFO +97 -0
- abstract_gpt-0.1.0/README.md +87 -0
- abstract_gpt-0.1.0/pyproject.toml +19 -0
- abstract_gpt-0.1.0/setup.cfg +4 -0
- abstract_gpt-0.1.0/src/abstract_gpt/__init__.py +2 -0
- abstract_gpt-0.1.0/src/abstract_gpt/__main__.py +2 -0
- abstract_gpt-0.1.0/src/abstract_gpt/actions.py +197 -0
- abstract_gpt-0.1.0/src/abstract_gpt/cli.py +49 -0
- abstract_gpt-0.1.0/src/abstract_gpt/login_worker.py +35 -0
- abstract_gpt-0.1.0/src/abstract_gpt/toolset.py +18 -0
- abstract_gpt-0.1.0/src/abstract_gpt.egg-info/PKG-INFO +97 -0
- abstract_gpt-0.1.0/src/abstract_gpt.egg-info/SOURCES.txt +15 -0
- abstract_gpt-0.1.0/src/abstract_gpt.egg-info/dependency_links.txt +1 -0
- abstract_gpt-0.1.0/src/abstract_gpt.egg-info/entry_points.txt +2 -0
- abstract_gpt-0.1.0/src/abstract_gpt.egg-info/requires.txt +3 -0
- abstract_gpt-0.1.0/src/abstract_gpt.egg-info/top_level.txt +1 -0
- abstract_gpt-0.1.0/tests/test_actions.py +95 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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()
|