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,188 @@
1
+ import AppKit
2
+
3
+ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
4
+ var item: NSStatusItem!
5
+ var stream: Process?
6
+ var buffer = Data()
7
+ var current: [String: Any] = [:]
8
+ var message: String?
9
+ var reconnect: DispatchWorkItem?
10
+ var stopping = false
11
+ let command = Bundle.main.object(forInfoDictionaryKey: "RelayCommand") as? String ?? "/usr/bin/false"
12
+ let state = Bundle.main.object(forInfoDictionaryKey: "RelayState") as? String ?? ""
13
+
14
+ func applicationDidFinishLaunching(_ notification: Notification) {
15
+ let others = NSRunningApplication.runningApplications(withBundleIdentifier: "local.relay.menu")
16
+ if others.contains(where: { $0.processIdentifier != ProcessInfo.processInfo.processIdentifier }) { NSApp.terminate(nil); return }
17
+ NSApp.setActivationPolicy(.accessory)
18
+ item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
19
+ item.button?.title = ""
20
+ item.button?.setAccessibilityLabel("Relay account and session status")
21
+ updateIcon(attention: false)
22
+ render()
23
+ startStream()
24
+ }
25
+ func process(_ args: [String]) -> Process {
26
+ let proc = Process()
27
+ proc.executableURL = URL(fileURLWithPath: command)
28
+ proc.arguments = args
29
+ var env = ProcessInfo.processInfo.environment
30
+ let node = Bundle.main.object(forInfoDictionaryKey: "RelayNodeDirectory") as? String ?? "/opt/homebrew/bin"
31
+ env["PATH"] = node + ":/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:" + (env["PATH"] ?? "")
32
+ env["RELAY_STATE"] = state
33
+ proc.environment = env
34
+ return proc
35
+ }
36
+ func startStream() {
37
+ guard !stopping else { return }
38
+ let proc = process(["status", "--watch", "--json"])
39
+ let pipe = Pipe()
40
+ proc.standardOutput = pipe; proc.standardError = FileHandle.nullDevice
41
+ pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
42
+ let bytes = handle.availableData
43
+ guard !bytes.isEmpty else { handle.readabilityHandler = nil; return }
44
+ DispatchQueue.main.async { self?.receive(bytes) }
45
+ }
46
+ proc.terminationHandler = { [weak self] _ in
47
+ DispatchQueue.main.async {
48
+ guard let self = self, !self.stopping else { return }
49
+ self.message = "Status connection interrupted. Retrying in 30 seconds."
50
+ self.render()
51
+ let retry = DispatchWorkItem { [weak self] in self?.startStream() }
52
+ self.reconnect = retry
53
+ DispatchQueue.main.asyncAfter(deadline: .now() + 30, execute: retry)
54
+ }
55
+ }
56
+ do { buffer.removeAll(); try proc.run(); stream = proc }
57
+ catch { message = "Relay could not start. Open Terminal and run relay doctor."; render() }
58
+ }
59
+ func receive(_ bytes: Data) {
60
+ buffer.append(bytes)
61
+ while let newline = buffer.firstIndex(of: 10) {
62
+ let line = buffer.prefix(upTo: newline); buffer.removeSubrange(...newline)
63
+ if let value = (try? JSONSerialization.jsonObject(with: line)) as? [String: Any] {
64
+ current = value; message = nil; render()
65
+ }
66
+ }
67
+ }
68
+ func updateIcon(attention: Bool) {
69
+ let symbol = attention ? "exclamationmark.circle.fill" : "arrow.triangle.branch"
70
+ let image = NSImage(systemSymbolName: symbol, accessibilityDescription: attention ? "Relay needs attention" : "Relay")
71
+ image?.isTemplate = true
72
+ item.button?.image = image
73
+ }
74
+ func text(_ title: String, into menu: NSMenu) {
75
+ menu.addItem(NSMenuItem(title: title, action: nil, keyEquivalent: ""))
76
+ }
77
+ func action(_ title: String, args: [String], into menu: NSMenu) {
78
+ let entry = NSMenuItem(title: title, action: #selector(runAction(_:)), keyEquivalent: "")
79
+ entry.target = self; entry.representedObject = args; menu.addItem(entry)
80
+ }
81
+ func date(_ value: Any?) -> String {
82
+ guard let seconds = value as? Double else { return "unknown" }
83
+ return Date(timeIntervalSince1970: seconds).formatted(date: .abbreviated, time: .shortened)
84
+ }
85
+ func countdown(_ title: String, value: Any, into menu: NSMenu) {
86
+ let entry = NSMenuItem(title: title + ": " + date(value), action: nil, keyEquivalent: "")
87
+ entry.representedObject = ["label": title, "at": value]
88
+ menu.addItem(entry)
89
+ }
90
+ func updateTimes(_ menu: NSMenu) {
91
+ for entry in menu.items {
92
+ if let data = entry.representedObject as? [String: Any], let at = data["at"] as? Double, let label = data["label"] as? String {
93
+ let seconds = max(0, Int(at - Date().timeIntervalSince1970))
94
+ let remaining = seconds < 60 ? "under a minute" : seconds < 3600 ? "\(seconds / 60)m" : "\(seconds / 3600)h \((seconds % 3600) / 60)m"
95
+ entry.title = label + ": " + (seconds == 0 ? "due now" : "in " + remaining) + " · " + date(at)
96
+ }
97
+ if let child = entry.submenu { updateTimes(child) }
98
+ }
99
+ }
100
+ func render() {
101
+ let sessions = current["sessions"] as? [[String: Any]] ?? []
102
+ let accounts = current["accounts"] as? [[String: Any]] ?? []
103
+ let attention = message != nil || sessions.contains { ($0["state"] as? String) == "needs_attention" }
104
+ updateIcon(attention: attention)
105
+ item.button?.toolTip = "Relay · \(sessions.count) sessions · \(accounts.count) accounts"
106
+ let menu = NSMenu(); menu.delegate = self
107
+ text("Relay · \(sessions.count) sessions", into: menu)
108
+ if let message = message { text(message, into: menu) }
109
+ if accounts.isEmpty { text("Add an account with relay setup in Terminal.", into: menu) }
110
+ for account in accounts {
111
+ let name = account["name"] as? String ?? "Account"
112
+ let primary = account["primary"] as? [String: Any]
113
+ let remaining = (primary?["usedPercent"] as? NSNumber).map { "\(max(0, 100 - $0.intValue))% left" } ?? "allowance unknown"
114
+ let label = (account["enabled"] as? Bool ?? true) ? remaining : "disabled"
115
+ let submenu = NSMenu()
116
+ if let email = account["email"] as? String { text(email, into: submenu) }
117
+ text("Short window: \(remaining)", into: submenu)
118
+ if let secondary = account["secondary"] as? [String: Any], let used = secondary["usedPercent"] as? NSNumber { text("Weekly window: \(max(0, 100 - used.intValue))% left", into: submenu) }
119
+ if let reset = account["next_check"] { countdown("Next check", value: reset, into: submenu) }
120
+ text("Last checked: \(date(account["usage_checked_at"]))", into: submenu)
121
+ let entry = NSMenuItem(title: "\(name) · \(label)", action: nil, keyEquivalent: ""); entry.submenu = submenu; menu.addItem(entry)
122
+ }
123
+ menu.addItem(.separator())
124
+ for session in sessions {
125
+ let id = session["id"] as? String ?? ""
126
+ let name = session["name"] as? String ?? String(id.prefix(10))
127
+ let status = (session["state"] as? String ?? "unknown").replacingOccurrences(of: "_", with: " ")
128
+ let submenu = NSMenu()
129
+ text("Account: \(session["account"] as? String ?? "unknown")", into: submenu)
130
+ text("Model: \(session["model"] as? String ?? "unknown")", into: submenu)
131
+ if let next = session["next_wake"] { countdown("Next action", value: next, into: submenu) }
132
+ if let reason = session["reason"] as? String { text(reason.replacingOccurrences(of: "_", with: " "), into: submenu) }
133
+ action("Copy reconnect command", args: ["copy", "relay attach " + id], into: submenu)
134
+ action("Pause recovery", args: ["pause", id, "--wait"], into: submenu)
135
+ action("Resume recovery", args: ["unpause", id, "--wait"], into: submenu)
136
+ action("Review and retry…", args: ["confirm-retry", id], into: submenu)
137
+ let entry = NSMenuItem(title: "\(name) · \(status)", action: nil, keyEquivalent: ""); entry.submenu = submenu; menu.addItem(entry)
138
+ }
139
+ menu.addItem(.separator())
140
+ action("Copy dashboard command", args: ["copy", "relay status --watch"], into: menu)
141
+ action("Check allowance now", args: ["refresh"], into: menu)
142
+ let login = NSMenuItem(title: "Open at login", action: #selector(toggleLogin), keyEquivalent: ""); login.target = self
143
+ login.state = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist") ? .on : .off
144
+ menu.addItem(login)
145
+ let quit = NSMenuItem(title: "Quit Relay menu", action: #selector(quitMenu), keyEquivalent: "q"); quit.target = self; menu.addItem(quit)
146
+ item.menu = menu
147
+ }
148
+ func menuWillOpen(_ menu: NSMenu) {
149
+ updateTimes(menu)
150
+ for entry in menu.items where entry.title == "Open at login" {
151
+ entry.state = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist") ? .on : .off
152
+ }
153
+ }
154
+ @objc func runAction(_ sender: NSMenuItem) {
155
+ guard var args = sender.representedObject as? [String], !args.isEmpty else { return }
156
+ if args[0] == "copy" { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(args[1], forType: .string); return }
157
+ if args[0] == "confirm-retry" {
158
+ let alert = NSAlert(); alert.messageText = "Continue this task?"; alert.informativeText = "Review any interrupted command or external action in the conversation before continuing."
159
+ alert.addButton(withTitle: "Continue task"); alert.addButton(withTitle: "Cancel")
160
+ NSApp.activate(ignoringOtherApps: true)
161
+ guard alert.runModal() == .alertFirstButtonReturn else { return }
162
+ args = ["retry", args[1], "--wait"]
163
+ }
164
+ let proc = process(args); let pipe = Pipe(); proc.standardOutput = FileHandle.nullDevice; proc.standardError = pipe
165
+ DispatchQueue.global(qos: .utility).async { [weak self] in
166
+ do {
167
+ try proc.run(); let bytes = pipe.fileHandleForReading.readDataToEndOfFile(); proc.waitUntilExit()
168
+ if proc.terminationStatus != 0 { DispatchQueue.main.async { self?.message = String(data: bytes, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Action failed. Check relay events."; self?.render() } }
169
+ } catch { DispatchQueue.main.async { self?.message = "Could not run that action."; self?.render() } }
170
+ }
171
+ }
172
+ @objc func toggleLogin() {
173
+ let enabled = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist")
174
+ runCommand(enabled ? ["menubar", "--no-startup", "--configure-only"] : ["menubar", "--configure-only"])
175
+ }
176
+ func runCommand(_ args: [String]) { let proc = process(args); proc.standardOutput = FileHandle.nullDevice; proc.standardError = FileHandle.nullDevice; try? proc.run() }
177
+ @objc func quitMenu() { stopping = true; reconnect?.cancel(); stream?.terminate(); NSApp.terminate(nil) }
178
+ func applicationWillTerminate(_ notification: Notification) { stopping = true; stream?.terminate() }
179
+ }
180
+ if CommandLine.arguments.contains("--self-check") {
181
+ let normal = NSImage(systemSymbolName: "arrow.triangle.branch", accessibilityDescription: "Relay")
182
+ let attention = NSImage(systemSymbolName: "exclamationmark.circle.fill", accessibilityDescription: "Relay needs attention")
183
+ print("Relay menu symbols available: \(normal != nil && attention != nil)")
184
+ exit(normal != nil && attention != nil ? 0 : 1)
185
+ } else {
186
+ let app = NSApplication.shared
187
+ let delegate = MenuController(); app.delegate = delegate; app.run()
188
+ }
@@ -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)