cdx-manager 0.25.7 → 0.25.9
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/README.md +31 -6
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_25_8.md +15 -0
- package/changelogs/CHANGELOGS_0_25_9.md +22 -0
- package/changelogs/CHANGELOGS_UNRELEASED.md +33 -1
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +69 -0
- package/docs/operations.md +39 -0
- package/docs/sessions-and-notifications.md +1 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/antigravity_status.py +161 -0
- package/src/claude_remote_token.py +45 -0
- package/src/cli.py +2 -0
- package/src/cli_args.py +2 -0
- package/src/commands/auth.py +25 -0
- package/src/commands/maintenance.py +4 -1
- package/src/commands/runs.py +16 -3
- package/src/interactive_usage.py +6 -12
- package/src/ollama_remote.py +175 -0
- package/src/provider_runtime.py +15 -0
- package/src/remote_run.py +69 -25
- package/src/remote_worker.py +85 -33
- package/src/run_command.py +9 -0
- package/src/run_registry.py +5 -0
- package/src/run_usage.py +63 -2
- package/src/session_service.py +4 -0
- package/src/session_status.py +16 -0
- package/src/status_view.py +1 -0
- package/src/update_all.py +9 -3
- package/src/workspace_sandbox.py +147 -14
package/src/workspace_sandbox.py
CHANGED
|
@@ -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
|
-
|
|
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,24 +81,82 @@ 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}
|
|
142
|
+
env["PATH"] = source.get("PATH", os.defpath) + os.pathsep + str(Path.home() / ".local/bin")
|
|
74
143
|
env.update(HOME="/home/agent", XDG_CONFIG_HOME="/home/agent/.config",
|
|
75
144
|
XDG_CACHE_HOME="/cache", GIT_CONFIG_NOSYSTEM="1",
|
|
76
145
|
GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0", GIT_OPTIONAL_LOCKS="0",
|
|
77
146
|
GIT_CONFIG_COUNT="1", GIT_CONFIG_KEY_0="credential.helper", GIT_CONFIG_VALUE_0="",
|
|
78
147
|
GH_CONFIG_DIR="/home/agent/.config/gh", NPM_CONFIG_CACHE="/cache/npm",
|
|
79
148
|
PIP_CACHE_DIR="/cache/pip", UV_CACHE_DIR="/cache/uv",
|
|
149
|
+
RTK_DB_PATH="/cache/rtk/history.db",
|
|
80
150
|
# Cache users have different mount paths; their GC cannot identify each other's browsers.
|
|
81
151
|
PLAYWRIGHT_BROWSERS_PATH="/cache/playwright", PLAYWRIGHT_SKIP_BROWSER_GC="1", CI="1")
|
|
82
152
|
return env
|
|
83
153
|
|
|
84
154
|
|
|
85
155
|
def sandbox_command(command: list[str], tree: Path, *, network: bool, writable: bool = True,
|
|
86
|
-
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]:
|
|
87
157
|
source = os.environ if source is None else source
|
|
88
|
-
|
|
158
|
+
env = clean_env(source)
|
|
159
|
+
binary = shutil.which(command[0], path=env["PATH"])
|
|
89
160
|
bwrap = shutil.which("bwrap", path=source.get("PATH"))
|
|
90
161
|
if not binary or not bwrap:
|
|
91
162
|
raise RuntimeError("Workspace isolation requires bubblewrap and " + command[0])
|
|
@@ -93,8 +164,9 @@ def sandbox_command(command: list[str], tree: Path, *, network: bool, writable:
|
|
|
93
164
|
raise RuntimeError("Workspace must be a directory")
|
|
94
165
|
tree = tree.resolve()
|
|
95
166
|
CACHE.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
(CACHE / "rtk").mkdir(exist_ok=True)
|
|
96
168
|
# Provider transport uses the same restricted egress, even for read-only tools.
|
|
97
|
-
online = bool(network or provider)
|
|
169
|
+
online = bool(network or (provider and provider != "ollama"))
|
|
98
170
|
if online:
|
|
99
171
|
pasta = shutil.which("pasta", path=source.get("PATH"))
|
|
100
172
|
nft = shutil.which("nft", path=os.defpath + ":/usr/sbin:/sbin")
|
|
@@ -108,6 +180,10 @@ def sandbox_command(command: list[str], tree: Path, *, network: bool, writable:
|
|
|
108
180
|
if online else ["--unshare-all"])
|
|
109
181
|
args += ["--cap-drop", "ALL"]
|
|
110
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"]
|
|
111
187
|
# An allowlist avoids overlooking another secret file or a credential symlink.
|
|
112
188
|
paths = [Path(p) for p in ("/usr", "/bin", "/sbin", "/lib", "/lib64")]
|
|
113
189
|
home = Path.home()
|
|
@@ -137,15 +213,24 @@ def sandbox_command(command: list[str], tree: Path, *, network: bool, writable:
|
|
|
137
213
|
or common.is_relative_to(tree) or common.is_relative_to(CACHE.resolve())
|
|
138
214
|
or Path((gitdir / "gitdir").read_text().strip()).resolve() != marker):
|
|
139
215
|
raise RuntimeError("Invalid external worktree Git directory")
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
216
|
+
# Use sandbox-owned parents rather than traversing private host homes.
|
|
217
|
+
# Do not expose repository config (remote URLs/helpers/includes), hooks,
|
|
218
|
+
# other worktrees, or the source checkout. Git remains read-only.
|
|
219
|
+
private_git = Path("/home/agent/.git")
|
|
220
|
+
args += ["--ro-bind", str(marker), str(marker), "--dir", str(private_git)]
|
|
221
|
+
# Combine this worktree's HEAD/index with shared history, without any
|
|
222
|
+
# commondir/gitdir pointers back into the host filesystem.
|
|
223
|
+
for origin, names in (
|
|
224
|
+
(gitdir, ["HEAD", "index", *[p.name for p in gitdir.glob("sharedindex.*")]]),
|
|
225
|
+
(common, ["objects", "refs", "packed-refs", "shallow", *[p.name for p in common.glob("sharedindex.*")]]),
|
|
226
|
+
):
|
|
227
|
+
for name in names:
|
|
228
|
+
if (origin / name).exists():
|
|
229
|
+
args += ["--ro-bind", str(origin / name), str(private_git / name)]
|
|
230
|
+
env.update(GIT_DIR=str(private_git), GIT_COMMON_DIR=str(private_git),
|
|
231
|
+
GIT_WORK_TREE=str(tree))
|
|
146
232
|
elif marker.exists():
|
|
147
233
|
raise RuntimeError("Expected an isolated git worktree")
|
|
148
|
-
env = clean_env(source)
|
|
149
234
|
if provider == "codex":
|
|
150
235
|
auth_path = Path(auth_home) / "auth.json"
|
|
151
236
|
if json.loads(auth_path.read_text()).get('tokens', {}).get('refresh_token') != '':
|
|
@@ -157,15 +242,63 @@ def sandbox_command(command: list[str], tree: Path, *, network: bool, writable:
|
|
|
157
242
|
args += ["--ro-bind", str(Path(auth_home) / "auth.json"), "/home/agent/.codex/auth.json"]
|
|
158
243
|
elif provider == "claude":
|
|
159
244
|
token = source.get("CLAUDE_CODE_OAUTH_TOKEN")
|
|
160
|
-
if not token:
|
|
245
|
+
if not token and not claude_token_stdin:
|
|
161
246
|
raise RuntimeError("Isolated Claude runs require a cdx OAuth setup token")
|
|
162
|
-
|
|
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",
|
|
163
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"]
|
|
164
267
|
elif provider:
|
|
165
268
|
raise RuntimeError("Unsupported isolated provider: " + provider)
|
|
166
269
|
args += ["--chdir", str(tree), "--", binary, *command[1:]]
|
|
167
270
|
if online:
|
|
168
|
-
|
|
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
|
|
169
302
|
# Disable both directions explicitly: automatic host-loopback splicing
|
|
170
303
|
# bypasses output filtering unless -T/-U are also disabled.
|
|
171
304
|
args = [pasta, "--config-net", "--no-map-gw", "-t", "none", "-u", "none",
|