relay-codex 0.4.1 → 0.5.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/python/launch.py CHANGED
@@ -1,44 +1,121 @@
1
- """Translate local launch preferences into explicit app-server thread settings."""
1
+ """Keep native argument boundaries while adapting interactive remote launches."""
2
2
 
3
+ import json
4
+ import functools
5
+ import subprocess
6
+ import os
7
+ import re
8
+ import tomllib
9
+ from pathlib import Path
3
10
 
4
- def translate(arguments):
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 --."""
5
64
  arguments = list(arguments)
6
- if arguments and arguments[0] == "--":
7
- arguments.pop(0)
8
- frontend, overrides = [], {}
9
65
  i = 0
10
66
  while i < len(arguments):
11
67
  arg = arguments[i]
12
68
  if arg == "--":
13
- frontend.extend(arguments[i:])
14
- break
15
- if arg in ("--yolo", "--dangerously-bypass-approvals-and-sandbox"):
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"):
16
111
  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)
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"):
42
119
  overrides[
43
120
  {
44
121
  "--sandbox": "sandbox",
@@ -46,9 +123,9 @@ def translate(arguments):
46
123
  "--permission-profile": "permissions",
47
124
  }[name]
48
125
  ] = value
49
- else:
50
- frontend.append(arg)
51
- i += 1
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)
52
129
  if "sandbox" in overrides and "permissions" in overrides:
53
130
  raise ValueError(
54
131
  "Choose either --sandbox/--yolo or a named permission profile."
@@ -72,46 +149,269 @@ def translate(arguments):
72
149
 
73
150
 
74
151
  def backend_arguments(arguments):
75
- """Copy config/profile selection to the backend without consuming native UI args."""
152
+ """Mirror server config without reading prompts or other options' values."""
76
153
  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":
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":
98
164
  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
165
+ elif name == "--strict-config":
166
+ result.append(name)
167
+ return result
106
168
 
107
- converted.extend(["-c", "model=" + json.dumps(result[i + 1])])
108
- i += 2
109
- elif result[i].startswith("--model="):
110
- import json
111
169
 
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
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
package/python/pool.py CHANGED
@@ -433,6 +433,13 @@ class Pool:
433
433
  (self.clock() - settings.load(self.state)["history_days"] * 86400,),
434
434
  )
435
435
 
436
+ def checked_at(self, label):
437
+ with self.db() as db:
438
+ row = db.execute(
439
+ "SELECT checked FROM usage WHERE label=?", (label,)
440
+ ).fetchone()
441
+ return row[0] if row else None
442
+
436
443
  def cached(self, label, max_age=60, not_before=None):
437
444
  with self.db() as db:
438
445
  row = db.execute(