cdx-manager 0.25.8 → 0.26.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/src/run_usage.py CHANGED
@@ -10,6 +10,10 @@ definitions without saying so.
10
10
 
11
11
  The definitions below are therefore normative, not a suggestion:
12
12
 
13
+ Antigravity exception: the native input/cache/thinking relationship is not
14
+ established by the measured CLI output. Preserve its counters and reported
15
+ total rather than inventing a subset relationship or recomputing that total.
16
+
13
17
  input_tokens Uncached input only. Claude reports it this way
14
18
  natively; Codex does not, so its cache-inclusive count
15
19
  is reduced here rather than left for a reader to guess.
@@ -64,9 +68,9 @@ TOKEN_USAGE_KEYS = (
64
68
  "total_tokens",
65
69
  )
66
70
  USAGE_KEYS = (*TOKEN_USAGE_KEYS, "cache_hit_ratio")
67
- #: Derived values are never read from a provider.
71
+ #: Derived values are never read from a provider, except Antigravity's native total.
68
72
  DERIVED_USAGE_KEYS = ("cached_input_tokens", "total_tokens", "cache_hit_ratio")
69
- SUPPORTED_PROVIDERS = {"claude", "codex"}
73
+ SUPPORTED_PROVIDERS = {"claude", "codex", "ollama", "antigravity"}
70
74
 
71
75
 
72
76
  def empty_usage():
@@ -456,6 +460,8 @@ def coerce_usage(usage, provider=None):
456
460
  reasoning_tokens=usage.get("reasoning_tokens"),
457
461
  cache_creation_1h_tokens=usage.get("cache_creation_1h_tokens"),
458
462
  )
463
+ if provider == "antigravity":
464
+ record["total_tokens"] = _first_int(usage.get("total_tokens"))
459
465
  if record["cached_input_tokens"] is None:
460
466
  # A legacy record carrying only the fused figure: keep it visible rather
461
467
  # than dropping a number someone already recorded, but leave the split
@@ -635,10 +641,25 @@ def _usage_from_dict(value, provider=None):
635
641
  tokens. The fix is not arithmetic in this function: it is that the split
636
642
  parts go to `normalize_usage`, which owns every derived figure.
637
643
  """
644
+ if provider == "ollama":
645
+ prompt = _first_int(value.get("prompt_eval_count"))
646
+ output = _first_int(value.get("eval_count"))
647
+ return {**normalize_usage(input_tokens=prompt, output_tokens=output),
648
+ "prompt_eval_count": prompt, "eval_count": output}
638
649
  usage = value.get("usage") if isinstance(value.get("usage"), dict) else value
639
650
  if not isinstance(usage, dict):
640
651
  return empty_usage()
641
652
 
653
+ if provider == "antigravity":
654
+ # agy reports thinking separately. Without a documented cache subset
655
+ # relationship, retain its input count and its measured total verbatim.
656
+ record = normalize_usage(
657
+ input_tokens=usage.get("input_tokens"), output_tokens=usage.get("output_tokens"),
658
+ cache_read_tokens=usage.get("cache_read_tokens"), reasoning_tokens=usage.get("thinking_tokens"),
659
+ )
660
+ record["total_tokens"] = _first_int(usage.get("total_tokens"))
661
+ return record
662
+
642
663
  cache_creation = _first_int(usage.get("cache_creation_input_tokens"))
643
664
  if cache_creation is None:
644
665
  cache_creation = _first_int(usage.get("cache_write_input_tokens"))
@@ -720,3 +741,43 @@ def _int_value(*values):
720
741
 
721
742
  def _has_usage(usage):
722
743
  return isinstance(usage, dict) and any(usage.get(key) is not None for key in USAGE_KEYS)
744
+
745
+
746
+ def antigravity_outcome(info):
747
+ """Fail closed on missing completion, authentication errors and refused tools."""
748
+ if info.get("timed_out") or info.get("budget_exceeded"):
749
+ return info
750
+ try:
751
+ with open(info["stdout_path"], encoding="utf-8") as handle:
752
+ records = _parse_json_records(handle.read())
753
+ with open(info["stderr_path"], encoding="utf-8") as handle:
754
+ error_text = handle.read().lower()
755
+ except OSError:
756
+ records, error_text = [], ""
757
+ def objects(value):
758
+ if isinstance(value, dict):
759
+ yield value
760
+ for child in value.values():
761
+ yield from objects(child)
762
+ elif isinstance(value, list):
763
+ for child in value:
764
+ yield from objects(child)
765
+
766
+ statuses = [record.get("status") for record in records if isinstance(record, dict) and "status" in record]
767
+ errors = [record.get("error") for record in objects(records) if record.get("error")]
768
+ text = json.dumps(errors if statuses and statuses[-1] == "SUCCESS" else records).lower() + error_text
769
+ expired = any(word in text for word in ("invalid_grant", "unauthenticated", "session expired", "token expired",
770
+ "login required", "sign in", "not authenticated", "authentication required"))
771
+ refused = any(record.get(key) for record in objects(records)
772
+ for key in ("permission_denials", "refused_actions", "denied_actions"))
773
+ refused = refused or any(str(record.get("status", "")).upper() in ("REFUSED", "DENIED", "PERMISSION_DENIED")
774
+ for record in objects(records))
775
+ if expired:
776
+ code, message = "antigravity_session_expired", "Antigravity worker session has expired; sign in again on the worker."
777
+ elif refused:
778
+ code, message = "antigravity_action_refused", "Antigravity refused one or more requested actions."
779
+ elif not statuses or statuses[-1] != "SUCCESS":
780
+ code, message = "antigravity_status_failed", "Antigravity did not report JSON status SUCCESS."
781
+ else:
782
+ return info
783
+ return {**info, "returncode": info.get("returncode") or 1, "provider_error": code, "provider_message": message}
@@ -45,6 +45,7 @@ from .config import (
45
45
  PROVIDER_CODEX,
46
46
  REASONING_EFFORT_VALUES,
47
47
  get_cdx_home,
48
+ normalize_launch_max_parallel,
48
49
  normalize_permission,
49
50
  split_extra_args,
50
51
  )
@@ -236,6 +237,8 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
236
237
  if priority < MIN_LAUNCH_PRIORITY or priority > MAX_LAUNCH_PRIORITY:
237
238
  raise CdxError(f"Unsupported priority: {settings['priority']}")
238
239
  normalized["priority"] = priority
240
+ if "max_parallel" in settings and settings["max_parallel"] is not None:
241
+ normalized["max_parallel"] = normalize_launch_max_parallel(settings["max_parallel"])
239
242
  if "policy" in settings and settings["policy"] is not None:
240
243
  if settings["policy"] not in ACCOUNT_POLICIES:
241
244
  raise CdxError(f"Unsupported policy: {settings['policy']} (use|ask-first|never)")
@@ -324,7 +327,7 @@ def unset_launch_settings(store, name, keys):
324
327
  raise CdxError("At least one launch setting is required.")
325
328
  allowed = {
326
329
  "power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify", "agent_state", "notify_preview",
327
- "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy",
330
+ "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy", "max_parallel",
328
331
  }
329
332
  unknown = [key for key in keys if key not in allowed]
330
333
  if unknown:
@@ -644,6 +647,10 @@ def create_session(base_dir, env, store, name, provider=DEFAULT_PROVIDER):
644
647
  _seed_codex_auth_from_global(auth_home, env=env)
645
648
  if normalized_provider == PROVIDER_CLAUDE:
646
649
  _ensure_claude_attribution_disabled(auth_home)
650
+ if normalized_provider == "antigravity":
651
+ from .antigravity_status import install
652
+
653
+ install(auth_home)
647
654
  now = _local_now_iso()
648
655
  session = {
649
656
  "name": name,
@@ -359,6 +359,12 @@ def session_paths(session):
359
359
 
360
360
  def _status_row_from_session(store, s):
361
361
  status = s.get("lastStatus")
362
+ antigravity = None
363
+ if s["provider"] == "antigravity":
364
+ from .antigravity_status import read_status
365
+
366
+ antigravity = read_status(s.get("authHome") or s.get("sessionRoot") or "")
367
+ status = antigravity
362
368
  enabled = s.get("enabled", True) is not False
363
369
  row_status = status if enabled else None
364
370
  runtime = _session_runtime(store, s["name"])
@@ -374,6 +380,7 @@ def _status_row_from_session(store, s):
374
380
  # ignored `--priority` entirely.
375
381
  "priority": _row_priority(s),
376
382
  "policy": (s.get("launch") or {}).get("policy") or "use",
383
+ "max_parallel": (s.get("launch") or {}).get("max_parallel"),
377
384
  "reasoning_effort": _row_reasoning_effort(s),
378
385
  "active": bool(runtime) if enabled else False,
379
386
  "cwd": runtime.get("cwd") if runtime else None,
@@ -401,6 +408,13 @@ def _status_row_from_session(store, s):
401
408
  "updated_at": _to_local_iso(s.get("lastStatusAt")),
402
409
  "last_launched_at": _to_local_iso(s.get("lastLaunchedAt")),
403
410
  }
411
+ if antigravity is not None:
412
+ row.update({key: antigravity[key] for key in (
413
+ "quota", "quota_status", "quota_stale", "reading_age_seconds", "plan_tier", "updated_at",
414
+ "reset_5h_at_iso", "reset_week_at_iso",
415
+ )})
416
+ if enabled and antigravity["quota_stale"]:
417
+ row["status"] = "stale"
404
418
  row["runs_out_at_iso"] = _runs_out(row)
405
419
  return row
406
420
 
@@ -434,6 +448,9 @@ def _resolve_session_status(store, base_dir, env, fetch_codex_status, session,
434
448
  cache_only=False,
435
449
  status_timeout_seconds=None,):
436
450
  current_status = session.get("lastStatus")
451
+ if session["provider"] == "antigravity":
452
+ # The local statusLine file is the only Antigravity quota source.
453
+ return None
437
454
  if session.get("enabled", True) is False:
438
455
  return current_status
439
456
  if cache_only:
@@ -369,6 +369,7 @@ def _format_status_detail(row, use_color=False):
369
369
  f"{_style('Session:', '1', use_color)} {_format_session_name(row)}",
370
370
  f"{_style('Provider:', '1', use_color)} {row.get('provider') or 'n/a'}",
371
371
  f"{_style('Status:', '1', use_color)} {_style(row.get('status') or ('enabled' if row.get('enabled', True) else 'disabled'), '2' if row.get('enabled', True) is False else '32', use_color)}",
372
+ *([f"{_style('Plan:', '1', use_color)} {row['plan_tier']}"] if row.get("plan_tier") else []),
372
373
  f"{_style('Auth:', '1', use_color)} {_style(auth, auth_color, use_color)}",
373
374
  *([f"{_style('Auth type:', '1', use_color)} {_auth_type_label(row)[0]}"] if row.get("auth_type") else []),
374
375
  f"{_style('Available:', '1', use_color)} {_style_pct(row.get('available_pct'), use_color)}",
package/src/update_all.py CHANGED
@@ -241,9 +241,15 @@ def apply_update_all_plan(plan, service, env=None, runner=None, progress=None):
241
241
  advanced = (_newer(after, item["version"]) and
242
242
  (not item["latest_version"] or after == item["latest_version"] or _newer(after, item["latest_version"])))
243
243
  if not (advanced or (after and after == item["version"] and reports_current)):
244
- result["returncode"] = 1
245
- result["reason"] = "Update verification failed: installed version did not advance to the expected release and updater did not confirm it is current."
246
- result["stderr"] = (result["stderr"] + "\n" + result["reason"]).strip()
244
+ if item["name"] == PROVIDER_ANTIGRAVITY and not _newer(item["latest_version"], item["version"]):
245
+ result["warning"] = {
246
+ "code": "update_unverified",
247
+ "message": "agy update exited successfully, but currency could not be verified: no expected newer CLI version is known. Status remains unknown.",
248
+ }
249
+ else:
250
+ result["returncode"] = 1
251
+ result["reason"] = "Update verification failed: installed version did not advance to the expected release and updater did not confirm it is current."
252
+ result["stderr"] = (result["stderr"] + "\n" + result["reason"]).strip()
247
253
  results.append(result)
248
254
  if result["returncode"] == 0 and step.get("id"):
249
255
  completed.add(step["id"])
@@ -5,9 +5,12 @@ Provider authentication is the only credential deliberately passed to the provid
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
+ import ipaddress
8
9
  import json
9
10
  import os
10
11
  import shutil
12
+ import socket
13
+ import stat
11
14
  import tempfile
12
15
  from contextlib import contextmanager
13
16
  from pathlib import Path
@@ -28,7 +31,17 @@ NETWORK_RULES = """table inet mission_egress {
28
31
  PUBLIC_RESOLV_CONF = "nameserver 1.1.1.1\nnameserver 9.9.9.9\n"
29
32
 
30
33
  # Keep the trusted setup outside the agent's filesystem/PID/user namespaces.
31
- NETWORK_SETUP = """
34
+ CLAUDE_STDIN_EXEC = """
35
+ import json
36
+ import os
37
+ import sys
38
+ token = json.loads(sys.stdin.buffer.readline())
39
+ os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token
40
+ os.dup2(os.open(os.devnull, os.O_RDONLY), 0)
41
+ os.execv(sys.argv[1], sys.argv[1:])
42
+ """
43
+
44
+ NETWORK_SETUP = r"""
32
45
  import pathlib
33
46
  import subprocess
34
47
  import sys
@@ -68,6 +81,61 @@ def private_auth_home(auth):
68
81
  yield directory
69
82
 
70
83
 
84
+ ANTIGRAVITY_TOKEN_PATH = ".gemini/antigravity-cli/antigravity-oauth-token"
85
+ # Candidate endpoints extracted from agy 1.4.12; live acceptance is pending.
86
+ ANTIGRAVITY_DESTINATIONS = ("oauth2.googleapis.com", "cloudcode-pa.googleapis.com",
87
+ "daily-cloudcode-pa.googleapis.com")
88
+
89
+
90
+ def antigravity_token():
91
+ path = Path.home() / ANTIGRAVITY_TOKEN_PATH
92
+ try:
93
+ info = path.lstat()
94
+ except FileNotFoundError:
95
+ raise RuntimeError("Antigravity worker is not signed in; sign in once on the worker.") from None
96
+ if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
97
+ or stat.S_IMODE(info.st_mode) != 0o600 or not info.st_size
98
+ or any(parent.is_symlink() for parent in path.parents)):
99
+ raise RuntimeError("Antigravity worker session file is unsafe; restore an owner-only regular token file.")
100
+ return path
101
+
102
+
103
+ def antigravity_secrets():
104
+ text = antigravity_token().read_text().strip()
105
+
106
+ def values(value):
107
+ if isinstance(value, dict):
108
+ return [item for child in value.values() for item in values(child)]
109
+ if isinstance(value, list):
110
+ return [item for child in value for item in values(child)]
111
+ return [value] if isinstance(value, str) and value else []
112
+
113
+ try:
114
+ return [text, *values(json.loads(text))]
115
+ except ValueError:
116
+ return [text]
117
+
118
+
119
+ @contextmanager
120
+ def antigravity_attempt(parent="/dev/shm"):
121
+ import fcntl
122
+
123
+ location = Path(parent).resolve()
124
+ mounts = [row.split() for row in Path('/proc/mounts').read_text().splitlines()]
125
+ enclosing = [row for row in mounts if Path(row[1]) == location or Path(row[1]) in location.parents]
126
+ if not enclosing or max(enclosing, key=lambda row: len(row[1]))[2] != 'tmpfs':
127
+ raise RuntimeError('Antigravity attempts require memory-backed storage')
128
+ token = antigravity_token()
129
+ # Lock the file itself without modifying it; every cdx attempt uses this inode.
130
+ with token.open("rb") as lock:
131
+ try:
132
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
133
+ except BlockingIOError:
134
+ raise RuntimeError("Antigravity worker session is busy with another attempt.") from None
135
+ with tempfile.TemporaryDirectory(prefix="cdx-agy-", dir=parent) as directory:
136
+ yield directory
137
+
138
+
71
139
  def clean_env(source: dict) -> dict:
72
140
  allowed = ("PATH", "LANG", "LC_ALL", "TERM", "TZ")
73
141
  env = {key: source[key] for key in allowed if key in source}
@@ -85,7 +153,7 @@ def clean_env(source: dict) -> dict:
85
153
 
86
154
 
87
155
  def sandbox_command(command: list[str], tree: Path, *, network: bool, writable: bool = True,
88
- source: dict | None = None, auth_home: str = "", provider: str = "", observation_home: str = "") -> tuple[list[str], dict]:
156
+ source: dict | None = None, auth_home: str = "", provider: str = "", observation_home: str = "", ollama_relay: str = "", claude_token_stdin: bool = False) -> tuple[list[str], dict]:
89
157
  source = os.environ if source is None else source
90
158
  env = clean_env(source)
91
159
  binary = shutil.which(command[0], path=env["PATH"])
@@ -98,7 +166,7 @@ def sandbox_command(command: list[str], tree: Path, *, network: bool, writable:
98
166
  CACHE.mkdir(parents=True, exist_ok=True)
99
167
  (CACHE / "rtk").mkdir(exist_ok=True)
100
168
  # Provider transport uses the same restricted egress, even for read-only tools.
101
- online = bool(network or provider)
169
+ online = bool(network or (provider and provider != "ollama"))
102
170
  if online:
103
171
  pasta = shutil.which("pasta", path=source.get("PATH"))
104
172
  nft = shutil.which("nft", path=os.defpath + ":/usr/sbin:/sbin")
@@ -112,6 +180,10 @@ def sandbox_command(command: list[str], tree: Path, *, network: bool, writable:
112
180
  if online else ["--unshare-all"])
113
181
  args += ["--cap-drop", "ALL"]
114
182
  args += ["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", "/home/agent"]
183
+ if (provider == "claude" and claude_token_stdin) or provider == "antigravity":
184
+ if not observation_home:
185
+ raise RuntimeError("Provider requires a volatile attempt home")
186
+ args += ["--bind", observation_home, "/home/agent"]
115
187
  # An allowlist avoids overlooking another secret file or a credential symlink.
116
188
  paths = [Path(p) for p in ("/usr", "/bin", "/sbin", "/lib", "/lib64")]
117
189
  home = Path.home()
@@ -170,15 +242,63 @@ def sandbox_command(command: list[str], tree: Path, *, network: bool, writable:
170
242
  args += ["--ro-bind", str(Path(auth_home) / "auth.json"), "/home/agent/.codex/auth.json"]
171
243
  elif provider == "claude":
172
244
  token = source.get("CLAUDE_CODE_OAUTH_TOKEN")
173
- if not token:
245
+ if not token and not claude_token_stdin:
174
246
  raise RuntimeError("Isolated Claude runs require a cdx OAuth setup token")
175
- env.update(CLAUDE_CODE_OAUTH_TOKEN=token, CLAUDE_CODE_SUBPROCESS_ENV_SCRUB="1",
247
+ if claude_token_stdin:
248
+ env["XDG_CACHE_HOME"] = "/home/agent/.cache"
249
+ python = shutil.which("python3", path=os.defpath)
250
+ if not python:
251
+ raise RuntimeError("Remote Claude requires Python 3")
252
+ command = [python, "-I", "-c", CLAUDE_STDIN_EXEC, binary, *command[1:]]
253
+ binary = python
254
+ else:
255
+ env["CLAUDE_CODE_OAUTH_TOKEN"] = token
256
+ env.update(CLAUDE_CODE_SUBPROCESS_ENV_SCRUB="1",
176
257
  CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1")
258
+ elif provider == "antigravity":
259
+ token = antigravity_token()
260
+ args += ["--ro-bind", str(token), "/home/agent/" + ANTIGRAVITY_TOKEN_PATH]
261
+ env["AGY_CLI_DISABLE_AUTO_UPDATE"] = "true"
262
+ env["XDG_CACHE_HOME"] = "/home/agent/.cache"
263
+ elif provider == "ollama":
264
+ if not ollama_relay:
265
+ raise RuntimeError("Isolated Ollama requires a supervisor-owned relay")
266
+ args += ["--ro-bind", ollama_relay, "/run/cdx-ollama"]
177
267
  elif provider:
178
268
  raise RuntimeError("Unsupported isolated provider: " + provider)
179
269
  args += ["--chdir", str(tree), "--", binary, *command[1:]]
180
270
  if online:
181
- setup = "RULES = " + repr(NETWORK_RULES) + "\nRESOLVER = " + repr(PUBLIC_RESOLV_CONF) + "\n" + NETWORK_SETUP
271
+ rules = NETWORK_RULES
272
+ if provider == "claude" and claude_token_stdin and not network:
273
+ # Resolve on the trusted worker before pasta replaces the DNS namespace.
274
+ addresses = {entry[4][0] for entry in socket.getaddrinfo("api.anthropic.com", 443, type=socket.SOCK_STREAM)}
275
+ inference = []
276
+ for family, values in (("ip", sorted(address for address in addresses if ":" not in address)),
277
+ ("ip6", sorted(address for address in addresses if ":" in address))):
278
+ if values:
279
+ inference.append(f" {family} daddr {{ {', '.join(values)} }} tcp dport 443 accept")
280
+ if not inference:
281
+ raise RuntimeError("No Anthropic inference addresses")
282
+ rules = rules.replace("policy accept", "policy drop").replace(
283
+ " oif", " ct state established,related accept\n oif")
284
+ rules = rules.replace(" }\n}", " ip daddr { 1.1.1.1, 9.9.9.9 } udp dport 53 accept\n"
285
+ " ip daddr { 1.1.1.1, 9.9.9.9 } tcp dport 53 accept\n"
286
+ + "\n".join(inference) + "\n }\n}")
287
+ if provider == "antigravity":
288
+ addresses = {entry[4][0] for host in ANTIGRAVITY_DESTINATIONS
289
+ for entry in socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)}
290
+ if not addresses or any(not ipaddress.ip_address(address).is_global for address in addresses):
291
+ raise RuntimeError("Antigravity destinations must resolve to public addresses")
292
+ rules = NETWORK_RULES.replace("policy accept", "policy drop")
293
+ inference = []
294
+ for family in ("ip", "ip6"):
295
+ values = sorted(a for a in addresses if (":" in a) == (family == "ip6"))
296
+ if values:
297
+ inference.append(f" {family} daddr {{ {', '.join(values)} }} tcp dport 443 accept")
298
+ rules = rules.replace(" }\n}", " ip daddr { 1.1.1.1, 9.9.9.9 } udp dport 53 accept\n"
299
+ " ip daddr { 1.1.1.1, 9.9.9.9 } tcp dport 53 accept\n"
300
+ + "\n".join(inference) + "\n }\n}")
301
+ setup = "RULES = " + repr(rules) + "\nRESOLVER = " + repr(PUBLIC_RESOLV_CONF) + "\n" + NETWORK_SETUP
182
302
  # Disable both directions explicitly: automatic host-loopback splicing
183
303
  # bypasses output filtering unless -T/-U are also disabled.
184
304
  args = [pasta, "--config-net", "--no-map-gw", "-t", "none", "-u", "none",