cdx-manager 0.24.4 → 0.24.5

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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.24.4-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.24.5-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
10
10
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.24.4
1
+ 0.24.5
@@ -0,0 +1,16 @@
1
+ # CDX Manager 0.24.5
2
+
3
+ Release date: 2026-10-29
4
+
5
+ This patch adds isolated workspace runs with opt-in network access. (#61)
6
+
7
+ ## Workspace runs
8
+
9
+ - Add `cdx run --isolate-workspace` for Linux workspace confinement through bubblewrap, exposing the checkout, permitted toolchains, shared caches, and provider authentication while keeping the operator's home and host processes outside the boundary. (#61)
10
+ - Add `--network-access` to enable Codex's workspace-write network setting while retaining its workspace sandbox. (#61)
11
+ - Preserve both options across detached runs and failover, and advertise `workspace_isolation` and `workspace_network` capabilities in `cdx schema`. (#61)
12
+
13
+ ## Maintenance
14
+
15
+ - Record tray companion checksums for v0.24.4. (`7465b2e8244e90032d1ab8b7ad7af6a26ffe805c`)
16
+ - Record release archive checksums for v0.24.4. (`a0afa21ccc816769fb0aac5883912efd90bf1028`)
@@ -617,6 +617,17 @@
617
617
  "x86_64-pc-windows-msvc": "6da838217c01251b809bac1e188217dcfbb00c2102b901cfda43cf2fc0696aa2",
618
618
  "x86_64-unknown-linux-musl": "c6651dd909d486c2b0bb209e0d813ab044dc7b5ffb1a07078c8ec7a69126e028"
619
619
  }
620
+ },
621
+ "v0.24.4": {
622
+ "github_tarball_sha256": "279d60a1073f56ac1ccc361248c14668e9e018d81dd56e4eb177cb3f4bac4cf6",
623
+ "github_zip_sha256": "05dd935dc0e8e686a1dce96c0543b91757995a5f49ae2aed1155ddf97916e55d",
624
+ "tray_assets": {
625
+ "aarch64-apple-darwin": "9fdcddf98e9723f2af2007f0c0a183644b2200e599c33d354c097e2510a3901b",
626
+ "aarch64-unknown-linux-musl": "eef61a80c0d1107de9dfb980fb5914d85ec3b0a5d640ff559bf8e7faa4e857bd",
627
+ "x86_64-apple-darwin": "3296ee96b17442d8a2d8eaee6ceeba90dc498c74168973a6932793fa509afc66",
628
+ "x86_64-pc-windows-msvc": "1fd621d4ba7175c868c4e598850c5f5ccd914c91aaef3d79024376082e8fe81d",
629
+ "x86_64-unknown-linux-musl": "f4c3a6e7c2af75cd8214609971fe2cbef6f34e818796d70b661d9a9f7e9327af"
630
+ }
620
631
  }
621
632
  }
622
633
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.24.4",
3
+ "version": "0.24.5",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.24.4"
7
+ version = "0.24.5"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
package/src/cli_args.py CHANGED
@@ -47,7 +47,7 @@ CAN_RESUME_USAGE = "Usage: cdx can-resume <name> [--conversation <id>] [--json]"
47
47
  SESSIONS_USAGE = "Usage: cdx sessions [--all] --json"
48
48
  SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json"
49
49
  NEXT_USAGE = "Usage: cdx next [--json] [--refresh]"
50
- RUN_USAGE = "Usage: cdx run [session] (--cwd|--dir) PATH (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--detach] [--failover] [--refresh] --json"
50
+ RUN_USAGE = "Usage: cdx run [session] (--cwd|--dir) PATH (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--network-access] [--isolate-workspace] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--detach] [--failover] [--refresh] --json"
51
51
  RUN_JSON_REQUIRED = "cdx run: --json is required."
52
52
  RUN_TARGET_REQUIRED = "cdx run: specify a session name or --provider PROVIDER."
53
53
  RUN_SESSION_PROVIDER_CONFLICT = "cdx run: cannot specify both a session name and --provider."
@@ -475,6 +475,7 @@ def cdx_schema():
475
475
  # No `schema_version` here: the JSON envelope already carries it, and two
476
476
  # keys of that name in one payload would be one too many.
477
477
  return {
478
+ "capabilities": {"workspace_isolation": 1, "workspace_network": 1},
478
479
  "enums": {
479
480
  "permission": {
480
481
  "accepted": list(RUN_PERMISSION_VALUES),
@@ -665,6 +666,8 @@ def _parse_run_args(args):
665
666
  "--permission": {"key": "permission", "type": "str", "default": None, "transform": _normalize_run_permission},
666
667
  "--timeout-seconds": {"key": "timeout_seconds", "type": "str", "default": None, "transform": _parse_run_timeout_seconds},
667
668
  "--detach": {"key": "detach", "type": "bool", "default": False},
669
+ "--network-access": {"key": "network_access", "type": "bool", "default": False},
670
+ "--isolate-workspace": {"key": "isolate_workspace", "type": "bool", "default": False},
668
671
  "--failover": {"key": "failover", "type": "bool", "default": False},
669
672
  "--refresh": {"key": "refresh", "type": "bool", "default": False},
670
673
  "--label": {"key": "label", "type": "str", "default": None},
@@ -716,6 +719,8 @@ def _parse_run_args(args):
716
719
  "permission": parsed["permission"],
717
720
  "timeout_seconds": parsed["timeout_seconds"],
718
721
  "detach": parsed["detach"],
722
+ "network_access": parsed["network_access"],
723
+ "isolate_workspace": parsed["isolate_workspace"],
719
724
  "failover": parsed["failover"],
720
725
  "label": parsed["label"],
721
726
  "max_parallel": parsed["max_parallel"],
@@ -206,8 +206,9 @@ def _detached_child_argv(parsed, prompt_path, session_name, cwd):
206
206
  argv += ["--timeout-seconds", str(parsed["timeout_seconds"])]
207
207
  # The child is the supervisor: it watches the run like any blocking one, so
208
208
  # it applies the failover rule and records each move in the registry.
209
- if parsed.get("failover"):
210
- argv.append("--failover")
209
+ for key in ("network_access", "isolate_workspace", "failover"):
210
+ if parsed.get(key):
211
+ argv.append("--" + key.replace("_", "-"))
211
212
  argv.append("--json")
212
213
  return argv
213
214
 
@@ -572,7 +573,7 @@ def handle_run(rest, ctx):
572
573
  raise CdxError(f"Invalid cwd: {parsed['cwd']}", code="invalid_cwd")
573
574
  prompt = read_run_prompt(parsed, stdin=ctx.get("prompt_stdin"))
574
575
  _consume_detached_prompt_file(parsed, detached_run_id)
575
- launch_updates = {}
576
+ launch_updates = {key: parsed.get(key, False) for key in ("network_access", "isolate_workspace")}
576
577
  if parsed.get("model"):
577
578
  launch_updates["model"] = parsed["model"]
578
579
  if parsed.get("permission"):
@@ -643,7 +644,7 @@ def handle_run(rest, ctx):
643
644
  launch = None
644
645
  # A provider's own background agent has no cdx process watching it,
645
646
  # so nothing could fail it over; --failover keeps the in-house child.
646
- if not parsed.get("failover") and supports_native_background(
647
+ if not parsed.get("isolate_workspace") and not parsed.get("network_access") and not parsed.get("failover") and supports_native_background(
647
648
  run_session.get("provider"),
648
649
  env=ctx.get("env"),
649
650
  spawn_sync=ctx.get("spawn_text"),
@@ -1210,6 +1210,33 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
1210
1210
  env_override=env_override,
1211
1211
  initial_prompt=initial_prompt,
1212
1212
  )
1213
+ launch = session.get("launch") or {}
1214
+ if launch.get("network_access"):
1215
+ if session.get("provider") == PROVIDER_CODEX:
1216
+ # Keep the workspace sandbox; append overrides before the prompt separator.
1217
+ index = spec["args"].index("--") if "--" in spec["args"] else len(spec["args"])
1218
+ spec["args"][index:index] = ["-c", 'sandbox_workspace_write.network_access=true', '-c', 'sandbox_workspace_write.writable_roots=["/cache", "/home/agent"]']
1219
+ elif session.get("provider") != PROVIDER_CLAUDE:
1220
+ raise CdxError("Network override is unsupported for this provider")
1221
+ if launch.get("isolate_workspace"):
1222
+ from pathlib import Path
1223
+
1224
+ from .workspace_sandbox import sandbox_command
1225
+ if session.get("provider") == PROVIDER_CLAUDE:
1226
+ # The outer OS sandbox is the permission boundary for unattended Bash.
1227
+ index = spec["args"].index("--") if "--" in spec["args"] else len(spec["args"])
1228
+ spec["args"][index:index] = ["--permission-mode", "bypassPermissions"]
1229
+ try:
1230
+ argv, child_env = sandbox_command(
1231
+ [spec["command"], *spec["args"]], Path(cwd),
1232
+ network=launch.get("network_access", False),
1233
+ writable=launch.get("permission") != "review",
1234
+ source=spec["options"]["env"], auth_home=_get_auth_home(session),
1235
+ provider=session.get("provider"))
1236
+ except (OSError, RuntimeError) as error:
1237
+ raise CdxError(str(error)) from error
1238
+ spec.update(command=argv[0], args=argv[1:])
1239
+ spec["options"]["env"] = child_env
1213
1240
  paths = _headless_artifact_paths(session, run_id=run_id)
1214
1241
  start_time = datetime.now(timezone.utc)
1215
1242
  command = spec["command"]
@@ -133,7 +133,7 @@ def run_warnings(parsed, session, selection_row=None):
133
133
  })
134
134
  provider = session.get("provider") if session else parsed.get("provider")
135
135
  permission = run_effective_permission(parsed, session)
136
- if headless_permission_disables_network(provider, permission):
136
+ if not parsed.get("network_access") and headless_permission_disables_network(provider, permission):
137
137
  described = repr(permission) if permission else "its default (none set)"
138
138
  warnings.append({
139
139
  "code": "network_disabled_by_permission",
@@ -0,0 +1,94 @@
1
+ """Linux workspace confinement shared with the cdx-manager integration patch.
2
+
3
+ Expose tools, caches and one checkout, never the operator's home or host processes.
4
+ Provider authentication is the only credential deliberately passed to the provider.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import shutil
10
+ from pathlib import Path
11
+
12
+ CACHE = Path.home() / ".cdx" / "shared-cache"
13
+
14
+
15
+ def clean_env(source: dict) -> dict:
16
+ allowed = ("PATH", "LANG", "LC_ALL", "TERM", "TZ")
17
+ env = {key: source[key] for key in allowed if key in source}
18
+ env.update(HOME="/home/agent", XDG_CONFIG_HOME="/home/agent/.config",
19
+ XDG_CACHE_HOME="/cache", GIT_CONFIG_NOSYSTEM="1",
20
+ GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0", GIT_OPTIONAL_LOCKS="0",
21
+ GIT_CONFIG_COUNT="1", GIT_CONFIG_KEY_0="credential.helper", GIT_CONFIG_VALUE_0="",
22
+ GH_CONFIG_DIR="/home/agent/.config/gh", NPM_CONFIG_CACHE="/cache/npm",
23
+ PIP_CACHE_DIR="/cache/pip", UV_CACHE_DIR="/cache/uv",
24
+ PLAYWRIGHT_BROWSERS_PATH="/cache/playwright", CI="1")
25
+ return env
26
+
27
+
28
+ def sandbox_command(command: list[str], tree: Path, *, network: bool, writable: bool = True,
29
+ source: dict | None = None, auth_home: str = "", provider: str = "") -> tuple[list[str], dict]:
30
+ source = os.environ if source is None else source
31
+ binary = shutil.which(command[0], path=source.get("PATH"))
32
+ bwrap = shutil.which("bwrap", path=source.get("PATH"))
33
+ if not binary or not bwrap:
34
+ raise RuntimeError("Workspace isolation requires bubblewrap and " + command[0])
35
+ if tree.is_symlink() or not tree.is_dir():
36
+ raise RuntimeError("Workspace must be a directory")
37
+ tree = tree.resolve()
38
+ CACHE.mkdir(parents=True, exist_ok=True)
39
+ args = [bwrap, "--die-with-parent", "--new-session", "--unshare-all", "--cap-drop", "ALL"]
40
+ # The provider needs transport even when its own tool sandbox denies network.
41
+ if network or provider:
42
+ args += ["--share-net"]
43
+ args += ["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", "/home/agent"]
44
+ # An allowlist avoids overlooking another secret file or a credential symlink.
45
+ paths = [Path(p) for p in ("/usr", "/bin", "/sbin", "/lib", "/lib64")]
46
+ home = Path.home()
47
+ paths += [home / ".nvm", home / ".local/bin", home / ".local/share/claude",
48
+ home / ".local/share/uv"]
49
+ for path in paths:
50
+ if path.exists():
51
+ args += ["--ro-bind", str(path), str(path)]
52
+ for path in ("/etc/ssl", "/etc/resolv.conf", "/etc/hosts", "/etc/nsswitch.conf", "/etc/ld.so.cache",
53
+ "/etc/fonts", "/etc/passwd", "/etc/group", "/etc/alternatives"):
54
+ if Path(path).exists():
55
+ args += ["--ro-bind", path, path]
56
+ # Fail closed for a provider installed outside the explicitly permitted toolchains.
57
+ resolved = Path(binary).resolve()
58
+ if not any(resolved.is_relative_to(p.resolve()) for p in paths if p.exists()):
59
+ raise RuntimeError("Executable is outside the permitted toolchains: " + str(resolved))
60
+ args += ["--bind", str(CACHE), "/cache", "--bind" if writable else "--ro-bind", str(tree), str(tree)]
61
+ marker = tree / ".git"
62
+ if marker.is_file():
63
+ text = marker.read_text().strip()
64
+ if marker.is_symlink() or not text.startswith("gitdir: "):
65
+ raise RuntimeError("Invalid worktree Git marker")
66
+ gitdir = (tree / text.removeprefix("gitdir: ")).resolve()
67
+ common = (gitdir / (gitdir / "commondir").read_text().strip()).resolve()
68
+ # A writable marker must never turn into an arbitrary host-directory mount.
69
+ if (common.name != ".git" or gitdir.parent != common / "worktrees"
70
+ or common.is_relative_to(tree) or common.is_relative_to(CACHE.resolve())
71
+ or Path((gitdir / "gitdir").read_text().strip()).resolve() != marker):
72
+ raise RuntimeError("Invalid external worktree Git directory")
73
+ args += ["--ro-bind", str(marker), str(marker), "--ro-bind", str(common), str(common),
74
+ "--tmpfs", str(common / "worktrees"), "--ro-bind", str(gitdir), str(gitdir),
75
+ "--ro-bind", "/dev/null", str(common / "config"),
76
+ "--tmpfs", str(common / "hooks")]
77
+ if (gitdir / "config.worktree").exists():
78
+ args += ["--ro-bind", "/dev/null", str(gitdir / "config.worktree")]
79
+ elif marker.exists():
80
+ raise RuntimeError("Expected an isolated git worktree")
81
+ env = clean_env(source)
82
+ if provider == "codex":
83
+ env["CODEX_HOME"] = "/home/agent/.codex"
84
+ args += ["--ro-bind", str(Path(auth_home) / "auth.json"), "/home/agent/.codex/auth.json"]
85
+ elif provider == "claude":
86
+ token = source.get("CLAUDE_CODE_OAUTH_TOKEN")
87
+ if not token:
88
+ raise RuntimeError("Isolated Claude runs require a cdx OAuth setup token")
89
+ env.update(CLAUDE_CODE_OAUTH_TOKEN=token, CLAUDE_CODE_SUBPROCESS_ENV_SCRUB="1",
90
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1")
91
+ elif provider:
92
+ raise RuntimeError("Unsupported isolated provider: " + provider)
93
+ args += ["--chdir", str(tree), "--", binary, *command[1:]]
94
+ return args, env