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
|
@@ -0,0 +1,225 @@
|
|
|
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 = 4
|
|
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
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"id": "probe",
|
|
137
|
+
"method": "turn/start",
|
|
138
|
+
"params": {
|
|
139
|
+
"threadId": ident,
|
|
140
|
+
"input": [{"type": "text", "text": "probe", "text_elements": []}],
|
|
141
|
+
"clientUserMessageId": ident,
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"id": "probe",
|
|
146
|
+
"method": "thread/turns/list",
|
|
147
|
+
"params": {
|
|
148
|
+
"threadId": ident,
|
|
149
|
+
"limit": 1,
|
|
150
|
+
"sortDirection": "desc",
|
|
151
|
+
"itemsView": "summary",
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
]
|
|
155
|
+
for sample in examples:
|
|
156
|
+
if not validator.is_valid(sample):
|
|
157
|
+
raise ValueError(
|
|
158
|
+
"Native RPC request contract changed: " + sample["method"]
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def check(binary, state):
|
|
163
|
+
"""Cache by launcher bytes/version; serialize callers with caller's file lock."""
|
|
164
|
+
version = subprocess.run(
|
|
165
|
+
[binary, "--version"], capture_output=True, text=True, check=True, timeout=15
|
|
166
|
+
).stdout.strip()
|
|
167
|
+
native = executable(binary)
|
|
168
|
+
fingerprint = hashlib.sha256(
|
|
169
|
+
(digest(binary) + digest(native) + version + str(ADAPTER)).encode()
|
|
170
|
+
).hexdigest()
|
|
171
|
+
cache = state / "compatibility.json"
|
|
172
|
+
if cache.exists():
|
|
173
|
+
try:
|
|
174
|
+
value = json.loads(cache.read_text())
|
|
175
|
+
if value.get("fingerprint") == fingerprint:
|
|
176
|
+
return value
|
|
177
|
+
except (ValueError, OSError):
|
|
178
|
+
pass
|
|
179
|
+
with tempfile.TemporaryDirectory(prefix="codex-account-schema-") as folder:
|
|
180
|
+
root = Path(folder)
|
|
181
|
+
subprocess.run(
|
|
182
|
+
[
|
|
183
|
+
binary,
|
|
184
|
+
"app-server",
|
|
185
|
+
"generate-json-schema",
|
|
186
|
+
"--experimental",
|
|
187
|
+
"--out",
|
|
188
|
+
folder,
|
|
189
|
+
],
|
|
190
|
+
capture_output=True,
|
|
191
|
+
check=True,
|
|
192
|
+
timeout=45,
|
|
193
|
+
)
|
|
194
|
+
validate_schema(root)
|
|
195
|
+
help_text = subprocess.run(
|
|
196
|
+
[binary, "--help"], capture_output=True, text=True, check=True, timeout=15
|
|
197
|
+
).stdout
|
|
198
|
+
if not all(flag in help_text for flag in ["--remote", "--remote-auth-token-env"]):
|
|
199
|
+
raise ValueError("Terminal remote connection flags changed.")
|
|
200
|
+
# New releases must also accept the live login operation twice in the same
|
|
201
|
+
# native process. Fake identities only; an OS sandbox denies all networking.
|
|
202
|
+
subprocess.run(
|
|
203
|
+
[
|
|
204
|
+
sys.executable,
|
|
205
|
+
str(Path(__file__).with_name("probe_native.py")),
|
|
206
|
+
"--binary",
|
|
207
|
+
binary,
|
|
208
|
+
],
|
|
209
|
+
capture_output=True,
|
|
210
|
+
check=True,
|
|
211
|
+
timeout=40,
|
|
212
|
+
)
|
|
213
|
+
value = {
|
|
214
|
+
"version": version,
|
|
215
|
+
"fingerprint": fingerprint,
|
|
216
|
+
"adapter": ADAPTER,
|
|
217
|
+
"native_executable": str(native),
|
|
218
|
+
"native_sha256": digest(native),
|
|
219
|
+
"validation": "schema, CLI flags, network-denied native login-switch probe; real handshake on launch",
|
|
220
|
+
}
|
|
221
|
+
temporary = cache.with_suffix(".tmp")
|
|
222
|
+
temporary.write_text(json.dumps(value, indent=2) + "\n")
|
|
223
|
+
temporary.chmod(0o600)
|
|
224
|
+
temporary.replace(cache)
|
|
225
|
+
return value
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Optional macOS menu app."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import plistlib
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from processes import spawn
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def menubar(state, launcher=None, build_only=False):
|
|
11
|
+
from router import VERSION
|
|
12
|
+
|
|
13
|
+
launcher = launcher or shutil.which("relay")
|
|
14
|
+
if not launcher:
|
|
15
|
+
raise ValueError("The relay command is not on PATH")
|
|
16
|
+
source = Path(__file__).with_name("Menu.swift")
|
|
17
|
+
app = state / "Relay Menu.app"
|
|
18
|
+
contents = app / "Contents"
|
|
19
|
+
binary = contents / "MacOS" / "RelayMenu"
|
|
20
|
+
binary.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
if not binary.exists() or source.stat().st_mtime > binary.stat().st_mtime:
|
|
22
|
+
compiler = shutil.which("swiftc")
|
|
23
|
+
if not compiler:
|
|
24
|
+
raise ValueError(
|
|
25
|
+
"Swift compiler is unavailable; install Apple command-line tools to build the optional menu app"
|
|
26
|
+
)
|
|
27
|
+
subprocess.run([compiler, str(source), "-o", str(binary)], check=True)
|
|
28
|
+
with (contents / "Info.plist").open("wb") as stream:
|
|
29
|
+
plistlib.dump(
|
|
30
|
+
{
|
|
31
|
+
"CFBundleIdentifier": "local.relay.menu",
|
|
32
|
+
"CFBundleName": "Relay",
|
|
33
|
+
"CFBundleExecutable": "RelayMenu",
|
|
34
|
+
"CFBundleVersion": VERSION,
|
|
35
|
+
"RelayCommand": launcher,
|
|
36
|
+
"LSUIElement": True,
|
|
37
|
+
},
|
|
38
|
+
stream,
|
|
39
|
+
)
|
|
40
|
+
subprocess.run(
|
|
41
|
+
["/usr/bin/codesign", "--force", "--sign", "-", str(app)],
|
|
42
|
+
check=True,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
)
|
|
45
|
+
if not build_only:
|
|
46
|
+
spawn(
|
|
47
|
+
["/usr/bin/open", "-n", str(app), "--args", str(launcher)],
|
|
48
|
+
stdin=subprocess.DEVNULL,
|
|
49
|
+
stdout=subprocess.DEVNULL,
|
|
50
|
+
stderr=subprocess.DEVNULL,
|
|
51
|
+
)
|
|
52
|
+
return app
|
package/python/launch.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Translate local launch preferences into explicit app-server thread settings."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def translate(arguments):
|
|
5
|
+
arguments = list(arguments)
|
|
6
|
+
if arguments and arguments[0] == "--":
|
|
7
|
+
arguments.pop(0)
|
|
8
|
+
frontend, overrides = [], {}
|
|
9
|
+
i = 0
|
|
10
|
+
while i < len(arguments):
|
|
11
|
+
arg = arguments[i]
|
|
12
|
+
if arg == "--":
|
|
13
|
+
frontend.extend(arguments[i:])
|
|
14
|
+
break
|
|
15
|
+
if arg in ("--yolo", "--dangerously-bypass-approvals-and-sandbox"):
|
|
16
|
+
overrides.update(approvalPolicy="never", sandbox="danger-full-access")
|
|
17
|
+
elif arg in (
|
|
18
|
+
"-s",
|
|
19
|
+
"--sandbox",
|
|
20
|
+
"-a",
|
|
21
|
+
"--ask-for-approval",
|
|
22
|
+
"-P",
|
|
23
|
+
"--permission-profile",
|
|
24
|
+
):
|
|
25
|
+
if i + 1 == len(arguments):
|
|
26
|
+
raise ValueError(f"{arg} requires a value")
|
|
27
|
+
i += 1
|
|
28
|
+
key = {
|
|
29
|
+
"-s": "sandbox",
|
|
30
|
+
"--sandbox": "sandbox",
|
|
31
|
+
"-a": "approvalPolicy",
|
|
32
|
+
"--ask-for-approval": "approvalPolicy",
|
|
33
|
+
"-P": "permissions",
|
|
34
|
+
"--permission-profile": "permissions",
|
|
35
|
+
}[arg]
|
|
36
|
+
overrides[key] = arguments[i]
|
|
37
|
+
elif any(
|
|
38
|
+
arg.startswith(flag + "=")
|
|
39
|
+
for flag in ("--sandbox", "--ask-for-approval", "--permission-profile")
|
|
40
|
+
):
|
|
41
|
+
name, value = arg.split("=", 1)
|
|
42
|
+
overrides[
|
|
43
|
+
{
|
|
44
|
+
"--sandbox": "sandbox",
|
|
45
|
+
"--ask-for-approval": "approvalPolicy",
|
|
46
|
+
"--permission-profile": "permissions",
|
|
47
|
+
}[name]
|
|
48
|
+
] = value
|
|
49
|
+
else:
|
|
50
|
+
frontend.append(arg)
|
|
51
|
+
i += 1
|
|
52
|
+
if "sandbox" in overrides and "permissions" in overrides:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"Choose either --sandbox/--yolo or a named permission profile."
|
|
55
|
+
)
|
|
56
|
+
if overrides.get("sandbox") not in (
|
|
57
|
+
None,
|
|
58
|
+
"read-only",
|
|
59
|
+
"workspace-write",
|
|
60
|
+
"danger-full-access",
|
|
61
|
+
):
|
|
62
|
+
raise ValueError("Unsupported sandbox mode.")
|
|
63
|
+
if overrides.get("approvalPolicy") not in (
|
|
64
|
+
None,
|
|
65
|
+
"never",
|
|
66
|
+
"on-request",
|
|
67
|
+
"untrusted",
|
|
68
|
+
"on-failure",
|
|
69
|
+
):
|
|
70
|
+
raise ValueError("Unsupported approval policy.")
|
|
71
|
+
return frontend, overrides
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def backend_arguments(arguments):
|
|
75
|
+
"""Copy config/profile selection to the backend without consuming native UI args."""
|
|
76
|
+
result = []
|
|
77
|
+
i = 0
|
|
78
|
+
while i < len(arguments):
|
|
79
|
+
arg = arguments[i]
|
|
80
|
+
if arg in {
|
|
81
|
+
"-c",
|
|
82
|
+
"--config",
|
|
83
|
+
"-p",
|
|
84
|
+
"--profile",
|
|
85
|
+
"--enable",
|
|
86
|
+
"--disable",
|
|
87
|
+
"-m",
|
|
88
|
+
"--model",
|
|
89
|
+
} and i + 1 < len(arguments):
|
|
90
|
+
result.extend([arg, arguments[i + 1]])
|
|
91
|
+
i += 2
|
|
92
|
+
continue
|
|
93
|
+
if arg.startswith(
|
|
94
|
+
("--config=", "--profile=", "--enable=", "--disable=", "--model=")
|
|
95
|
+
):
|
|
96
|
+
result.append(arg)
|
|
97
|
+
elif arg == "--search":
|
|
98
|
+
result.extend(["-c", 'web_search="live"'])
|
|
99
|
+
i += 1
|
|
100
|
+
# app-server has no --model flag; a model config override has the same default.
|
|
101
|
+
converted = []
|
|
102
|
+
i = 0
|
|
103
|
+
while i < len(result):
|
|
104
|
+
if result[i] in {"-m", "--model"}:
|
|
105
|
+
import json
|
|
106
|
+
|
|
107
|
+
converted.extend(["-c", "model=" + json.dumps(result[i + 1])])
|
|
108
|
+
i += 2
|
|
109
|
+
elif result[i].startswith("--model="):
|
|
110
|
+
import json
|
|
111
|
+
|
|
112
|
+
converted.extend(["-c", "model=" + json.dumps(result[i].split("=", 1)[1])])
|
|
113
|
+
i += 1
|
|
114
|
+
else:
|
|
115
|
+
converted.append(result[i])
|
|
116
|
+
i += 1
|
|
117
|
+
return converted
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Recover local routing metadata without touching credentials or conversations."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sqlite3
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
from pool import Pool, process_birth
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def restore_backup(state):
|
|
14
|
+
backup = state / "pool.backup.sqlite"
|
|
15
|
+
if not backup.is_file():
|
|
16
|
+
raise ValueError("No metadata backup exists; use doctor --backup first")
|
|
17
|
+
# Supervised workers are identified independently of the potentially damaged DB.
|
|
18
|
+
for path in (state / "services").glob("*.supervisor.json"):
|
|
19
|
+
try:
|
|
20
|
+
value = json.loads(path.read_text())
|
|
21
|
+
except (ValueError, OSError):
|
|
22
|
+
raise ValueError(
|
|
23
|
+
"Unreadable supervisor metadata; inspect it before restoring"
|
|
24
|
+
) from None
|
|
25
|
+
if Pool.alive(value["pid"]) and process_birth(value["pid"]) == value.get(
|
|
26
|
+
"birth"
|
|
27
|
+
):
|
|
28
|
+
raise ValueError(
|
|
29
|
+
"Stop wrapper sessions before restoring metadata; account logins are retained"
|
|
30
|
+
)
|
|
31
|
+
database = state / "pool.sqlite"
|
|
32
|
+
if database.exists():
|
|
33
|
+
with contextlib.suppress(sqlite3.Error):
|
|
34
|
+
db = sqlite3.connect(database)
|
|
35
|
+
try:
|
|
36
|
+
if any(
|
|
37
|
+
Pool.alive(row[0]) for row in db.execute("SELECT pid FROM sessions")
|
|
38
|
+
):
|
|
39
|
+
raise ValueError("Stop wrapper sessions before restoring metadata")
|
|
40
|
+
finally:
|
|
41
|
+
db.close()
|
|
42
|
+
temporary = state / ("restore-" + uuid.uuid4().hex + ".sqlite")
|
|
43
|
+
source = sqlite3.connect("file:" + str(backup) + "?mode=ro", uri=True)
|
|
44
|
+
try:
|
|
45
|
+
if source.execute("PRAGMA quick_check").fetchone()[0] != "ok":
|
|
46
|
+
raise ValueError("Metadata backup is corrupt")
|
|
47
|
+
dest = sqlite3.connect(temporary)
|
|
48
|
+
try:
|
|
49
|
+
source.backup(dest)
|
|
50
|
+
tables = {
|
|
51
|
+
r[0]
|
|
52
|
+
for r in dest.execute(
|
|
53
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
if not {"usage", "blocks", "sessions", "threads"} <= tables:
|
|
57
|
+
raise ValueError("Not a Relay metadata backup")
|
|
58
|
+
# Old process/command records must never become live after restore.
|
|
59
|
+
for table in (
|
|
60
|
+
"sessions",
|
|
61
|
+
"threads",
|
|
62
|
+
"reservations",
|
|
63
|
+
"session_details",
|
|
64
|
+
"commands",
|
|
65
|
+
):
|
|
66
|
+
if table in tables:
|
|
67
|
+
dest.execute("DELETE FROM " + table)
|
|
68
|
+
dest.commit()
|
|
69
|
+
finally:
|
|
70
|
+
dest.close()
|
|
71
|
+
temporary.chmod(0o600)
|
|
72
|
+
quarantine = state / ("metadata-before-restore-" + str(time.time_ns()))
|
|
73
|
+
quarantine.mkdir(mode=0o700)
|
|
74
|
+
for suffix in ("", "-wal", "-shm", "-journal"):
|
|
75
|
+
path = Path(str(database) + suffix)
|
|
76
|
+
if path.exists():
|
|
77
|
+
path.replace(quarantine / path.name)
|
|
78
|
+
temporary.replace(database)
|
|
79
|
+
return str(quarantine)
|
|
80
|
+
finally:
|
|
81
|
+
source.close()
|
|
82
|
+
temporary.unlink(missing_ok=True)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Opt-in local macOS notifications with a bounded, metadata-only message."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
from processes import spawn
|
|
6
|
+
|
|
7
|
+
_last = {}
|
|
8
|
+
EVENTS = {
|
|
9
|
+
"switched": "Account switched",
|
|
10
|
+
"waiting_for_reset": "Waiting for account reset",
|
|
11
|
+
"needs_attention": "Codex needs attention",
|
|
12
|
+
"completed": "Codex task finished",
|
|
13
|
+
"backend_restored": "Codex session recovered",
|
|
14
|
+
"model_fallback": "Configured model fallback selected",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def notify(kind, label=""):
|
|
19
|
+
if kind not in EVENTS or time.monotonic() - _last.get(kind, 0) < 30:
|
|
20
|
+
return
|
|
21
|
+
_last[kind] = time.monotonic()
|
|
22
|
+
# argv is passed to AppleScript as data, never interpolated as script code.
|
|
23
|
+
script = (
|
|
24
|
+
'on run argv\ndisplay notification (item 1 of argv) with title "Relay"\nend run'
|
|
25
|
+
)
|
|
26
|
+
spawn(
|
|
27
|
+
[
|
|
28
|
+
"/usr/bin/osascript",
|
|
29
|
+
"-e",
|
|
30
|
+
script,
|
|
31
|
+
EVENTS[kind] + (" — " + label if label else ""),
|
|
32
|
+
],
|
|
33
|
+
stdin=subprocess.DEVNULL,
|
|
34
|
+
stdout=subprocess.DEVNULL,
|
|
35
|
+
stderr=subprocess.DEVNULL,
|
|
36
|
+
)
|