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,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
|
+
}
|
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)
|