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
package/python/launch.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""Keep native argument boundaries while adapting interactive remote launches."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import functools
|
|
5
|
+
import subprocess
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tomllib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# Values must be consumed together: a value resembling another flag is still a
|
|
12
|
+
# value. Unknown options remain untouched for the installed native CLI to validate.
|
|
13
|
+
RESUME_FIELDS = {
|
|
14
|
+
"model",
|
|
15
|
+
"modelProvider",
|
|
16
|
+
"cwd",
|
|
17
|
+
"approvalPolicy",
|
|
18
|
+
"approvalsReviewer",
|
|
19
|
+
"sandbox",
|
|
20
|
+
"permissions",
|
|
21
|
+
"serviceTier",
|
|
22
|
+
"runtimeWorkspaceRoots",
|
|
23
|
+
"config",
|
|
24
|
+
"personality",
|
|
25
|
+
"baseInstructions",
|
|
26
|
+
"developerInstructions",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
VALUE_FLAGS = {
|
|
31
|
+
"-c": "--config",
|
|
32
|
+
"-p": "--profile",
|
|
33
|
+
"-m": "--model",
|
|
34
|
+
"-C": "--cd",
|
|
35
|
+
"-i": "--image",
|
|
36
|
+
"-s": "--sandbox",
|
|
37
|
+
"-a": "--ask-for-approval",
|
|
38
|
+
"-P": "--permission-profile",
|
|
39
|
+
"--enable": "--enable",
|
|
40
|
+
"--disable": "--disable",
|
|
41
|
+
"--local-provider": "--local-provider",
|
|
42
|
+
"--add-dir": "--add-dir",
|
|
43
|
+
"--remote": "--remote",
|
|
44
|
+
"--remote-auth-token-env": "--remote-auth-token-env",
|
|
45
|
+
"--thread-source": "--thread-source",
|
|
46
|
+
"--cyber-access-program": "--cyber-access-program",
|
|
47
|
+
"--output-schema": "--output-schema",
|
|
48
|
+
"--color": "--color",
|
|
49
|
+
"-o": "--output-last-message",
|
|
50
|
+
"--base": "--base",
|
|
51
|
+
"--commit": "--commit",
|
|
52
|
+
"--title": "--title",
|
|
53
|
+
}
|
|
54
|
+
VALUE_FLAGS.update({value: value for value in list(VALUE_FLAGS.values())})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def arguments_only(arguments):
|
|
58
|
+
arguments = list(arguments)
|
|
59
|
+
return arguments[1:] if arguments[:1] == ["--"] else arguments
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def tokens(arguments):
|
|
63
|
+
"""Yield (canonical option, value, original tokens); stop parsing at --."""
|
|
64
|
+
arguments = list(arguments)
|
|
65
|
+
i = 0
|
|
66
|
+
while i < len(arguments):
|
|
67
|
+
arg = arguments[i]
|
|
68
|
+
if arg == "--":
|
|
69
|
+
yield None, None, arguments[i:]
|
|
70
|
+
return
|
|
71
|
+
name, sep, value = arg.partition("=")
|
|
72
|
+
if name in VALUE_FLAGS:
|
|
73
|
+
if sep:
|
|
74
|
+
yield VALUE_FLAGS[name], value, [arg]
|
|
75
|
+
else:
|
|
76
|
+
if i + 1 == len(arguments):
|
|
77
|
+
raise ValueError(f"{arg} requires a value")
|
|
78
|
+
yield VALUE_FLAGS[name], arguments[i + 1], arguments[i : i + 2]
|
|
79
|
+
i += 1
|
|
80
|
+
elif len(arg) > 2 and arg[:2] in VALUE_FLAGS and not arg.startswith("--"):
|
|
81
|
+
yield VALUE_FLAGS[arg[:2]], arg[2:].removeprefix("="), [arg]
|
|
82
|
+
else:
|
|
83
|
+
yield arg if arg.startswith("-") else None, arg, [arg]
|
|
84
|
+
i += 1
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def working_directory(arguments, cwd):
|
|
88
|
+
target = Path(cwd)
|
|
89
|
+
for name, value, _ in tokens(arguments_only(arguments)):
|
|
90
|
+
if name == "--cd":
|
|
91
|
+
path = Path(value).expanduser()
|
|
92
|
+
target = path if path.is_absolute() else Path(cwd) / path
|
|
93
|
+
return str(target.resolve())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def frontend_arguments(arguments, cwd):
|
|
97
|
+
"""Remote Codex needs an explicit cwd to offer its Cwd session filter."""
|
|
98
|
+
arguments = arguments_only(arguments)
|
|
99
|
+
target = working_directory(arguments, cwd)
|
|
100
|
+
result = ["-C", target]
|
|
101
|
+
for name, _, original in tokens(arguments):
|
|
102
|
+
if name != "--cd":
|
|
103
|
+
result.extend(original)
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def translate(arguments):
|
|
108
|
+
frontend, overrides = [], {}
|
|
109
|
+
for name, value, original in tokens(arguments_only(arguments)):
|
|
110
|
+
if name in ("--yolo", "--dangerously-bypass-approvals-and-sandbox"):
|
|
111
|
+
overrides.update(approvalPolicy="never", sandbox="danger-full-access")
|
|
112
|
+
elif name == "--approve-for-me":
|
|
113
|
+
overrides.update(
|
|
114
|
+
approvalPolicy="on-request",
|
|
115
|
+
sandbox="workspace-write",
|
|
116
|
+
approvalsReviewer="auto_review",
|
|
117
|
+
)
|
|
118
|
+
elif name in ("--sandbox", "--ask-for-approval", "--permission-profile"):
|
|
119
|
+
overrides[
|
|
120
|
+
{
|
|
121
|
+
"--sandbox": "sandbox",
|
|
122
|
+
"--ask-for-approval": "approvalPolicy",
|
|
123
|
+
"--permission-profile": "permissions",
|
|
124
|
+
}[name]
|
|
125
|
+
] = value
|
|
126
|
+
elif name not in {"--no-daemon", "--profile", "--add-dir"}:
|
|
127
|
+
# Relay already gives each managed task its own private app server.
|
|
128
|
+
frontend.extend(original)
|
|
129
|
+
if "sandbox" in overrides and "permissions" in overrides:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
"Choose either --sandbox/--yolo or a named permission profile."
|
|
132
|
+
)
|
|
133
|
+
if overrides.get("sandbox") not in (
|
|
134
|
+
None,
|
|
135
|
+
"read-only",
|
|
136
|
+
"workspace-write",
|
|
137
|
+
"danger-full-access",
|
|
138
|
+
):
|
|
139
|
+
raise ValueError("Unsupported sandbox mode.")
|
|
140
|
+
if overrides.get("approvalPolicy") not in (
|
|
141
|
+
None,
|
|
142
|
+
"never",
|
|
143
|
+
"on-request",
|
|
144
|
+
"untrusted",
|
|
145
|
+
"on-failure",
|
|
146
|
+
):
|
|
147
|
+
raise ValueError("Unsupported approval policy.")
|
|
148
|
+
return frontend, overrides
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def backend_arguments(arguments):
|
|
152
|
+
"""Mirror server config without reading prompts or other options' values."""
|
|
153
|
+
result = []
|
|
154
|
+
parsed = list(tokens(arguments_only(arguments)))
|
|
155
|
+
for name, value, _ in parsed:
|
|
156
|
+
if name == "--profile":
|
|
157
|
+
result.extend(profile_arguments(value))
|
|
158
|
+
for name, value, original in parsed:
|
|
159
|
+
if name in {"--config", "--enable", "--disable"}:
|
|
160
|
+
result.extend(original)
|
|
161
|
+
elif name == "--model":
|
|
162
|
+
result.extend(["-c", "model=" + json.dumps(value)])
|
|
163
|
+
elif name == "--search":
|
|
164
|
+
result.extend(["-c", 'web_search="live"'])
|
|
165
|
+
elif name == "--strict-config":
|
|
166
|
+
result.append(name)
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
NATIVE_COMMANDS = {
|
|
171
|
+
"agents",
|
|
172
|
+
"login",
|
|
173
|
+
"logout",
|
|
174
|
+
"mcp",
|
|
175
|
+
"plugin",
|
|
176
|
+
"app-server",
|
|
177
|
+
"remote-control",
|
|
178
|
+
"app",
|
|
179
|
+
"completion",
|
|
180
|
+
"update",
|
|
181
|
+
"doctor",
|
|
182
|
+
"sandbox",
|
|
183
|
+
"debug",
|
|
184
|
+
"apply",
|
|
185
|
+
"a",
|
|
186
|
+
"queue",
|
|
187
|
+
"archive",
|
|
188
|
+
"delete",
|
|
189
|
+
"migrate-rollouts",
|
|
190
|
+
"migrate-nfs-db",
|
|
191
|
+
"unarchive",
|
|
192
|
+
"cloud",
|
|
193
|
+
"exec-server",
|
|
194
|
+
"features",
|
|
195
|
+
"help",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@functools.lru_cache(maxsize=8)
|
|
200
|
+
def native_commands(binary):
|
|
201
|
+
result = subprocess.run(
|
|
202
|
+
[binary, "--help"], capture_output=True, text=True, timeout=15
|
|
203
|
+
)
|
|
204
|
+
if result.returncode:
|
|
205
|
+
return NATIVE_COMMANDS
|
|
206
|
+
section = result.stdout.split("Commands:", 1)
|
|
207
|
+
commands = set(NATIVE_COMMANDS)
|
|
208
|
+
if len(section) > 1:
|
|
209
|
+
for line in section[1].splitlines():
|
|
210
|
+
if line and not line.startswith(" "):
|
|
211
|
+
break
|
|
212
|
+
match = re.match(r" ([a-z][a-z0-9-]*)\s", line)
|
|
213
|
+
if match:
|
|
214
|
+
commands.add(match.group(1))
|
|
215
|
+
return commands - {"exec", "e", "review", "resume", "fork"}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def native_mode(arguments, binary=None):
|
|
219
|
+
"""Modes outside the remote interactive protocol run native, unchanged."""
|
|
220
|
+
commands = native_commands(binary) if binary else NATIVE_COMMANDS
|
|
221
|
+
first_positional = True
|
|
222
|
+
initial_command = None
|
|
223
|
+
for name, value, original in tokens(arguments_only(arguments)):
|
|
224
|
+
if name in {
|
|
225
|
+
"--help",
|
|
226
|
+
"-h",
|
|
227
|
+
"--version",
|
|
228
|
+
"-V",
|
|
229
|
+
"--remote",
|
|
230
|
+
"--oss",
|
|
231
|
+
"--local-provider",
|
|
232
|
+
"--ignore-user-config",
|
|
233
|
+
"--ignore-rules",
|
|
234
|
+
"--dangerously-bypass-hook-trust",
|
|
235
|
+
}:
|
|
236
|
+
return True
|
|
237
|
+
if name is None and len(original) == 1 and first_positional:
|
|
238
|
+
first_positional = False
|
|
239
|
+
initial_command = value
|
|
240
|
+
if value in commands:
|
|
241
|
+
return True
|
|
242
|
+
elif name is None and value == "help" and initial_command in {"exec", "e"}:
|
|
243
|
+
return True
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def toml_value(value):
|
|
248
|
+
if isinstance(value, bool):
|
|
249
|
+
return "true" if value else "false"
|
|
250
|
+
if isinstance(value, str):
|
|
251
|
+
return json.dumps(value)
|
|
252
|
+
if isinstance(value, (int, float)):
|
|
253
|
+
return str(value)
|
|
254
|
+
if isinstance(value, list):
|
|
255
|
+
return "[" + ", ".join(toml_value(v) for v in value) + "]"
|
|
256
|
+
if isinstance(value, dict):
|
|
257
|
+
return (
|
|
258
|
+
"{"
|
|
259
|
+
+ ", ".join(json.dumps(k) + "=" + toml_value(v) for k, v in value.items())
|
|
260
|
+
+ "}"
|
|
261
|
+
)
|
|
262
|
+
if hasattr(value, "isoformat"):
|
|
263
|
+
return value.isoformat()
|
|
264
|
+
raise ValueError("Unsupported profile value")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def profile_arguments(name, home=None):
|
|
268
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", name):
|
|
269
|
+
raise ValueError(
|
|
270
|
+
"Choose a native profile name containing letters, numbers, - or _"
|
|
271
|
+
)
|
|
272
|
+
home = Path(home or os.environ.get("CODEX_HOME", Path.home() / ".codex"))
|
|
273
|
+
path = home / (name + ".config.toml")
|
|
274
|
+
if not path.is_file():
|
|
275
|
+
raise ValueError("Native Codex profile does not exist: " + name)
|
|
276
|
+
data = tomllib.loads(path.read_text())
|
|
277
|
+
result = []
|
|
278
|
+
|
|
279
|
+
def flatten(value, keys):
|
|
280
|
+
if any(
|
|
281
|
+
re.search(r"token|secret|password|api_key|authorization|headers", key, re.I)
|
|
282
|
+
for key in keys
|
|
283
|
+
):
|
|
284
|
+
return
|
|
285
|
+
if isinstance(value, dict) and value:
|
|
286
|
+
if all(re.fullmatch(r"[A-Za-z0-9_-]+", key) for key in value):
|
|
287
|
+
for key, child in value.items():
|
|
288
|
+
flatten(child, [*keys, key])
|
|
289
|
+
return
|
|
290
|
+
if sensitive_config(value):
|
|
291
|
+
return
|
|
292
|
+
if isinstance(value, list) and sensitive_config(value):
|
|
293
|
+
return
|
|
294
|
+
result.extend(["-c", ".".join(keys) + "=" + toml_value(value)])
|
|
295
|
+
|
|
296
|
+
for key, value in data.items():
|
|
297
|
+
flatten(value, [key])
|
|
298
|
+
return result
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def additional_roots(arguments, cwd):
|
|
302
|
+
roots = []
|
|
303
|
+
for name, value, _ in tokens(arguments_only(arguments)):
|
|
304
|
+
if name == "--add-dir":
|
|
305
|
+
path = Path(value).expanduser()
|
|
306
|
+
roots.append(
|
|
307
|
+
str((path if path.is_absolute() else Path(cwd) / path).resolve())
|
|
308
|
+
)
|
|
309
|
+
return roots
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def prepare_worktree(arguments, state, cwd):
|
|
313
|
+
"""Create an isolated Git worktree once; never remove a user's working files."""
|
|
314
|
+
import subprocess
|
|
315
|
+
import uuid
|
|
316
|
+
|
|
317
|
+
parsed = list(tokens(arguments_only(arguments)))
|
|
318
|
+
if not any(name == "--worktree" for name, _, _ in parsed):
|
|
319
|
+
return list(arguments), cwd
|
|
320
|
+
positional = [
|
|
321
|
+
value for name, value, raw in parsed if name is None and len(raw) == 1
|
|
322
|
+
]
|
|
323
|
+
flags = {name for name, _, _ in parsed}
|
|
324
|
+
if (
|
|
325
|
+
"resume" in positional[:2]
|
|
326
|
+
or "review" in positional[:2]
|
|
327
|
+
or flags & {"--ephemeral", "--ignore-user-config"}
|
|
328
|
+
):
|
|
329
|
+
raise ValueError(
|
|
330
|
+
"--worktree cannot be combined with resume, review, --ephemeral, or --ignore-user-config"
|
|
331
|
+
)
|
|
332
|
+
if "fork" in positional[:2] and (
|
|
333
|
+
"--last" in flags or len(positional) < positional.index("fork") + 2
|
|
334
|
+
):
|
|
335
|
+
raise ValueError("A worktree fork requires an explicit session ID")
|
|
336
|
+
probe = subprocess.run(
|
|
337
|
+
["git", "-C", cwd, "rev-parse", "--show-toplevel"],
|
|
338
|
+
capture_output=True,
|
|
339
|
+
text=True,
|
|
340
|
+
)
|
|
341
|
+
if probe.returncode:
|
|
342
|
+
raise ValueError("--worktree requires a Git repository with a commit")
|
|
343
|
+
root = state / "worktrees"
|
|
344
|
+
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
345
|
+
target = root / uuid.uuid4().hex[:12]
|
|
346
|
+
create = subprocess.run(
|
|
347
|
+
["git", "-C", cwd, "worktree", "add", "--detach", str(target), "HEAD"],
|
|
348
|
+
capture_output=True,
|
|
349
|
+
text=True,
|
|
350
|
+
)
|
|
351
|
+
if create.returncode:
|
|
352
|
+
raise ValueError(
|
|
353
|
+
"Git could not create a worktree. Check the repository and available disk space."
|
|
354
|
+
)
|
|
355
|
+
result = []
|
|
356
|
+
for name, _, raw in parsed:
|
|
357
|
+
if name != "--worktree":
|
|
358
|
+
result.extend(raw)
|
|
359
|
+
result = frontend_arguments(result, cwd)
|
|
360
|
+
result[result.index("-C") + 1] = str(target.resolve())
|
|
361
|
+
return result, str(target.resolve())
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def sensitive_config(value):
|
|
365
|
+
if isinstance(value, dict):
|
|
366
|
+
return any(
|
|
367
|
+
re.search(r"token|secret|password|api_key|authorization|headers", key, re.I)
|
|
368
|
+
or sensitive_config(child)
|
|
369
|
+
for key, child in value.items()
|
|
370
|
+
)
|
|
371
|
+
if isinstance(value, list):
|
|
372
|
+
return any(sensitive_config(child) for child in value)
|
|
373
|
+
return False
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def profile_config(arguments, home=None):
|
|
377
|
+
"""Secret-bearing profile fields travel over private RPC, not process argv."""
|
|
378
|
+
home = Path(home or os.environ.get("CODEX_HOME", Path.home() / ".codex"))
|
|
379
|
+
result = {}
|
|
380
|
+
for name, value, _ in tokens(arguments_only(arguments)):
|
|
381
|
+
if name == "--profile":
|
|
382
|
+
# Validate names and existence through the same path as backend arguments.
|
|
383
|
+
profile_arguments(value, home)
|
|
384
|
+
data = tomllib.loads((home / (value + ".config.toml")).read_text())
|
|
385
|
+
|
|
386
|
+
def contains_sensitive(child, keys=()):
|
|
387
|
+
if any(
|
|
388
|
+
re.search(
|
|
389
|
+
r"token|secret|password|api_key|authorization|headers",
|
|
390
|
+
key,
|
|
391
|
+
re.I,
|
|
392
|
+
)
|
|
393
|
+
for key in keys
|
|
394
|
+
):
|
|
395
|
+
return True
|
|
396
|
+
if isinstance(child, dict):
|
|
397
|
+
return any(
|
|
398
|
+
contains_sensitive(v, (*keys, k)) for k, v in child.items()
|
|
399
|
+
)
|
|
400
|
+
if isinstance(child, list):
|
|
401
|
+
return any(contains_sensitive(v, keys) for v in child)
|
|
402
|
+
return False
|
|
403
|
+
|
|
404
|
+
for key, child in data.items():
|
|
405
|
+
if contains_sensitive(child, (key,)):
|
|
406
|
+
result[key] = child
|
|
407
|
+
# Explicit native overrides retain higher precedence than a profile layer.
|
|
408
|
+
for name, value, _ in tokens(arguments_only(arguments)):
|
|
409
|
+
if name == "--config" and "=" in value:
|
|
410
|
+
key, raw = value.split("=", 1)
|
|
411
|
+
if key.split(".")[0] in result:
|
|
412
|
+
try:
|
|
413
|
+
parsed = tomllib.loads("value=" + raw)["value"]
|
|
414
|
+
except tomllib.TOMLDecodeError:
|
|
415
|
+
parsed = raw
|
|
416
|
+
result[key] = parsed
|
|
417
|
+
return result
|
|
@@ -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
|
+
)
|