subs-pool 0.1.0__py3-none-any.whl
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.
- subs_pool/__init__.py +3 -0
- subs_pool/__main__.py +4 -0
- subs_pool/agent_cli.py +69 -0
- subs_pool/cli.py +77 -0
- subs_pool/cli_client.py +365 -0
- subs_pool/home.py +41 -0
- subs_pool/modules/__init__.py +1 -0
- subs_pool/modules/codex/CLI_CONTRACT.md +44 -0
- subs_pool/modules/codex/CONTRACT.md +52 -0
- subs_pool/modules/codex/__init__.py +1 -0
- subs_pool/modules/codex/accounts.py +289 -0
- subs_pool/modules/codex/auth_codex.py +428 -0
- subs_pool/modules/codex/chain.py +187 -0
- subs_pool/modules/codex/cli.py +371 -0
- subs_pool/modules/codex/device_login.py +273 -0
- subs_pool/modules/codex/errors.py +28 -0
- subs_pool/modules/codex/hashing.py +96 -0
- subs_pool/modules/codex/home.py +37 -0
- subs_pool/modules/codex/quota.py +524 -0
- subs_pool/modules/codex/quota_refresh.py +607 -0
- subs_pool/modules/codex/quota_store.py +656 -0
- subs_pool/modules/codex/routing.py +128 -0
- subs_pool/modules/codex/server.py +536 -0
- subs_pool/modules/codex/sse.py +124 -0
- subs_pool/modules/codex/tui.py +1143 -0
- subs_pool/modules/codex/tui_adapter.py +97 -0
- subs_pool/modules/codex/upstream.py +198 -0
- subs_pool/output.py +22 -0
- subs_pool/registry.py +53 -0
- subs_pool/tui.py +21 -0
- subs_pool-0.1.0.dist-info/METADATA +142 -0
- subs_pool-0.1.0.dist-info/RECORD +36 -0
- subs_pool-0.1.0.dist-info/WHEEL +4 -0
- subs_pool-0.1.0.dist-info/entry_points.txt +3 -0
- subs_pool-0.1.0.dist-info/licenses/LICENSE +201 -0
- subs_pool-0.1.0.dist-info/licenses/NOTICE +34 -0
subs_pool/__init__.py
ADDED
subs_pool/__main__.py
ADDED
subs_pool/agent_cli.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Machine-only ``subspool-cli`` entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .output import envelope, error_object
|
|
11
|
+
from .registry import BUILTIN_MODULES
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _emit(command: str, rc: int, data: Any = None, *, error: dict[str, Any] | None = None) -> int:
|
|
15
|
+
if rc != 0 and error is None:
|
|
16
|
+
refresh_error = data.get("refresh", {}).get("error") if isinstance(data, dict) else None
|
|
17
|
+
if isinstance(refresh_error, dict):
|
|
18
|
+
error = error_object(
|
|
19
|
+
str(refresh_error.get("code", "operation_unavailable")),
|
|
20
|
+
str(refresh_error.get("message", "operation did not complete")),
|
|
21
|
+
)
|
|
22
|
+
else:
|
|
23
|
+
error = error_object("operation_unavailable", "operation did not complete")
|
|
24
|
+
print(json.dumps(envelope(command, ok=rc == 0, data=data, error=error), ensure_ascii=False, separators=(",", ":"), allow_nan=False))
|
|
25
|
+
return rc
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(argv: list[str] | None = None) -> int:
|
|
29
|
+
args = list(argv) if argv is not None else sys.argv[1:]
|
|
30
|
+
if not args:
|
|
31
|
+
return _emit("help", 0, {"usage": "subspool-cli [--help|--version] codex COMMAND"})
|
|
32
|
+
if args == ["--help"]:
|
|
33
|
+
return _emit("help", 0, {"usage": "subspool-cli codex account|status|quota"})
|
|
34
|
+
if args == ["--version"]:
|
|
35
|
+
return _emit("version", 0, {"version": __version__})
|
|
36
|
+
if args[0] == "modules":
|
|
37
|
+
if args[1:] == ["--help"]:
|
|
38
|
+
return _emit("modules", 0, {"usage": "subspool-cli modules [list]"})
|
|
39
|
+
if args[1:] not in ([], ["list"]):
|
|
40
|
+
return _emit("modules", 2, None, error=error_object("invalid_syntax", "usage: subspool-cli modules [list]"))
|
|
41
|
+
return _emit("modules", 0, {"modules": [{"id": m.id, "display_name": m.display_name} for m in BUILTIN_MODULES]})
|
|
42
|
+
if args[0] == "tui":
|
|
43
|
+
return _emit("tui", 2, None, error=error_object("unsupported_command", "the machine executable never opens the TUI"))
|
|
44
|
+
if args[0] != "codex":
|
|
45
|
+
return _emit("help", 2, None, error=error_object("unknown_module", "unknown subscription module"))
|
|
46
|
+
command = "codex"
|
|
47
|
+
if len(args) > 1:
|
|
48
|
+
if args[1] != "--help":
|
|
49
|
+
command = "codex." + args[1]
|
|
50
|
+
if args[1] == "account" and len(args) > 2 and args[2] != "--help":
|
|
51
|
+
command += "." + args[2]
|
|
52
|
+
try:
|
|
53
|
+
from .modules.codex.cli import machine_operation
|
|
54
|
+
|
|
55
|
+
rc, data = machine_operation(args)
|
|
56
|
+
if isinstance(data, dict) and isinstance(data.get("error"), dict):
|
|
57
|
+
return _emit(command, rc, None, error=data["error"])
|
|
58
|
+
return _emit(command, rc, data)
|
|
59
|
+
except KeyboardInterrupt:
|
|
60
|
+
return _emit(command, 130, None, error=error_object("interrupted", "operation interrupted"))
|
|
61
|
+
except Exception:
|
|
62
|
+
return _emit(command, 5, None, error=error_object("internal_error", "unexpected internal failure"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
raise SystemExit(main())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = ["main"]
|
subs_pool/cli.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Human ``subspool`` dispatcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .registry import BUILTIN_MODULES, DEFAULT_MODULE_ID, UnknownModuleError, get_module
|
|
9
|
+
|
|
10
|
+
_USAGE = """usage: subspool [tui | modules [list] | codex COMMAND ...]
|
|
11
|
+
|
|
12
|
+
With no arguments, open the Codex account TUI.
|
|
13
|
+
tui open the TUI
|
|
14
|
+
modules [list] list static built-in modules
|
|
15
|
+
codex COMMAND run a human Codex operation
|
|
16
|
+
--version print the version
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _error(message: str, code: int = 2) -> int:
|
|
21
|
+
print(f"error: {message}", file=sys.stderr)
|
|
22
|
+
return code
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _run_tui(module_id: str) -> int:
|
|
26
|
+
try:
|
|
27
|
+
module = get_module(module_id)
|
|
28
|
+
except UnknownModuleError as exc:
|
|
29
|
+
return _error(str(exc))
|
|
30
|
+
from .tui import run_tui
|
|
31
|
+
|
|
32
|
+
run_tui(module.id)
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _list_modules() -> int:
|
|
37
|
+
for module in BUILTIN_MODULES:
|
|
38
|
+
marker = " (default)" if module.id == DEFAULT_MODULE_ID else ""
|
|
39
|
+
print(f"{module.id:<16} {module.display_name}{marker}")
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main(argv: list[str] | None = None) -> int:
|
|
44
|
+
args = list(argv) if argv is not None else sys.argv[1:]
|
|
45
|
+
if not args:
|
|
46
|
+
return _run_tui(DEFAULT_MODULE_ID)
|
|
47
|
+
if args == ["--help"] or args == ["-h"]:
|
|
48
|
+
print(_USAGE, end="")
|
|
49
|
+
return 0
|
|
50
|
+
if args == ["--version"]:
|
|
51
|
+
print(f"subspool {__version__}")
|
|
52
|
+
return 0
|
|
53
|
+
if args[0] == "modules":
|
|
54
|
+
rest = args[1:]
|
|
55
|
+
if rest in ([], ["list"]):
|
|
56
|
+
return _list_modules()
|
|
57
|
+
return _error("usage: subspool modules [list]")
|
|
58
|
+
if args[0] == "tui":
|
|
59
|
+
if args[1:] in ([], ["codex"]):
|
|
60
|
+
return _run_tui(DEFAULT_MODULE_ID)
|
|
61
|
+
if args[1:] in (["--help"], ["-h"]):
|
|
62
|
+
print("usage: subspool tui [codex]")
|
|
63
|
+
return 0
|
|
64
|
+
return _error("usage: subspool tui [codex]")
|
|
65
|
+
try:
|
|
66
|
+
module = get_module(args[0])
|
|
67
|
+
except UnknownModuleError as exc:
|
|
68
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
69
|
+
return 2
|
|
70
|
+
return module.command_main(args[1:])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
raise SystemExit(main())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = ["main"]
|
subs_pool/cli_client.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Thin async subprocess wrapper around a subs-pool module CLI contract.
|
|
2
|
+
|
|
3
|
+
This module owns no account/token/auth state and makes no provider HTTP calls.
|
|
4
|
+
Machine-safe operations use the JSON-only ``subspool-cli`` module entrypoint;
|
|
5
|
+
the human TUI's device-login stream uses the human dispatcher with a private
|
|
6
|
+
JSONL adapter. The subprocess spawn function is injectable so tests can supply
|
|
7
|
+
a fake process without starting a real one.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
17
|
+
from typing import Any, Protocol
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CLIError(Exception):
|
|
21
|
+
"""Raised when a subs-pool module CLI exits non-zero or emits bad output."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str, *, returncode: int | None = None, data: dict | None = None) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.message = message
|
|
26
|
+
self.returncode = returncode
|
|
27
|
+
self.data = data
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Process(Protocol):
|
|
31
|
+
"""The minimal subset of :class:`asyncio.subprocess.Process` used here."""
|
|
32
|
+
|
|
33
|
+
returncode: int | None
|
|
34
|
+
stdout: Any
|
|
35
|
+
stderr: Any
|
|
36
|
+
|
|
37
|
+
async def communicate(self) -> tuple[bytes, bytes]: ...
|
|
38
|
+
|
|
39
|
+
async def wait(self) -> int: ...
|
|
40
|
+
|
|
41
|
+
def terminate(self) -> None: ...
|
|
42
|
+
|
|
43
|
+
def kill(self) -> None: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
Spawner = Callable[[Sequence[str]], Awaitable[Process]]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _default_spawn(args: Sequence[str]) -> Process:
|
|
50
|
+
return await asyncio.create_subprocess_exec(
|
|
51
|
+
sys.executable,
|
|
52
|
+
"-m",
|
|
53
|
+
"subs_pool.agent_cli",
|
|
54
|
+
*args,
|
|
55
|
+
stdout=asyncio.subprocess.PIPE,
|
|
56
|
+
stderr=asyncio.subprocess.PIPE,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _default_human_spawn(args: Sequence[str]) -> Process:
|
|
61
|
+
"""Run the human dispatcher for the TUI-only login event stream."""
|
|
62
|
+
return await asyncio.create_subprocess_exec(
|
|
63
|
+
sys.executable,
|
|
64
|
+
"-m",
|
|
65
|
+
"subs_pool.cli",
|
|
66
|
+
*args,
|
|
67
|
+
stdout=asyncio.subprocess.PIPE,
|
|
68
|
+
stderr=asyncio.subprocess.PIPE,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_MAX_RAW_ERROR_CHARS = 2000
|
|
73
|
+
_LOGIN_EXIT_TIMEOUT_SECONDS = 5.0
|
|
74
|
+
_SENSITIVE_ASSIGNMENT = re.compile(
|
|
75
|
+
r"(?i)\b(access[_-]?token|refresh[_-]?token|id[_-]?token|password|secret|api[_-]?key|authorization)"
|
|
76
|
+
r"(\s*[:=]\s*)([\"']?)([^\"'\s,}]+)"
|
|
77
|
+
)
|
|
78
|
+
_SENSITIVE_BEARER = re.compile(r"(?i)\bBearer\s+[^\s,}]+")
|
|
79
|
+
_SENSITIVE_SECRET_PREFIX = re.compile(r"\b(?:sk|sess|rt|at)-[A-Za-z0-9_-]{8,}\b")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _safe_text(text: str) -> str:
|
|
83
|
+
"""Bound and redact common credential forms in fallback diagnostics."""
|
|
84
|
+
text = _SENSITIVE_ASSIGNMENT.sub(r"\1\2<redacted>", text)
|
|
85
|
+
text = _SENSITIVE_BEARER.sub("Bearer <redacted>", text)
|
|
86
|
+
text = _SENSITIVE_SECRET_PREFIX.sub("<redacted>", text)
|
|
87
|
+
return text if len(text) <= _MAX_RAW_ERROR_CHARS else text[:_MAX_RAW_ERROR_CHARS] + "… (truncated)"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _parse_error(stderr: bytes) -> str:
|
|
91
|
+
text = stderr.decode(errors="replace").strip()
|
|
92
|
+
if not text:
|
|
93
|
+
return "subs-pool module CLI exited with an error (no message)"
|
|
94
|
+
# Contract errors are one JSON object on the last non-empty stderr line.
|
|
95
|
+
# A broken installation may instead produce a traceback; retain a bounded,
|
|
96
|
+
# redacted diagnostic rather than dumping unbounded/provider-tainted text.
|
|
97
|
+
try:
|
|
98
|
+
payload = json.loads(text.splitlines()[-1])
|
|
99
|
+
except json.JSONDecodeError:
|
|
100
|
+
return _safe_text(text)
|
|
101
|
+
if isinstance(payload, dict):
|
|
102
|
+
if isinstance(payload.get("error"), str):
|
|
103
|
+
return _safe_text(payload["error"])
|
|
104
|
+
if isinstance(payload.get("error"), dict):
|
|
105
|
+
return _safe_text(str(payload["error"].get("message", "module CLI failed")))
|
|
106
|
+
return "subs-pool module CLI returned a malformed error response"
|
|
107
|
+
return _safe_text(text)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _safe_login_event(event: dict) -> dict:
|
|
111
|
+
"""Project JSONL events onto the small non-secret frontend contract."""
|
|
112
|
+
kind = event.get("event")
|
|
113
|
+
if kind == "authorization_required":
|
|
114
|
+
return {
|
|
115
|
+
"event": "authorization_required",
|
|
116
|
+
"verification_uri": event.get("verification_uri", ""),
|
|
117
|
+
"user_code": event.get("user_code", ""),
|
|
118
|
+
"expires_in": event.get("expires_in"),
|
|
119
|
+
"interval": event.get("interval"),
|
|
120
|
+
}
|
|
121
|
+
if kind == "completed":
|
|
122
|
+
account = event.get("account")
|
|
123
|
+
if not isinstance(account, dict):
|
|
124
|
+
raise CLIError("malformed login event: completed event lacks account")
|
|
125
|
+
return {
|
|
126
|
+
"event": "completed",
|
|
127
|
+
"account": {
|
|
128
|
+
key: account[key]
|
|
129
|
+
for key in ("ref", "enabled", "weight", "auth_present", "quota")
|
|
130
|
+
if key in account
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
# Unknown events are not part of the contract. Preserve only their type so
|
|
134
|
+
# a broken child cannot smuggle arbitrary provider payloads into the UI.
|
|
135
|
+
return {"event": str(kind) if kind is not None else "unknown"}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def _spawn_or_raise(spawn: Spawner, args: Sequence[str]) -> Process:
|
|
139
|
+
try:
|
|
140
|
+
return await spawn(args)
|
|
141
|
+
except FileNotFoundError as exc:
|
|
142
|
+
raise CLIError(
|
|
143
|
+
"subs-pool CLI not found: is subs-pool installed for this interpreter?"
|
|
144
|
+
) from exc
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _terminate_process(proc: Process) -> None:
|
|
148
|
+
"""Best-effort terminate and reap a local child process this wrapper owns.
|
|
149
|
+
|
|
150
|
+
This only touches our local CLI subprocess; it never claims to cancel a
|
|
151
|
+
request already submitted upstream to a provider.
|
|
152
|
+
"""
|
|
153
|
+
if proc.returncode is not None:
|
|
154
|
+
return
|
|
155
|
+
try:
|
|
156
|
+
proc.terminate()
|
|
157
|
+
except (OSError, ProcessLookupError):
|
|
158
|
+
return
|
|
159
|
+
try:
|
|
160
|
+
await asyncio.wait_for(proc.wait(), timeout=5)
|
|
161
|
+
except asyncio.TimeoutError:
|
|
162
|
+
try:
|
|
163
|
+
proc.kill()
|
|
164
|
+
except (OSError, ProcessLookupError):
|
|
165
|
+
return
|
|
166
|
+
await proc.wait()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class LoginStream:
|
|
170
|
+
"""One in-flight device-login event stream for the human frontend.
|
|
171
|
+
|
|
172
|
+
Async-iterate this to receive parsed JSONL events as they arrive. A
|
|
173
|
+
``completed`` line is held until the child has drained its pipes and exited
|
|
174
|
+
with code zero; a line alone is never treated as success. :meth:`cancel`
|
|
175
|
+
stops only the local CLI subprocess.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def __init__(self, spawn: Spawner, args: Sequence[str]) -> None:
|
|
179
|
+
self._spawn = spawn
|
|
180
|
+
self._args = list(args)
|
|
181
|
+
self._proc: Process | None = None
|
|
182
|
+
self._done = False
|
|
183
|
+
self._cancel_requested = False
|
|
184
|
+
|
|
185
|
+
def __aiter__(self) -> "LoginStream":
|
|
186
|
+
return self
|
|
187
|
+
|
|
188
|
+
async def __anext__(self) -> dict:
|
|
189
|
+
if self._done:
|
|
190
|
+
raise StopAsyncIteration
|
|
191
|
+
if self._proc is None:
|
|
192
|
+
proc = await _spawn_or_raise(self._spawn, self._args)
|
|
193
|
+
self._proc = proc
|
|
194
|
+
if self._cancel_requested:
|
|
195
|
+
await _terminate_process(proc)
|
|
196
|
+
raise StopAsyncIteration
|
|
197
|
+
assert self._proc.stdout is not None
|
|
198
|
+
raw = await self._proc.stdout.readline()
|
|
199
|
+
if not raw:
|
|
200
|
+
try:
|
|
201
|
+
returncode = await asyncio.wait_for(self._proc.wait(), timeout=_LOGIN_EXIT_TIMEOUT_SECONDS)
|
|
202
|
+
except asyncio.TimeoutError:
|
|
203
|
+
await _terminate_process(self._proc)
|
|
204
|
+
self._done = True
|
|
205
|
+
raise CLIError("login CLI did not exit after closing stdout")
|
|
206
|
+
self._done = True
|
|
207
|
+
if returncode != 0:
|
|
208
|
+
stderr = await self._proc.stderr.read() if self._proc.stderr is not None else b""
|
|
209
|
+
raise CLIError(_parse_error(stderr), returncode=returncode)
|
|
210
|
+
raise StopAsyncIteration
|
|
211
|
+
|
|
212
|
+
line = raw.decode(errors="replace").strip()
|
|
213
|
+
if not line:
|
|
214
|
+
return await self.__anext__()
|
|
215
|
+
try:
|
|
216
|
+
event = json.loads(line)
|
|
217
|
+
except json.JSONDecodeError as exc:
|
|
218
|
+
self._done = True
|
|
219
|
+
await _terminate_process(self._proc)
|
|
220
|
+
raise CLIError("malformed login event: could not parse JSON") from exc
|
|
221
|
+
if not isinstance(event, dict):
|
|
222
|
+
self._done = True
|
|
223
|
+
await _terminate_process(self._proc)
|
|
224
|
+
raise CLIError(
|
|
225
|
+
f"malformed login event: expected a JSON object, got {type(event).__name__}"
|
|
226
|
+
)
|
|
227
|
+
if event.get("event") == "completed":
|
|
228
|
+
try:
|
|
229
|
+
safe_event = _safe_login_event(event)
|
|
230
|
+
except CLIError:
|
|
231
|
+
self._done = True
|
|
232
|
+
await _terminate_process(self._proc)
|
|
233
|
+
raise
|
|
234
|
+
return await self._finish_completed(safe_event)
|
|
235
|
+
return _safe_login_event(event)
|
|
236
|
+
|
|
237
|
+
async def _finish_completed(self, event: dict) -> dict:
|
|
238
|
+
"""Trust ``completed`` only after clean child termination.
|
|
239
|
+
|
|
240
|
+
``communicate`` drains both pipes while waiting. The bounded wait also
|
|
241
|
+
handles a provider-poll process that emits a completion line and then
|
|
242
|
+
hangs, without leaving a child behind or claiming successful login.
|
|
243
|
+
"""
|
|
244
|
+
assert self._proc is not None
|
|
245
|
+
try:
|
|
246
|
+
_stdout, stderr = await asyncio.wait_for(
|
|
247
|
+
self._proc.communicate(), timeout=_LOGIN_EXIT_TIMEOUT_SECONDS
|
|
248
|
+
)
|
|
249
|
+
except asyncio.TimeoutError as exc:
|
|
250
|
+
await _terminate_process(self._proc)
|
|
251
|
+
self._done = True
|
|
252
|
+
raise CLIError("login CLI did not exit cleanly after completion") from exc
|
|
253
|
+
except asyncio.CancelledError:
|
|
254
|
+
await _terminate_process(self._proc)
|
|
255
|
+
self._done = True
|
|
256
|
+
raise
|
|
257
|
+
self._done = True
|
|
258
|
+
returncode = self._proc.returncode
|
|
259
|
+
if returncode is None:
|
|
260
|
+
returncode = await self._proc.wait()
|
|
261
|
+
if returncode != 0:
|
|
262
|
+
raise CLIError(_parse_error(stderr), returncode=returncode)
|
|
263
|
+
return event
|
|
264
|
+
|
|
265
|
+
async def cancel(self) -> None:
|
|
266
|
+
"""Terminate the in-flight login subprocess, if any (idempotent)."""
|
|
267
|
+
if self._done:
|
|
268
|
+
return
|
|
269
|
+
self._done = True
|
|
270
|
+
proc = self._proc
|
|
271
|
+
if proc is None:
|
|
272
|
+
self._cancel_requested = True
|
|
273
|
+
return
|
|
274
|
+
await _terminate_process(proc)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class CLIClient:
|
|
278
|
+
"""Async wrapper for one module's machine command surface."""
|
|
279
|
+
|
|
280
|
+
def __init__(
|
|
281
|
+
self,
|
|
282
|
+
module_id: str,
|
|
283
|
+
spawn: Spawner | None = None,
|
|
284
|
+
*,
|
|
285
|
+
timeout: float = 30.0,
|
|
286
|
+
) -> None:
|
|
287
|
+
self.module_id = module_id
|
|
288
|
+
self._spawn = spawn or _default_spawn
|
|
289
|
+
# Bounds one-shot commands (status/quota/pool/import/etc.), not the
|
|
290
|
+
# device-login provider poll window owned by the CLI subprocess.
|
|
291
|
+
self._timeout = timeout
|
|
292
|
+
|
|
293
|
+
async def _run_json(self, args: Sequence[str]) -> dict:
|
|
294
|
+
proc = await _spawn_or_raise(
|
|
295
|
+
self._spawn, [self.module_id, *args]
|
|
296
|
+
)
|
|
297
|
+
try:
|
|
298
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=self._timeout)
|
|
299
|
+
except asyncio.TimeoutError as exc:
|
|
300
|
+
await _terminate_process(proc)
|
|
301
|
+
raise CLIError(f"subs-pool module CLI timed out after {self._timeout:g}s") from exc
|
|
302
|
+
except asyncio.CancelledError:
|
|
303
|
+
await _terminate_process(proc)
|
|
304
|
+
raise
|
|
305
|
+
if proc.returncode != 0:
|
|
306
|
+
try:
|
|
307
|
+
payload = json.loads(stdout.decode(errors="replace"))
|
|
308
|
+
error = payload.get("error") if isinstance(payload, dict) else None
|
|
309
|
+
if isinstance(error, dict):
|
|
310
|
+
raise CLIError(str(error.get("message", "module CLI failed")), returncode=proc.returncode, data=payload.get("data") if isinstance(payload.get("data"), dict) else None)
|
|
311
|
+
except json.JSONDecodeError:
|
|
312
|
+
pass
|
|
313
|
+
raise CLIError(_parse_error(stderr), returncode=proc.returncode)
|
|
314
|
+
try:
|
|
315
|
+
payload = json.loads(stdout.decode(errors="replace"))
|
|
316
|
+
except json.JSONDecodeError as exc:
|
|
317
|
+
raise CLIError(f"malformed CLI output: could not parse JSON ({len(stdout)} bytes)") from exc
|
|
318
|
+
if not isinstance(payload, dict):
|
|
319
|
+
raise CLIError(
|
|
320
|
+
f"malformed CLI output: expected a JSON object, got {type(payload).__name__}"
|
|
321
|
+
)
|
|
322
|
+
if payload.get("schema_version") == 1 and "ok" in payload:
|
|
323
|
+
if not payload.get("ok"):
|
|
324
|
+
if isinstance(payload.get("data"), dict):
|
|
325
|
+
return payload["data"]
|
|
326
|
+
error = payload.get("error")
|
|
327
|
+
message = error.get("message", "module CLI failed") if isinstance(error, dict) else "module CLI failed"
|
|
328
|
+
raise CLIError(str(message), returncode=proc.returncode)
|
|
329
|
+
data = payload.get("data")
|
|
330
|
+
return data if isinstance(data, dict) else {}
|
|
331
|
+
return payload
|
|
332
|
+
|
|
333
|
+
async def accounts_list(self) -> dict:
|
|
334
|
+
return await self._run_json(["account", "list"])
|
|
335
|
+
|
|
336
|
+
async def accounts_import(self, ref: str, path: str, *, weight: int = 1) -> dict:
|
|
337
|
+
return await self._run_json(
|
|
338
|
+
["account", "import", ref, "--path", path, "--weight", str(weight)]
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
async def pool_enable(self, ref: str) -> dict:
|
|
342
|
+
return await self._run_json(["account", "enable", ref])
|
|
343
|
+
|
|
344
|
+
async def pool_disable(self, ref: str) -> dict:
|
|
345
|
+
return await self._run_json(["account", "disable", ref])
|
|
346
|
+
|
|
347
|
+
async def pool_weight(self, ref: str, weight: int) -> dict:
|
|
348
|
+
return await self._run_json(["account", "weight", ref, str(weight)])
|
|
349
|
+
|
|
350
|
+
async def status(self) -> dict:
|
|
351
|
+
return await self._run_json(["status"])
|
|
352
|
+
|
|
353
|
+
async def quota(self) -> dict:
|
|
354
|
+
return await self._run_json(["quota"])
|
|
355
|
+
|
|
356
|
+
def login(self, ref: str) -> LoginStream:
|
|
357
|
+
"""Start login lazily, on first iteration."""
|
|
358
|
+
spawn = _default_human_spawn if self._spawn is _default_spawn else self._spawn
|
|
359
|
+
return LoginStream(
|
|
360
|
+
spawn,
|
|
361
|
+
[self.module_id, "account", "login", ref, "--device", "--events-jsonl"],
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
__all__ = ["CLIClient", "CLIError", "LoginStream", "Process", "Spawner"]
|
subs_pool/home.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Canonical data-root resolution owned by the subscription-pool core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
DEFAULT_HOME = "~/.subs-pool"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HomeError(ValueError):
|
|
12
|
+
"""The selected subscription-pool root is not usable."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _resolve_env_path(name: str, default: str) -> Path:
|
|
16
|
+
value = os.environ.get(name, default)
|
|
17
|
+
if value == "":
|
|
18
|
+
raise HomeError(f"{name} must not be empty")
|
|
19
|
+
path = Path(value).expanduser()
|
|
20
|
+
if not path.is_absolute():
|
|
21
|
+
path = Path.cwd() / path
|
|
22
|
+
return path.resolve()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def data_home(*, create: bool = True) -> Path:
|
|
26
|
+
"""Return the canonical generic root.
|
|
27
|
+
|
|
28
|
+
The core never searches for old roots. Creation is explicit so machine
|
|
29
|
+
help/version paths can remain state-free while stateful operations create
|
|
30
|
+
only their selected root.
|
|
31
|
+
"""
|
|
32
|
+
home = _resolve_env_path("SUBS_POOL_HOME", DEFAULT_HOME)
|
|
33
|
+
if create:
|
|
34
|
+
existed = home.exists()
|
|
35
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
if not existed and os.name != "nt":
|
|
37
|
+
home.chmod(0o700)
|
|
38
|
+
return home
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = ["DEFAULT_HOME", "HomeError", "data_home"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Built-in subscription modules."""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Codex CLI contract
|
|
2
|
+
|
|
3
|
+
Human operations are selected by `subspool codex`. Agent operations are
|
|
4
|
+
selected by `subspool-cli codex` and are wrapped by the root JSON envelope.
|
|
5
|
+
|
|
6
|
+
```text
|
|
7
|
+
account import ID --path AUTH.json [--weight N]
|
|
8
|
+
account list
|
|
9
|
+
account login ID --device # human only
|
|
10
|
+
account enable ID
|
|
11
|
+
account disable ID
|
|
12
|
+
account weight ID N
|
|
13
|
+
status
|
|
14
|
+
quota
|
|
15
|
+
serve --listen 127.0.0.1:8765 # human only
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Account list is metadata-only and never reports quota readiness. Account IDs
|
|
19
|
+
are any non-empty path-safe strings; `/`, `\`, `.`, and `..` are rejected,
|
|
20
|
+
with no additional ASCII or length restriction. Existing IDs remain readable.
|
|
21
|
+
Auth paths are explicit references; credentials
|
|
22
|
+
are not copied into a generic home. There is no logout/remove command in this
|
|
23
|
+
release.
|
|
24
|
+
|
|
25
|
+
`quota` always requests the shared bounded pool-wide refresh, whether or not a
|
|
26
|
+
proxy is running. Its data includes `module`, resolved `codex_root`,
|
|
27
|
+
`generated_at`, committed `snapshot_revision`, refresh outcome, account rows,
|
|
28
|
+
current sample, historical `last_success`, timestamps, freshness/age,
|
|
29
|
+
eligibility, exclusion reason, and sanitized error. Missing/failed/checking or
|
|
30
|
+
stale samples are not current values. A failed required refresh returns exit
|
|
31
|
+
3 while retaining the readable partial snapshot in Agent `data`.
|
|
32
|
+
|
|
33
|
+
The WHAM adapter makes one direct request per account, retains the baseline
|
|
34
|
+
nullable secondary-window parsing, calculates remaining as `100 - used` only
|
|
35
|
+
for valid finite percentages, and never fabricates zero for unknown. Provider
|
|
36
|
+
bodies, auth headers, tokens, device codes, and credential paths never enter
|
|
37
|
+
machine output or errors.
|
|
38
|
+
|
|
39
|
+
Agent `login` and `serve` return stable `prompt_required`/`unsupported_command`
|
|
40
|
+
errors; they never prompt, open a browser, bind a port, or write human text.
|
|
41
|
+
Agent parser/usage errors likewise produce one JSON object plus newline. Exit
|
|
42
|
+
codes are 0 success, 2 syntax/unsupported, 3 unavailable/incomplete quota or
|
|
43
|
+
auth/network work, 4 local state/configuration, 5 unexpected internal failure,
|
|
44
|
+
and 130 interruption.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Codex module contract
|
|
2
|
+
|
|
3
|
+
Codex is the only built-in provider module. It owns auth, account
|
|
4
|
+
configuration, WHAM parsing, quota sidecar/freshness/refresh, eligibility,
|
|
5
|
+
routing, affinity, upstream wire behavior, SSE, and the local Responses
|
|
6
|
+
server. Generic `subs_pool` code does not inspect any of these facts.
|
|
7
|
+
|
|
8
|
+
The module keeps the baseline two-rule scheduler: a full input-prefix and
|
|
9
|
+
effective-config match stays on its current in-memory chain account; a miss is
|
|
10
|
+
a weighted choice. Full-prefix hashing, UUID session labels, chain capacity,
|
|
11
|
+
successful-only commits, and response normalization remain in the relocated
|
|
12
|
+
baseline files. A failed, partial, cancelled, or incomplete generation never
|
|
13
|
+
commits and is never retried or replayed.
|
|
14
|
+
|
|
15
|
+
Before selection, the module reads account configuration and
|
|
16
|
+
`quota-v1.json` together under `state.lock`. A candidate must be enabled,
|
|
17
|
+
locally authenticated, on the current quota
|
|
18
|
+
epoch, `status=ok`, fresh under the fixed 60-second source-time policy, and
|
|
19
|
+
have known positive remaining capacity. A nullable secondary window remains
|
|
20
|
+
nullable according to the baseline WHAM parser; if it is present and
|
|
21
|
+
exhausted it excludes the account. Unknown quota is never turned into zero or
|
|
22
|
+
green capacity. No candidate means a local HTTP 503 with code
|
|
23
|
+
`quota_unavailable`, before any upstream generation request.
|
|
24
|
+
|
|
25
|
+
The sidecar is the sole quota authority. Its top-level schema is version 1,
|
|
26
|
+
module `codex`, monotonic revision, replacement timestamp, and account-keyed
|
|
27
|
+
records containing epoch, status (`never`/`checking`/`ok`/`failed`), attempt
|
|
28
|
+
metadata, refresh marker, last successful sample, and sanitized error. It
|
|
29
|
+
contains no tokens or raw provider bodies. Failed refreshes preserve the
|
|
30
|
+
historical `last_success` but never expose it as current.
|
|
31
|
+
|
|
32
|
+
Sidecar writes use same-directory exclusive temporary files, flush/fsync,
|
|
33
|
+
atomic replace, directory fsync where supported, and mode 0600 state files.
|
|
34
|
+
`filelock` backs the persistent `state.lock` and
|
|
35
|
+
`quota-v1.refresh.lock` inodes. State locking is short; network work never
|
|
36
|
+
holds it. One pool-wide owner performs at most two account checks concurrently
|
|
37
|
+
and foreground coordination is bounded. Followers join completed overlapping
|
|
38
|
+
waves or fail closed; they do not steal a live kernel lock.
|
|
39
|
+
|
|
40
|
+
The direct Codex root is explicit `CODEX_POOL_HOME`, otherwise
|
|
41
|
+
`${SUBS_POOL_HOME:-~/.subs-pool}/codex`. The root itself contains `pool.json`,
|
|
42
|
+
`quota-v1.json`, `state.lock`, and `quota-v1.refresh.lock`; there is no
|
|
43
|
+
`accounts/` relocation or hidden migration. Existing `pool.json` entries
|
|
44
|
+
without `quota_epoch` mean `legacy-v1` for compatibility. Mutations that
|
|
45
|
+
change enablement, auth, or identity advance the epoch and remove its quota
|
|
46
|
+
record.
|
|
47
|
+
|
|
48
|
+
The foreground server remains `--listen 127.0.0.1:8765`, loopback-only, and
|
|
49
|
+
user-managed. It refreshes on startup, at the 30-second target while running,
|
|
50
|
+
and just-in-time when no current candidate exists. It does not install a
|
|
51
|
+
service, auto-start from the TUI, persist affinity, or perform generation
|
|
52
|
+
failover.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Codex subscription module: accounts, auth, quota, routing, and protocol."""
|