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.
@@ -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}