relay-codex 0.0.0-stage → 0.5.0
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 +118 -2
- package/assets/relay.svg +15 -0
- package/bin/relay.cjs +148 -0
- package/docs/commands.md +176 -0
- package/docs/publishing.md +72 -0
- package/docs/troubleshooting.md +49 -0
- package/package.json +47 -4
- package/python/Menu.swift +188 -0
- package/python/broker.py +297 -0
- package/python/commands.py +900 -0
- package/python/compatibility.py +227 -0
- package/python/desktop.py +171 -0
- package/python/execution.py +551 -0
- package/python/launch.py +417 -0
- package/python/metadata.py +82 -0
- package/python/notifications.py +36 -0
- package/python/pool.py +586 -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 +2288 -0
- package/python/runtime.py +97 -0
- package/python/service.py +737 -0
- package/python/settings.py +110 -0
- package/python/tools.py +233 -0
- package/python/watch.py +74 -0
- package/tests/fake_native.py +240 -0
- package/tests/test_audit.py +320 -0
- package/tests/test_capacity.py +217 -0
- package/tests/test_controller.py +517 -0
- package/tests/test_features.py +350 -0
- package/tests/test_launch.py +150 -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 +479 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Check protocol capabilities, not an exact Codex version allowlist."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
ADAPTER = 5
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def executable(path):
|
|
14
|
+
"""Resolve the native executable behind the official npm launcher when present."""
|
|
15
|
+
real = Path(path).resolve()
|
|
16
|
+
if real.suffix == ".js":
|
|
17
|
+
root = real.parent.parent
|
|
18
|
+
candidates = list(
|
|
19
|
+
(root / "node_modules" / "@openai").glob("codex-*/vendor/*/bin/codex")
|
|
20
|
+
)
|
|
21
|
+
candidates += list((root / "vendor").glob("*/bin/codex"))
|
|
22
|
+
import platform
|
|
23
|
+
|
|
24
|
+
architecture = "aarch64" if platform.machine() == "arm64" else "x86_64"
|
|
25
|
+
candidates = [
|
|
26
|
+
p for p in candidates if architecture in str(p) and "darwin" in str(p)
|
|
27
|
+
]
|
|
28
|
+
if len(candidates) == 1:
|
|
29
|
+
return candidates[0].resolve()
|
|
30
|
+
return real
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def digest(path):
|
|
34
|
+
value = hashlib.sha256()
|
|
35
|
+
with Path(path).open("rb") as stream:
|
|
36
|
+
for data in iter(lambda: stream.read(1024 * 1024), b""):
|
|
37
|
+
value.update(data)
|
|
38
|
+
return value.hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_schema(root):
|
|
42
|
+
request = json.loads((root / "ClientRequest.json").read_text())
|
|
43
|
+
methods = {
|
|
44
|
+
m
|
|
45
|
+
for x in request["oneOf"]
|
|
46
|
+
for m in x.get("properties", {}).get("method", {}).get("enum", [])
|
|
47
|
+
}
|
|
48
|
+
required = {
|
|
49
|
+
"initialize",
|
|
50
|
+
"account/read",
|
|
51
|
+
"account/login/start",
|
|
52
|
+
"account/rateLimits/read",
|
|
53
|
+
"thread/start",
|
|
54
|
+
"thread/resume",
|
|
55
|
+
"turn/start",
|
|
56
|
+
"turn/interrupt",
|
|
57
|
+
}
|
|
58
|
+
if required - methods:
|
|
59
|
+
raise ValueError("Required account/thread methods changed.")
|
|
60
|
+
login = json.loads((root / "v2/LoginAccountParams.json").read_text())
|
|
61
|
+
variants = [
|
|
62
|
+
x
|
|
63
|
+
for x in login["oneOf"]
|
|
64
|
+
if x.get("properties", {}).get("type", {}).get("enum") == ["chatgptAuthTokens"]
|
|
65
|
+
]
|
|
66
|
+
if not variants or not {"accessToken", "chatgptAccountId"} <= set(
|
|
67
|
+
variants[0]["properties"]
|
|
68
|
+
):
|
|
69
|
+
raise ValueError("Live token login interface changed.")
|
|
70
|
+
if set(variants[0].get("required", [])) - {
|
|
71
|
+
"type",
|
|
72
|
+
"accessToken",
|
|
73
|
+
"chatgptAccountId",
|
|
74
|
+
"chatgptPlanType",
|
|
75
|
+
}:
|
|
76
|
+
raise ValueError("Token login requires new fields.")
|
|
77
|
+
for name, fields in {
|
|
78
|
+
"v2/TurnStartParams.json": {"threadId", "input"},
|
|
79
|
+
"v2/TurnCompletedNotification.json": {"threadId", "turn"},
|
|
80
|
+
"v2/GetAccountRateLimitsResponse.json": {"rateLimits"},
|
|
81
|
+
"ChatgptAuthTokensRefreshParams.json": {"reason", "previousAccountId"},
|
|
82
|
+
}.items():
|
|
83
|
+
value = json.loads((root / name).read_text())
|
|
84
|
+
if not fields <= set(value["properties"]):
|
|
85
|
+
raise ValueError("Required response fields changed.")
|
|
86
|
+
if name == "v2/TurnStartParams.json" and set(value.get("required", [])) - {
|
|
87
|
+
"threadId",
|
|
88
|
+
"input",
|
|
89
|
+
}:
|
|
90
|
+
raise ValueError("Turn continuation requires new fields.")
|
|
91
|
+
# Validate complete request examples against the generated combined schema,
|
|
92
|
+
# including types and newly required fields, not just matching field names.
|
|
93
|
+
try:
|
|
94
|
+
import jsonschema
|
|
95
|
+
except ImportError:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"Install requirements.txt; JSON schema validation is required."
|
|
98
|
+
) from None
|
|
99
|
+
if request.get("definitions"):
|
|
100
|
+
validator = jsonschema.Draft7Validator(request)
|
|
101
|
+
ident = "00000000-0000-4000-8000-000000000000"
|
|
102
|
+
examples = [
|
|
103
|
+
{
|
|
104
|
+
"id": "probe",
|
|
105
|
+
"method": "initialize",
|
|
106
|
+
"params": {
|
|
107
|
+
"clientInfo": {"name": "local_codex_accounts", "version": "0.3.0"},
|
|
108
|
+
"capabilities": {"experimentalApi": True},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"id": "probe",
|
|
113
|
+
"method": "account/login/start",
|
|
114
|
+
"params": {
|
|
115
|
+
"type": "chatgptAuthTokens",
|
|
116
|
+
"accessToken": "probe",
|
|
117
|
+
"chatgptAccountId": "probe",
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"id": "probe",
|
|
122
|
+
"method": "account/read",
|
|
123
|
+
"params": {"refreshToken": False},
|
|
124
|
+
},
|
|
125
|
+
{"id": "probe", "method": "account/rateLimits/read"},
|
|
126
|
+
{
|
|
127
|
+
"id": "probe",
|
|
128
|
+
"method": "thread/resume",
|
|
129
|
+
"params": {
|
|
130
|
+
"threadId": ident,
|
|
131
|
+
"sandbox": "danger-full-access",
|
|
132
|
+
"approvalPolicy": "never",
|
|
133
|
+
"approvalsReviewer": "user",
|
|
134
|
+
"runtimeWorkspaceRoots": ["/tmp/relay-probe"],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"id": "probe",
|
|
139
|
+
"method": "turn/start",
|
|
140
|
+
"params": {
|
|
141
|
+
"threadId": ident,
|
|
142
|
+
"input": [{"type": "text", "text": "probe", "text_elements": []}],
|
|
143
|
+
"clientUserMessageId": ident,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"id": "probe",
|
|
148
|
+
"method": "thread/turns/list",
|
|
149
|
+
"params": {
|
|
150
|
+
"threadId": ident,
|
|
151
|
+
"limit": 1,
|
|
152
|
+
"sortDirection": "desc",
|
|
153
|
+
"itemsView": "summary",
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
]
|
|
157
|
+
for sample in examples:
|
|
158
|
+
if not validator.is_valid(sample):
|
|
159
|
+
raise ValueError(
|
|
160
|
+
"Native RPC request contract changed: " + sample["method"]
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def check(binary, state):
|
|
165
|
+
"""Cache by launcher bytes/version; serialize callers with caller's file lock."""
|
|
166
|
+
version = subprocess.run(
|
|
167
|
+
[binary, "--version"], capture_output=True, text=True, check=True, timeout=15
|
|
168
|
+
).stdout.strip()
|
|
169
|
+
native = executable(binary)
|
|
170
|
+
fingerprint = hashlib.sha256(
|
|
171
|
+
(digest(binary) + digest(native) + version + str(ADAPTER)).encode()
|
|
172
|
+
).hexdigest()
|
|
173
|
+
cache = state / "compatibility.json"
|
|
174
|
+
if cache.exists():
|
|
175
|
+
try:
|
|
176
|
+
value = json.loads(cache.read_text())
|
|
177
|
+
if value.get("fingerprint") == fingerprint:
|
|
178
|
+
return value
|
|
179
|
+
except (ValueError, OSError):
|
|
180
|
+
pass
|
|
181
|
+
with tempfile.TemporaryDirectory(prefix="codex-account-schema-") as folder:
|
|
182
|
+
root = Path(folder)
|
|
183
|
+
subprocess.run(
|
|
184
|
+
[
|
|
185
|
+
binary,
|
|
186
|
+
"app-server",
|
|
187
|
+
"generate-json-schema",
|
|
188
|
+
"--experimental",
|
|
189
|
+
"--out",
|
|
190
|
+
folder,
|
|
191
|
+
],
|
|
192
|
+
capture_output=True,
|
|
193
|
+
check=True,
|
|
194
|
+
timeout=45,
|
|
195
|
+
)
|
|
196
|
+
validate_schema(root)
|
|
197
|
+
help_text = subprocess.run(
|
|
198
|
+
[binary, "--help"], capture_output=True, text=True, check=True, timeout=15
|
|
199
|
+
).stdout
|
|
200
|
+
if not all(flag in help_text for flag in ["--remote", "--remote-auth-token-env"]):
|
|
201
|
+
raise ValueError("Terminal remote connection flags changed.")
|
|
202
|
+
# New releases must also accept the live login operation twice in the same
|
|
203
|
+
# native process. Fake identities only; an OS sandbox denies all networking.
|
|
204
|
+
subprocess.run(
|
|
205
|
+
[
|
|
206
|
+
sys.executable,
|
|
207
|
+
str(Path(__file__).with_name("probe_native.py")),
|
|
208
|
+
"--binary",
|
|
209
|
+
binary,
|
|
210
|
+
],
|
|
211
|
+
capture_output=True,
|
|
212
|
+
check=True,
|
|
213
|
+
timeout=40,
|
|
214
|
+
)
|
|
215
|
+
value = {
|
|
216
|
+
"version": version,
|
|
217
|
+
"fingerprint": fingerprint,
|
|
218
|
+
"adapter": ADAPTER,
|
|
219
|
+
"native_executable": str(native),
|
|
220
|
+
"native_sha256": digest(native),
|
|
221
|
+
"validation": "schema, CLI flags, network-denied native login-switch probe; real handshake on launch",
|
|
222
|
+
}
|
|
223
|
+
temporary = cache.with_suffix(".tmp")
|
|
224
|
+
temporary.write_text(json.dumps(value, indent=2) + "\n")
|
|
225
|
+
temporary.chmod(0o600)
|
|
226
|
+
temporary.replace(cache)
|
|
227
|
+
return value
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Native menu app and per-user login startup. No account credentials in launch files."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import plistlib
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from processes import spawn
|
|
10
|
+
|
|
11
|
+
LABEL = "local.relay.menu"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def login_item(state, app=None, enabled=True, home=None):
|
|
15
|
+
home = Path(home or Path.home())
|
|
16
|
+
path = home / "Library/LaunchAgents" / (LABEL + ".plist")
|
|
17
|
+
domain = f"gui/{os.getuid()}"
|
|
18
|
+
if not enabled:
|
|
19
|
+
# Removing login startup should not close the menu currently in use.
|
|
20
|
+
path.unlink(missing_ok=True)
|
|
21
|
+
return path
|
|
22
|
+
if path.exists():
|
|
23
|
+
subprocess.run(
|
|
24
|
+
["/bin/launchctl", "bootout", domain, str(path)], capture_output=True
|
|
25
|
+
)
|
|
26
|
+
if app is None:
|
|
27
|
+
app = Path(state) / "Relay Menu.app"
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
payload = {
|
|
30
|
+
"Label": LABEL,
|
|
31
|
+
"ProgramArguments": [str(Path(app) / "Contents/MacOS/RelayMenu")],
|
|
32
|
+
"RunAtLoad": True,
|
|
33
|
+
"KeepAlive": {"SuccessfulExit": False},
|
|
34
|
+
"ProcessType": "Interactive",
|
|
35
|
+
"EnvironmentVariables": {
|
|
36
|
+
"PATH": os.environ.get(
|
|
37
|
+
"PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
38
|
+
),
|
|
39
|
+
"RELAY_STATE": str(Path(state).resolve()),
|
|
40
|
+
},
|
|
41
|
+
"ThrottleInterval": 30,
|
|
42
|
+
}
|
|
43
|
+
with path.open("wb") as stream:
|
|
44
|
+
plistlib.dump(payload, stream)
|
|
45
|
+
path.chmod(0o600)
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
["/bin/launchctl", "bootstrap", domain, str(path)], capture_output=True
|
|
48
|
+
)
|
|
49
|
+
if result.returncode:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"Login startup was saved, but launchctl could not load it. Run relay menubar again after signing in to macOS."
|
|
52
|
+
)
|
|
53
|
+
return path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def menubar(state, launcher=None, build_only=False, startup=True):
|
|
57
|
+
from router import VERSION
|
|
58
|
+
|
|
59
|
+
launcher = launcher or shutil.which("relay")
|
|
60
|
+
if not launcher:
|
|
61
|
+
raise ValueError("The relay command is not on PATH")
|
|
62
|
+
launcher = str(Path(launcher).absolute())
|
|
63
|
+
source = Path(__file__).with_name("Menu.swift")
|
|
64
|
+
app = state / "Relay Menu.app"
|
|
65
|
+
contents = app / "Contents"
|
|
66
|
+
binary = contents / "MacOS" / "RelayMenu"
|
|
67
|
+
stamp = state / "menu-build.txt"
|
|
68
|
+
(contents / "relay-build.txt").unlink(missing_ok=True)
|
|
69
|
+
identity = hashlib.sha256(
|
|
70
|
+
source.read_bytes() + launcher.encode() + VERSION.encode()
|
|
71
|
+
).hexdigest()
|
|
72
|
+
if not binary.exists() or not stamp.exists() or stamp.read_text() != identity:
|
|
73
|
+
compiler = shutil.which("swiftc")
|
|
74
|
+
if not compiler:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
"Install Apple command-line tools to build the menu app: xcode-select --install"
|
|
77
|
+
)
|
|
78
|
+
binary.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
temporary = binary.with_suffix(".new")
|
|
80
|
+
subprocess.run([compiler, str(source), "-o", str(temporary)], check=True)
|
|
81
|
+
temporary.replace(binary)
|
|
82
|
+
with (contents / "Info.plist").open("wb") as stream:
|
|
83
|
+
plistlib.dump(
|
|
84
|
+
{
|
|
85
|
+
"CFBundleIdentifier": LABEL,
|
|
86
|
+
"CFBundleName": "Relay",
|
|
87
|
+
"CFBundleExecutable": "RelayMenu",
|
|
88
|
+
"CFBundleVersion": VERSION,
|
|
89
|
+
"RelayCommand": launcher,
|
|
90
|
+
"RelayState": str(state),
|
|
91
|
+
"RelayNodeDirectory": str(
|
|
92
|
+
Path(shutil.which("node") or "/usr/bin/node").parent
|
|
93
|
+
),
|
|
94
|
+
"CFBundlePackageType": "APPL",
|
|
95
|
+
"LSUIElement": True,
|
|
96
|
+
"NSHighResolutionCapable": True,
|
|
97
|
+
},
|
|
98
|
+
stream,
|
|
99
|
+
)
|
|
100
|
+
subprocess.run(
|
|
101
|
+
["/usr/bin/codesign", "--force", "--sign", "-", str(app)],
|
|
102
|
+
check=True,
|
|
103
|
+
capture_output=True,
|
|
104
|
+
)
|
|
105
|
+
stamp.write_text(identity)
|
|
106
|
+
if not build_only:
|
|
107
|
+
# Replace only this user's previous Relay menu, preserving all Codex tasks.
|
|
108
|
+
subprocess.run(
|
|
109
|
+
["/usr/bin/pkill", "-u", str(os.getuid()), "-x", "RelayMenu"],
|
|
110
|
+
capture_output=True,
|
|
111
|
+
)
|
|
112
|
+
if startup:
|
|
113
|
+
login_item(state, app, enabled=True)
|
|
114
|
+
else:
|
|
115
|
+
spawn(
|
|
116
|
+
["/usr/bin/open", str(app)],
|
|
117
|
+
stdin=subprocess.DEVNULL,
|
|
118
|
+
stdout=subprocess.DEVNULL,
|
|
119
|
+
stderr=subprocess.DEVNULL,
|
|
120
|
+
)
|
|
121
|
+
return app
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def job_startup(state, action, session=None, launcher=None, home=None):
|
|
125
|
+
"""Opt-in recovery of explicitly selected background jobs at Mac login."""
|
|
126
|
+
from service import resolve
|
|
127
|
+
|
|
128
|
+
home = Path(home or Path.home())
|
|
129
|
+
root = home / "Library/LaunchAgents"
|
|
130
|
+
if action == "list":
|
|
131
|
+
return [
|
|
132
|
+
p.stem.removeprefix("local.relay.job.")
|
|
133
|
+
for p in root.glob("local.relay.job.*.plist")
|
|
134
|
+
]
|
|
135
|
+
ident = resolve(state, session)
|
|
136
|
+
path = root / ("local.relay.job." + ident + ".plist")
|
|
137
|
+
domain = f"gui/{os.getuid()}"
|
|
138
|
+
if path.exists():
|
|
139
|
+
subprocess.run(
|
|
140
|
+
["/bin/launchctl", "bootout", domain, str(path)], capture_output=True
|
|
141
|
+
)
|
|
142
|
+
if action == "disable":
|
|
143
|
+
path.unlink(missing_ok=True)
|
|
144
|
+
return {"session": ident, "startup": False}
|
|
145
|
+
launcher = launcher or shutil.which("relay")
|
|
146
|
+
if not launcher:
|
|
147
|
+
raise ValueError("The relay command is not on PATH")
|
|
148
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
with path.open("wb") as stream:
|
|
150
|
+
plistlib.dump(
|
|
151
|
+
{
|
|
152
|
+
"Label": "local.relay.job." + ident,
|
|
153
|
+
"ProgramArguments": [
|
|
154
|
+
str(Path(launcher).absolute()),
|
|
155
|
+
"restart",
|
|
156
|
+
ident,
|
|
157
|
+
"--if-stopped",
|
|
158
|
+
],
|
|
159
|
+
"RunAtLoad": True,
|
|
160
|
+
"EnvironmentVariables": {
|
|
161
|
+
"PATH": os.environ.get(
|
|
162
|
+
"PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
163
|
+
),
|
|
164
|
+
"RELAY_STATE": str(Path(state).resolve()),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
stream,
|
|
168
|
+
)
|
|
169
|
+
path.chmod(0o600)
|
|
170
|
+
# Do not run a stopped task immediately; load it at the next login.
|
|
171
|
+
return {"session": ident, "startup": True}
|