relay-codex 0.0.0-stage → 0.4.1
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.
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/assets/relay.svg +15 -0
- package/bin/relay.cjs +148 -0
- package/docs/commands.md +92 -0
- package/docs/publishing.md +56 -0
- package/docs/troubleshooting.md +37 -0
- package/package.json +47 -4
- package/python/Menu.swift +70 -0
- package/python/broker.py +297 -0
- package/python/commands.py +701 -0
- package/python/compatibility.py +225 -0
- package/python/desktop.py +52 -0
- package/python/launch.py +117 -0
- package/python/metadata.py +82 -0
- package/python/notifications.py +36 -0
- package/python/pool.py +579 -0
- package/python/probe_native.py +91 -0
- package/python/processes.py +12 -0
- package/python/requirements.lock +6 -0
- package/python/requirements.txt +2 -0
- package/python/router.py +2053 -0
- package/python/runtime.py +97 -0
- package/python/service.py +663 -0
- package/python/settings.py +109 -0
- package/tests/fake_native.py +238 -0
- package/tests/test_capacity.py +217 -0
- package/tests/test_controller.py +519 -0
- package/tests/test_pool.py +446 -0
- package/tests/test_relay.py +173 -0
- package/tests/test_router.py +314 -0
- package/tests/test_services.py +473 -0
package/python/broker.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Private Unix-socket credential authority. Tokens never enter the pool database."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import secrets
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
|
|
15
|
+
from pool import file_lock, Pool
|
|
16
|
+
from settings import atomic_json
|
|
17
|
+
from processes import spawn
|
|
18
|
+
|
|
19
|
+
LEGACY_CLIENT_REVISION = (
|
|
20
|
+
"b3b3a2c9e0da6e29ce54d2e1b4b747b4372b31d4eb193c0626eee6a86421c3f7"
|
|
21
|
+
)
|
|
22
|
+
PROTOCOL_VERSION = 1
|
|
23
|
+
SOURCE_REVISION = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def paths(state):
|
|
27
|
+
# macOS Unix socket paths have a short length limit.
|
|
28
|
+
import hashlib
|
|
29
|
+
|
|
30
|
+
name = hashlib.sha256(str(state.resolve()).encode()).hexdigest()[:20]
|
|
31
|
+
root = Path("/private/tmp") / f"codex-accounts-{os.getuid()}-{name}"
|
|
32
|
+
root.mkdir(mode=0o700, exist_ok=True)
|
|
33
|
+
if root.is_symlink() or root.stat().st_uid != os.getuid():
|
|
34
|
+
raise RuntimeError("Unsafe local socket directory")
|
|
35
|
+
root.chmod(0o700)
|
|
36
|
+
return root / "broker.sock", state / "broker.json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def exchange(state, request, timeout=60):
|
|
40
|
+
socket, meta = paths(state)
|
|
41
|
+
data = json.loads(meta.read_text())
|
|
42
|
+
|
|
43
|
+
async def call():
|
|
44
|
+
reader, writer = await asyncio.open_unix_connection(
|
|
45
|
+
str(socket), limit=4 * 1024 * 1024
|
|
46
|
+
)
|
|
47
|
+
try:
|
|
48
|
+
writer.write(
|
|
49
|
+
json.dumps({"token": data["token"], **request}).encode() + b"\n"
|
|
50
|
+
)
|
|
51
|
+
await writer.drain()
|
|
52
|
+
response = json.loads(await reader.readline())
|
|
53
|
+
if not response.get("ok"):
|
|
54
|
+
raise RuntimeError("Credential operation unavailable")
|
|
55
|
+
return response.get("result")
|
|
56
|
+
finally:
|
|
57
|
+
writer.close()
|
|
58
|
+
await writer.wait_closed()
|
|
59
|
+
|
|
60
|
+
return await asyncio.wait_for(call(), timeout)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def ensure(state, binary):
|
|
64
|
+
async with file_lock(state / "broker-start.lock"):
|
|
65
|
+
try:
|
|
66
|
+
running = await exchange(state, {"op": "ping"}, 3)
|
|
67
|
+
if running.get("protocol", 1) == PROTOCOL_VERSION:
|
|
68
|
+
return
|
|
69
|
+
await exchange(state, {"op": "stop"}, 3)
|
|
70
|
+
for _ in range(50):
|
|
71
|
+
if not paths(state)[1].exists():
|
|
72
|
+
break
|
|
73
|
+
await asyncio.sleep(0.1)
|
|
74
|
+
except (OSError, ValueError, RuntimeError, asyncio.TimeoutError):
|
|
75
|
+
pass
|
|
76
|
+
spawn(
|
|
77
|
+
[
|
|
78
|
+
sys.executable,
|
|
79
|
+
str(Path(__file__).with_name("router.py")),
|
|
80
|
+
"_broker",
|
|
81
|
+
"--binary",
|
|
82
|
+
binary,
|
|
83
|
+
],
|
|
84
|
+
env={
|
|
85
|
+
**os.environ,
|
|
86
|
+
"CODEX_ACCOUNTS_STATE": str(state),
|
|
87
|
+
"RELAY_STATE": str(state),
|
|
88
|
+
},
|
|
89
|
+
stdin=subprocess.DEVNULL,
|
|
90
|
+
stdout=subprocess.DEVNULL,
|
|
91
|
+
stderr=subprocess.DEVNULL,
|
|
92
|
+
start_new_session=True,
|
|
93
|
+
)
|
|
94
|
+
deadline = time.monotonic() + 15
|
|
95
|
+
while time.monotonic() < deadline:
|
|
96
|
+
try:
|
|
97
|
+
await exchange(state, {"op": "ping"}, 2)
|
|
98
|
+
return
|
|
99
|
+
except (OSError, ValueError, RuntimeError, asyncio.TimeoutError):
|
|
100
|
+
await asyncio.sleep(0.1)
|
|
101
|
+
raise RuntimeError("Local credential coordinator did not start")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ClientAccount:
|
|
105
|
+
def __init__(self, label, home, binary, pool):
|
|
106
|
+
self.label, self.home, self.binary, self.pool = label, home, binary, pool
|
|
107
|
+
|
|
108
|
+
async def call(self, op, **params):
|
|
109
|
+
from router import RouterError
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
await ensure(self.pool.state, self.binary)
|
|
113
|
+
return await exchange(
|
|
114
|
+
self.pool.state, {"op": op, "label": self.label, **params}
|
|
115
|
+
)
|
|
116
|
+
except (OSError, ValueError, RuntimeError, asyncio.TimeoutError):
|
|
117
|
+
raise RouterError(
|
|
118
|
+
"Account coordinator could not complete this operation. Check doctor or reauthenticate this account."
|
|
119
|
+
) from None
|
|
120
|
+
|
|
121
|
+
async def credentials(self, refresh=False):
|
|
122
|
+
return await self.call("credentials", refresh=refresh)
|
|
123
|
+
|
|
124
|
+
async def usage(self, force=False):
|
|
125
|
+
return await self.call(
|
|
126
|
+
"usage", force=force, after=time.time() if force else None
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def network(self):
|
|
130
|
+
return await self.call("network")
|
|
131
|
+
|
|
132
|
+
async def close(self):
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def serve(state, binary):
|
|
137
|
+
from router import Account, account_home, labels, read_keychain
|
|
138
|
+
|
|
139
|
+
socket, meta = paths(state)
|
|
140
|
+
async with file_lock(state / "broker-owner.lock"):
|
|
141
|
+
socket.unlink(missing_ok=True)
|
|
142
|
+
token = secrets.token_urlsafe(32)
|
|
143
|
+
atomic_json(meta, {"token": token, "pid": os.getpid()})
|
|
144
|
+
pool = Pool(state)
|
|
145
|
+
accounts = {}
|
|
146
|
+
locks = {}
|
|
147
|
+
stamps = {}
|
|
148
|
+
refreshed = {}
|
|
149
|
+
last_used = time.monotonic()
|
|
150
|
+
network = {"at": 0, "value": None}
|
|
151
|
+
stop = asyncio.Event()
|
|
152
|
+
|
|
153
|
+
async def handle(reader, writer):
|
|
154
|
+
nonlocal last_used
|
|
155
|
+
response = {"ok": False}
|
|
156
|
+
try:
|
|
157
|
+
raw = await asyncio.wait_for(reader.readline(), 5)
|
|
158
|
+
request = json.loads(raw)
|
|
159
|
+
if not secrets.compare_digest(str(request.get("token", "")), token):
|
|
160
|
+
return
|
|
161
|
+
last_used = time.monotonic()
|
|
162
|
+
op = request.get("op")
|
|
163
|
+
if op == "ping":
|
|
164
|
+
response = {
|
|
165
|
+
"ok": True,
|
|
166
|
+
"result": {
|
|
167
|
+
"pid": os.getpid(),
|
|
168
|
+
"revision": LEGACY_CLIENT_REVISION,
|
|
169
|
+
"protocol": PROTOCOL_VERSION,
|
|
170
|
+
"build": SOURCE_REVISION,
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
elif op == "stop":
|
|
174
|
+
response = {"ok": True, "result": True}
|
|
175
|
+
stop.set()
|
|
176
|
+
elif op == "network":
|
|
177
|
+
if time.monotonic() - network["at"] > 30:
|
|
178
|
+
try:
|
|
179
|
+
_, connection = await asyncio.wait_for(
|
|
180
|
+
asyncio.open_connection("auth.openai.com", 443), 3
|
|
181
|
+
)
|
|
182
|
+
connection.close()
|
|
183
|
+
await connection.wait_closed()
|
|
184
|
+
network["value"] = True
|
|
185
|
+
except (OSError, asyncio.TimeoutError):
|
|
186
|
+
network["value"] = False
|
|
187
|
+
network["at"] = time.monotonic()
|
|
188
|
+
response = {"ok": True, "result": network["value"]}
|
|
189
|
+
else:
|
|
190
|
+
label = request.get("label", "")
|
|
191
|
+
if label not in labels(state):
|
|
192
|
+
raise ValueError()
|
|
193
|
+
if pool.options(label).get("maintenance") and op != "invalidate":
|
|
194
|
+
raise ValueError()
|
|
195
|
+
lock = locks.setdefault(label, asyncio.Lock())
|
|
196
|
+
async with lock:
|
|
197
|
+
# Enrollment writes a new generation marker and invalidates
|
|
198
|
+
# this authority before any cached manager is reused.
|
|
199
|
+
marker = account_home(state, label) / "enrolled"
|
|
200
|
+
stamp = marker.stat().st_mtime_ns
|
|
201
|
+
if label in accounts and stamps.get(label) != stamp:
|
|
202
|
+
await accounts.pop(label).close()
|
|
203
|
+
account = accounts.setdefault(
|
|
204
|
+
label,
|
|
205
|
+
Account(label, account_home(state, label), binary, pool),
|
|
206
|
+
)
|
|
207
|
+
stamps[label] = stamp
|
|
208
|
+
async with file_lock(account.home / "credential.lock"):
|
|
209
|
+
if op == "credentials":
|
|
210
|
+
value = None
|
|
211
|
+
with contextlib.suppress(Exception):
|
|
212
|
+
candidate = await asyncio.to_thread(
|
|
213
|
+
read_keychain, account.home
|
|
214
|
+
)
|
|
215
|
+
part = candidate["accessToken"].split(".")[1]
|
|
216
|
+
expires = json.loads(
|
|
217
|
+
base64.urlsafe_b64decode(
|
|
218
|
+
part + "=" * (-len(part) % 4)
|
|
219
|
+
)
|
|
220
|
+
).get("exp", 0)
|
|
221
|
+
if expires > time.time() + 120 and (
|
|
222
|
+
not request.get("refresh")
|
|
223
|
+
or time.monotonic() - refreshed.get(label, 0)
|
|
224
|
+
< 30
|
|
225
|
+
):
|
|
226
|
+
value = candidate
|
|
227
|
+
if value is None:
|
|
228
|
+
await account.start()
|
|
229
|
+
result = await account.rpc.call(
|
|
230
|
+
"account/read",
|
|
231
|
+
{"refreshToken": bool(request.get("refresh"))},
|
|
232
|
+
)
|
|
233
|
+
if not result.get("account"):
|
|
234
|
+
raise ValueError()
|
|
235
|
+
value = await asyncio.to_thread(
|
|
236
|
+
read_keychain, account.home
|
|
237
|
+
)
|
|
238
|
+
if request.get("refresh"):
|
|
239
|
+
refreshed[label] = time.monotonic()
|
|
240
|
+
elif op == "usage":
|
|
241
|
+
value = (
|
|
242
|
+
pool.cached(label, not_before=request.get("after"))
|
|
243
|
+
if request.get("force") and request.get("after")
|
|
244
|
+
else pool.cached(label)
|
|
245
|
+
if not request.get("force")
|
|
246
|
+
else None
|
|
247
|
+
)
|
|
248
|
+
if value is None:
|
|
249
|
+
await account.start()
|
|
250
|
+
value = await account.rpc.call(
|
|
251
|
+
"account/rateLimits/read"
|
|
252
|
+
)
|
|
253
|
+
pool.save(label, value)
|
|
254
|
+
elif op == "invalidate":
|
|
255
|
+
await account.close()
|
|
256
|
+
accounts.pop(label, None)
|
|
257
|
+
value = True
|
|
258
|
+
else:
|
|
259
|
+
raise ValueError()
|
|
260
|
+
response = {"ok": True, "result": value}
|
|
261
|
+
except (Exception, asyncio.CancelledError):
|
|
262
|
+
response = {"ok": False}
|
|
263
|
+
finally:
|
|
264
|
+
with contextlib.suppress(Exception):
|
|
265
|
+
writer.write(json.dumps(response).encode() + b"\n")
|
|
266
|
+
await writer.drain()
|
|
267
|
+
writer.close()
|
|
268
|
+
with contextlib.suppress(Exception):
|
|
269
|
+
await writer.wait_closed()
|
|
270
|
+
|
|
271
|
+
server = await asyncio.start_unix_server(
|
|
272
|
+
handle, str(socket), limit=4 * 1024 * 1024
|
|
273
|
+
)
|
|
274
|
+
socket.chmod(0o600)
|
|
275
|
+
try:
|
|
276
|
+
while not stop.is_set():
|
|
277
|
+
try:
|
|
278
|
+
await asyncio.wait_for(stop.wait(), 30)
|
|
279
|
+
except asyncio.TimeoutError:
|
|
280
|
+
pass
|
|
281
|
+
import settings
|
|
282
|
+
|
|
283
|
+
if (
|
|
284
|
+
time.monotonic() - last_used
|
|
285
|
+
> settings.load(state)["broker_idle_seconds"]
|
|
286
|
+
and not pool.detail()
|
|
287
|
+
):
|
|
288
|
+
break
|
|
289
|
+
finally:
|
|
290
|
+
server.close()
|
|
291
|
+
await server.wait_closed()
|
|
292
|
+
await asyncio.gather(
|
|
293
|
+
*(a.close() for a in accounts.values()), return_exceptions=True
|
|
294
|
+
)
|
|
295
|
+
pool.close()
|
|
296
|
+
socket.unlink(missing_ok=True)
|
|
297
|
+
meta.unlink(missing_ok=True)
|