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