claude-multiacc 2.0.30 → 2.0.31

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.
@@ -70,6 +70,12 @@ USAGE
70
70
  codex-accounts remove <acct-NN> [--yes] delete account (propagates to server)
71
71
  codex-accounts dedupe [--yes] remove any account registered twice
72
72
  (same email), keeping one per email
73
+ codex-accounts configure [--max-subagents N] [--include-global] [--check] [--json]
74
+ explicitly manage Codex subagent concurrency for this pool (Python 3.11+).
75
+ Applies to existing and future manifest accounts; other settings stay intact.
76
+ --include-global also updates ~/.codex/config.toml; --check only reads settings.
77
+ Without --max-subagents, reads back the current policy. Changes stay local
78
+ until sync distributes the opted-in pool policy to its configured targets.
73
79
  codex-accounts sync [--no-server] push manifest+seeds to the sync target AND
74
80
  any manifest 'peers' (extra machines). Mac
75
81
  only. auth.json is machine-local, NEVER
@@ -109,6 +115,23 @@ EOF
109
115
 
110
116
  require_manifest() { [ -f "$MANIFEST" ] || die "no manifest at $MANIFEST — run claude-multiacc install first"; }
111
117
 
118
+ cmd_configure() {
119
+ require_manifest
120
+ local apply=0 check=0 arg rc
121
+ for arg in "$@"; do
122
+ case "$arg" in --max-subagents|--max-subagents=*) apply=1 ;; --check) check=1 ;; esac
123
+ done
124
+ if [ "$apply" = "1" ] && [ "$check" = "0" ]; then
125
+ mutate_lock || die "could not acquire the account settings lock; try again"
126
+ trap mutate_unlock EXIT
127
+ fi
128
+ "$PYBIN" "$LIB_DIR/codex_settings.py" configure "$ACC_ROOT" "$@"
129
+ rc=$?
130
+ mutate_unlock
131
+ trap - EXIT
132
+ return "$rc"
133
+ }
134
+
112
135
  next_id() {
113
136
  local n=1 id
114
137
  while :; do
@@ -1960,6 +1983,14 @@ done" >>"$ACC_ROOT/sync.log" 2>&1 || fail "removal propagation on $server failed
1960
1983
  # Post-sync hook: the target seeds dirs and re-runs its quick verification matrix.
1961
1984
  # Non-fatal: a hook that is absent (not bootstrapped) or exits nonzero (accounts
1962
1985
  # awaiting a login THERE) must not fail the push that just succeeded.
1986
+ if [ -f "$ACC_ROOT/codex-settings-policy.json" ]; then
1987
+ local subagent_cap
1988
+ subagent_cap="$("$PYBIN" "$LIB_DIR/codex_settings.py" policy-value "$ACC_ROOT")" \
1989
+ || fail "cannot read opted-in Codex settings policy"
1990
+ ssh -o BatchMode=yes "$server" \
1991
+ "CODEX_ACCOUNTS_ROOT='$sroot' '$srepo/bin/codex-accounts' configure --max-subagents '$subagent_cap' --json" \
1992
+ >>"$ACC_ROOT/sync.log" 2>&1 || fail "Codex settings policy was not verified on $server"
1993
+ fi
1963
1994
  ssh -o BatchMode=yes "$server" "[ -x '$srepo/bin/codex-accounts' ] && '$srepo/bin/codex-accounts' post-sync" \
1964
1995
  >>"$ACC_ROOT/sync.log" 2>&1 || slog "post-sync hook on $server unavailable or unhappy (see its pool state)"
1965
1996
  }
@@ -2211,7 +2242,7 @@ PYEOF
2211
2242
  # distribution for as long as the arg loops rejected the flag. An UNKNOWN verb must still
2212
2243
  # FAIL, or the probe stops meaning what it says — so this list must hold exactly the verbs
2213
2244
  # the dispatcher below implements, and a test pins that both ways.
2214
- _KNOWN_VERBS="list status add import export-credential export-cred import-credential import-cred adopt dedupe remove login expired relogin re-login sync verify limits post-sync health self-update init-pool"
2245
+ _KNOWN_VERBS="list status add import export-credential export-cred import-credential import-cred adopt dedupe remove login expired relogin re-login sync configure verify limits post-sync health self-update init-pool"
2215
2246
  case " $_KNOWN_VERBS " in
2216
2247
  *" ${1:-help} "*)
2217
2248
  for _arg in "$@"; do
@@ -2233,6 +2264,7 @@ case "${1:-help}" in
2233
2264
  expired) shift; cmd_expired "$@" ;;
2234
2265
  relogin|re-login) shift; cmd_relogin "$@" ;;
2235
2266
  sync) shift; cmd_sync "$@" ;;
2267
+ configure) shift; cmd_configure "$@" ;;
2236
2268
  verify) shift; cmd_verify "$@" ;;
2237
2269
  limits) shift; cmd_limits "$@" ;;
2238
2270
  post-sync) shift; cmd_post_sync "$@" ;;
package/docs/CODEX.md CHANGED
@@ -81,3 +81,47 @@ codex-accounts verify # real codex exec call per account
81
81
  codex-accounts --help # all account commands and options
82
82
  claude-multiacc codex status # same status command via the npm wrapper
83
83
  ```
84
+
85
+ ## Opt-in subagent concurrency policy
86
+
87
+ The addon does not choose delegation policy by default. To manage the concurrent
88
+ subagent ceiling across one Codex pool, explicitly configure it (Python 3.11+):
89
+
90
+ ```bash
91
+ codex-accounts configure --max-subagents 8 --include-global --json
92
+ codex-accounts configure --max-subagents 8 --include-global --check --json
93
+ ```
94
+
95
+ An explicitly selected modern Python is preserved. If the shell resolves an older
96
+ system Python, this command tries already installed Python 3.11+ executables on
97
+ PATH and in standard Homebrew locations, with bounded probes and no downloads.
98
+
99
+ The first command sets `agents.max_concurrent_threads_per_session` in every manifest
100
+ account's `config.toml`, migrating its legacy `agents.max_threads` key when present.
101
+ `--include-global` also updates the machine's `~/.codex/config.toml`; without it,
102
+ the global file is untouched. Unrelated models, instructions, MCP servers, project
103
+ trust and custom agents remain intact. Retired directories outside the manifest
104
+ are never changed. This is a concurrency ceiling per parent session, not an
105
+ instruction to spawn that many agents or a pool-wide account limit.
106
+
107
+ An opted-in pool persists `codex-settings-policy.json`. Future account seeding and
108
+ local sync apply that policy to existing and new accounts. Normal remote sync
109
+ explicitly configures and verifies the same pool policy on each target using its
110
+ installed addon; update the target addon before syncing. A failed policy readback
111
+ fails sync instead of reporting success. Remote sync does not change the target's
112
+ global config. Without the policy file, existing settings keep their usual
113
+ copy-only-if-missing behavior and no new remote command runs.
114
+
115
+ `--check` never writes; it checks the requested value against the persisted policy
116
+ and selected configs, returning exit 1 on drift. Without `--max-subagents`, the
117
+ command only reads the current pool policy and configuration. JSON reports contain
118
+ the installed `plugin_version`, `max_subagents`, `policy_max_subagents`,
119
+ `accounts_total`, `accounts_configured`, `global_configured`, `mismatch_count` and
120
+ `verified`, without account identities, paths, credentials or arbitrary config.
121
+
122
+ Conflicting profile or `features.multi_agent_v2` object overrides are rejected.
123
+ Unusual inline-table layouts are also rejected before configuration changes;
124
+ use an ordinary `[agents]` table for the managed setting. All target configs are
125
+ validated before writes, each replacement is atomic, and repeating the command
126
+ repairs an interrupted write. Existing running sessions retain the settings they
127
+ loaded; the policy applies to subsequent sessions.
@@ -0,0 +1,71 @@
1
+ """Lossless, validated edits of Codex's per-session subagent limit (Python 3.11+)."""
2
+
3
+ import copy
4
+ import re
5
+ try:
6
+ import tomllib
7
+ except ImportError:
8
+ raise SystemExit("Codex settings configuration requires Python 3.11 or newer") from None
9
+
10
+ KEY = "max_concurrent_threads_per_session"
11
+ ALIASES = (KEY, "max_threads")
12
+
13
+
14
+ def limits(document):
15
+ """Report only delegation settings; never expose arbitrary configuration values."""
16
+ agents = document.get("agents", {})
17
+ feature = document.get("features", {}).get("multi_agent_v2")
18
+ override = feature.get(KEY) if isinstance(feature, dict) else None
19
+ configured = agents.get(KEY, agents.get("max_threads"))
20
+ return {"configured": configured, "feature_override": override,
21
+ "effective": override if override is not None else configured}
22
+
23
+
24
+ def validate_overrides(document, value):
25
+ """Reject conflicting feature/profile overrides instead of silently missing the cap."""
26
+ for name, item in [("default", document), *document.get("profiles", {}).items()]:
27
+ if not isinstance(item, dict):
28
+ continue
29
+ current = limits(item)
30
+ if current["feature_override"] not in (None, value):
31
+ raise ValueError(f"{name}: features.multi_agent_v2 overrides the requested subagent limit")
32
+ if name != "default" and current["configured"] not in (None, value):
33
+ raise ValueError(f"{name}: profile overrides the requested subagent limit")
34
+
35
+
36
+ def updated_config(source, value):
37
+ """Edit ordinary [agents] or dotted keys, then prove no unrelated TOML value changed."""
38
+ original = tomllib.loads(source)
39
+ validate_overrides(original, value)
40
+ expected = copy.deepcopy(original)
41
+ agents = expected.setdefault("agents", {})
42
+ agents.pop("max_threads", None)
43
+ agents[KEY] = value
44
+ lines, section, output, inserted = source.splitlines(keepends=True), "", [], False
45
+ header = re.compile(r'^\s*\[\s*(?:agents|"agents"|\x27agents\x27)\s*\]\s*(?:#.*)?$')
46
+ assignment = re.compile(r'^\s*(?:"|\x27)?(?:' + '|'.join(ALIASES) + r')(?:"|\x27)?\s*=')
47
+ dotted = re.compile(r'^\s*agents\s*\.\s*(?:' + '|'.join(ALIASES) + r')\s*=')
48
+ for line in lines:
49
+ if header.match(line.rstrip("\r\n")):
50
+ section = "agents"
51
+ output.extend([line.rstrip("\r\n") + "\n", f"{KEY} = {value}\n"])
52
+ inserted = True
53
+ continue
54
+ if line.lstrip().startswith("["):
55
+ section = "other"
56
+ if (section == "agents" and assignment.match(line)) or (not section and dotted.match(line)):
57
+ continue
58
+ output.append(line)
59
+ if not inserted:
60
+ if any(dotted.match(line) for line in lines):
61
+ output.insert(0, f"agents.{KEY} = {value}\n")
62
+ else:
63
+ output.extend(["\n[agents]\n", f"{KEY} = {value}\n"])
64
+ result = "".join(output)
65
+ try:
66
+ unchanged = tomllib.loads(result) == expected
67
+ except tomllib.TOMLDecodeError:
68
+ unchanged = False
69
+ if not unchanged:
70
+ raise ValueError("unsupported agents TOML layout; use a normal [agents] table before configuring")
71
+ return result
@@ -0,0 +1,27 @@
1
+ """Find an already installed TOML-capable Python when stock macOS Python is older."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+
9
+
10
+ def require_python311(script):
11
+ if sys.version_info >= (3, 11):
12
+ return
13
+ names = ["python3.14", "python3.13", "python3.12", "python3.11"]
14
+ candidates = [shutil.which(name) for name in names]
15
+ candidates.extend(str(Path(prefix) / name)
16
+ for prefix in ("/opt/homebrew/bin", "/usr/local/bin") for name in names)
17
+ for candidate in dict.fromkeys(candidates):
18
+ if not candidate or not os.access(candidate, os.X_OK):
19
+ continue
20
+ try:
21
+ probe = subprocess.run([candidate, "-c", "import sys;sys.exit(sys.version_info < (3,11))"],
22
+ capture_output=True, check=False, timeout=3)
23
+ if probe.returncode == 0:
24
+ os.execv(candidate, [candidate, str(script), *sys.argv[1:]])
25
+ except (OSError, subprocess.TimeoutExpired):
26
+ continue
27
+ raise SystemExit("Codex settings configuration requires an installed Python 3.11 or newer")
@@ -0,0 +1,169 @@
1
+ """Explicit pool-owned Codex settings, applied only after an operator opts in."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+
12
+ from codex_python import require_python311
13
+
14
+ require_python311(__file__)
15
+
16
+ from codex_config_edit import limits, tomllib, updated_config, validate_overrides
17
+
18
+ POLICY_FILE = "codex-settings-policy.json"
19
+
20
+
21
+ def positive_integer(value):
22
+ if isinstance(value, bool) or not re.fullmatch(r"[0-9]+", str(value)):
23
+ raise ValueError("max-subagents must be a positive integer")
24
+ parsed = int(value)
25
+ if parsed < 1:
26
+ raise ValueError("max-subagents must be a positive integer")
27
+ return parsed
28
+
29
+
30
+ def policy_value(root):
31
+ path = root / POLICY_FILE
32
+ if not path.exists():
33
+ return None
34
+ policy = json.loads(path.read_text())
35
+ if policy.get("version") != 1:
36
+ raise ValueError("unsupported Codex settings policy version")
37
+ return positive_integer(policy["max_subagents"])
38
+
39
+
40
+ def target_paths(root, include_global):
41
+ manifest = json.loads((root / "accounts.json").read_text())
42
+ accounts = manifest.get("accounts")
43
+ if not isinstance(accounts, list):
44
+ raise ValueError("manifest accounts must be a list")
45
+ result = []
46
+ for account in accounts:
47
+ account_id = account.get("id", "") if isinstance(account, dict) else ""
48
+ if not re.fullmatch(r"acct-\d{2}", account_id):
49
+ raise ValueError("manifest has an invalid account id")
50
+ result.append(root / account_id / "config.toml")
51
+ if include_global:
52
+ result.append(Path.home() / ".codex" / "config.toml")
53
+ return list(dict.fromkeys(result))
54
+
55
+
56
+ def atomic_write(path, text):
57
+ target = path.resolve()
58
+ target.parent.mkdir(parents=True, exist_ok=True)
59
+ mode = target.stat().st_mode & 0o777 if target.exists() else 0o600
60
+ descriptor, name = tempfile.mkstemp(prefix=".codex-settings-", dir=target.parent)
61
+ try:
62
+ with os.fdopen(descriptor, "w") as handle:
63
+ os.fchmod(handle.fileno(), mode)
64
+ handle.write(text)
65
+ os.replace(name, target)
66
+ finally:
67
+ if os.path.exists(name):
68
+ os.unlink(name)
69
+
70
+
71
+ def configure(root, paths, value):
72
+ pending, seen = [], set()
73
+ for path in paths:
74
+ if path.resolve() in seen:
75
+ continue
76
+ seen.add(path.resolve())
77
+ previous = path.read_text() if path.exists() else ""
78
+ source = previous
79
+ if not path.exists() and path != Path.home() / ".codex/config.toml":
80
+ global_config = Path.home() / ".codex/config.toml"
81
+ source = global_config.read_text() if global_config.exists() else ""
82
+ pending.append((path, previous, updated_config(source, value)))
83
+ for path, previous, updated in pending:
84
+ if (path.read_text() if path.exists() else "") != previous:
85
+ raise ValueError("configuration changed during validation; retry configure")
86
+ if updated != previous:
87
+ atomic_write(path, updated)
88
+ atomic_write(root / POLICY_FILE, json.dumps({"version": 1, "max_subagents": value}) + "\n")
89
+
90
+
91
+ def readback(paths, value):
92
+ result = {}
93
+ for path in paths:
94
+ document = tomllib.loads(path.read_text()) if path.exists() else {}
95
+ matches = limits(document)["effective"] == value if value is not None else True
96
+ try:
97
+ validate_overrides(document, value)
98
+ except ValueError:
99
+ matches = False
100
+ result[path] = matches
101
+ return result
102
+
103
+
104
+ def plugin_version():
105
+ repo = Path(__file__).resolve().parents[1]
106
+ if (repo / ".git").exists():
107
+ try:
108
+ result = subprocess.run(["git", "describe", "--tags", "--exact-match"], cwd=repo,
109
+ capture_output=True, text=True, check=False, timeout=5)
110
+ tag = result.stdout.strip()
111
+ if result.returncode == 0 and re.fullmatch(r"v\d+\.\d+\.\d+", tag):
112
+ return tag[1:]
113
+ except (OSError, subprocess.TimeoutExpired):
114
+ pass
115
+ return str(json.loads((repo / "package.json").read_text())["version"])
116
+
117
+
118
+ def report_for(root, paths, value, include_global):
119
+ matches = readback(paths, value)
120
+ global_path = Path.home() / ".codex/config.toml"
121
+ global_configured = matches.pop(global_path, None) if include_global else None
122
+ configured, total = sum(matches.values()), len(matches)
123
+ policy = policy_value(root)
124
+ mismatches = total - configured + int(global_configured is False)
125
+ return {"provider": "codex", "plugin_version": plugin_version(), "max_subagents": value,
126
+ "policy_max_subagents": policy, "accounts_total": total, "accounts_configured": configured,
127
+ "global_configured": global_configured, "mismatch_count": mismatches,
128
+ "verified": mismatches == 0 and policy == value}
129
+
130
+
131
+ def main():
132
+ parser = argparse.ArgumentParser(description=__doc__)
133
+ parser.add_argument("mode", choices=("configure", "seed", "policy-value"))
134
+ parser.add_argument("root", type=Path)
135
+ parser.add_argument("--max-subagents", type=positive_integer)
136
+ parser.add_argument("--include-global", action="store_true")
137
+ parser.add_argument("--json", action="store_true")
138
+ parser.add_argument("--check", action="store_true")
139
+ parser.add_argument("--account-dir", type=Path)
140
+ args = parser.parse_args()
141
+ value = args.max_subagents if args.max_subagents is not None else policy_value(args.root)
142
+ if args.mode == "policy-value":
143
+ if value is not None:
144
+ print(value)
145
+ return
146
+ if args.mode == "seed":
147
+ if value is not None:
148
+ path = args.account_dir / "config.toml"
149
+ source = path.read_text() if path.exists() else ""
150
+ updated = updated_config(source, value)
151
+ if source != updated:
152
+ atomic_write(path, updated)
153
+ return
154
+ paths = target_paths(args.root, args.include_global)
155
+ if args.max_subagents is not None and not args.check:
156
+ configure(args.root, paths, value)
157
+ report = report_for(args.root, paths, value, args.include_global)
158
+ print(json.dumps(report) if args.json else
159
+ f"Codex subagent policy: {value if value is not None else 'unmanaged'}; checked {len(paths)} configs")
160
+ if not report["verified"]:
161
+ sys.exit(1)
162
+
163
+
164
+ if __name__ == "__main__":
165
+ try:
166
+ main()
167
+ except (ValueError, OSError, KeyError, TypeError) as error:
168
+ print(f"codex-accounts configure: {error}", file=sys.stderr)
169
+ sys.exit(1)
package/lib/common.sh CHANGED
@@ -691,6 +691,10 @@ seed_account_dir() { # $1 = acct dir
691
691
  if [ ! -f "$d/config.toml" ] && [ -f "$HOME/.codex/config.toml" ]; then
692
692
  cp "$HOME/.codex/config.toml" "$d/config.toml" 2>/dev/null || true
693
693
  fi
694
+ if [ -f "$ACC_ROOT/codex-settings-policy.json" ]; then
695
+ "$PYBIN" "$LIB_DIR/codex_settings.py" seed "$ACC_ROOT" --account-dir "$d" \
696
+ || die "could not apply the opted-in Codex settings policy"
697
+ fi
694
698
  if [ ! -e "$d/sessions" ] && [ -d "$HOME/.codex/sessions" ]; then
695
699
  ln -s "$HOME/.codex/sessions" "$d/sessions" 2>/dev/null || true
696
700
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.30",
3
+ "version": "2.0.31",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,7 +57,8 @@
57
57
  "postinstall": "node scripts/postinstall.mjs",
58
58
  "test": "npm run test:core && npm run test:seeding && npm run test:commands",
59
59
  "test:core": "bash tests/run-tests.sh && python3 tests/test_selector.py",
60
- "test:seeding": "python3 tests/test_account_seeding.py",
60
+ "test:seeding": "python3 tests/test_account_seeding.py && npm run test:codex-settings",
61
+ "test:codex-settings": "python3 tests/test_codex_config_edit.py && python3 tests/test_codex_settings.py",
61
62
  "test:commands": "python3 tests/test_packaged_commands.py"
62
63
  },
63
64
  "dependencies": {
@@ -0,0 +1,82 @@
1
+ """Prove a subagent policy edit preserves unrelated parsed settings and source text."""
2
+
3
+ from pathlib import Path
4
+ import sys
5
+ import tomllib
6
+ import unittest
7
+ from unittest import mock
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib"))
10
+ from codex_config_edit import KEY, updated_config
11
+ from codex_python import require_python311
12
+
13
+
14
+ class CodexConfigEditTests(unittest.TestCase):
15
+ def test_models_comments_and_custom_agents_survive(self):
16
+ source = ('# personal preference\nmodel = "chosen"\n[agents]\nmax_threads = 4\n'
17
+ 'max_depth = 2\n[agents.reviewer]\ndescription = "review"\n'
18
+ '[features]\nmulti_agent_v2 = true\n[mcp_servers.example]\ncommand = "example"\n')
19
+ updated = updated_config(source, 8)
20
+ self.assertEqual(tomllib.loads(updated)["agents"][KEY], 8)
21
+ self.assertNotIn("max_threads", tomllib.loads(updated)["agents"])
22
+ for preserved in ('# personal preference', 'model = "chosen"', 'max_depth = 2',
23
+ 'description = "review"', 'command = "example"'):
24
+ self.assertIn(preserved, updated)
25
+ self.assertEqual(updated_config(updated, 8), updated)
26
+
27
+ def test_empty_regular_and_dotted_layouts_are_idempotent(self):
28
+ for source in ("", '[features]\nmulti_agent_v2 = true',
29
+ 'agents.max_threads = 4\nmodel = "chosen"\n',
30
+ '["agents"] # preference\nmax_concurrent_threads_per_session = 2\n'):
31
+ with self.subTest(source=source):
32
+ updated = updated_config(source, 8)
33
+ self.assertEqual(tomllib.loads(updated)["agents"][KEY], 8)
34
+ self.assertEqual(updated_config(updated, 8), updated)
35
+
36
+ def test_conflicting_feature_and_profile_overrides_are_rejected(self):
37
+ for source in ('[features.multi_agent_v2]\nmax_concurrent_threads_per_session = 20\n',
38
+ '[profiles.quick.agents]\nmax_threads = 20\n'):
39
+ with self.subTest(source=source), self.assertRaisesRegex(ValueError, "overrides"):
40
+ updated_config(source, 8)
41
+ source = '[features.multi_agent_v2]\nmax_concurrent_threads_per_session = 8\n'
42
+ self.assertEqual(tomllib.loads(updated_config(source, 8))["agents"][KEY], 8)
43
+
44
+ def test_unsupported_layouts_and_string_lookalikes_fail_safely(self):
45
+ for source in ('agents = { max_threads = 4 }\n',
46
+ 'instructions = """\n[agents]\nmax_threads = 99\n"""\n'):
47
+ with self.subTest(source=source), self.assertRaisesRegex(ValueError, "unsupported"):
48
+ updated_config(source, 8)
49
+
50
+
51
+ class CodexPythonTests(unittest.TestCase):
52
+ def test_explicit_modern_python_is_preserved(self):
53
+ with mock.patch("codex_python.sys.version_info", (3, 12)), \
54
+ mock.patch("codex_python.subprocess.run") as probe:
55
+ require_python311("settings.py")
56
+ probe.assert_not_called()
57
+
58
+ def test_old_python_reexecutes_available_modern_interpreter_with_same_args(self):
59
+ with mock.patch("codex_python.sys.version_info", (3, 9)), \
60
+ mock.patch("codex_python.shutil.which", return_value="/installed/python3.12"), \
61
+ mock.patch("codex_python.os.access", return_value=True), \
62
+ mock.patch("codex_python.sys.argv", ["settings.py", "configure", "pool", "--check"]), \
63
+ mock.patch("codex_python.subprocess.run", return_value=mock.Mock(returncode=0)) as probe, \
64
+ mock.patch("codex_python.os.execv", side_effect=SystemExit(42)) as replace:
65
+ with self.assertRaises(SystemExit):
66
+ require_python311("settings.py")
67
+ self.assertEqual(probe.call_args.kwargs["timeout"], 3)
68
+ replace.assert_called_once_with("/installed/python3.12",
69
+ ["/installed/python3.12", "settings.py", "configure", "pool", "--check"])
70
+
71
+ def test_missing_modern_python_fails_without_downloads(self):
72
+ with mock.patch("codex_python.sys.version_info", (3, 9)), \
73
+ mock.patch("codex_python.shutil.which", return_value=None), \
74
+ mock.patch("codex_python.os.access", return_value=False), \
75
+ mock.patch("codex_python.subprocess.run") as probe:
76
+ with self.assertRaisesRegex(SystemExit, "requires an installed Python 3.11"):
77
+ require_python311("settings.py")
78
+ probe.assert_not_called()
79
+
80
+
81
+ if __name__ == "__main__":
82
+ unittest.main()
@@ -0,0 +1,130 @@
1
+ """Exercise the public opt-in CLI with isolated pools and no provider/credential access."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import subprocess
7
+ import tempfile
8
+ import tomllib
9
+ import unittest
10
+
11
+ REPO = Path(__file__).resolve().parents[1]
12
+ KEY = "max_concurrent_threads_per_session"
13
+
14
+
15
+ class CodexSettingsTests(unittest.TestCase):
16
+ def setUp(self):
17
+ self.work = tempfile.TemporaryDirectory()
18
+ self.addCleanup(self.work.cleanup)
19
+ self.home = Path(self.work.name)
20
+ self.pool = self.home / "pool"
21
+ self.pool.mkdir()
22
+ (self.home / ".codex").mkdir()
23
+ self.global_config = self.home / ".codex/config.toml"
24
+ self.global_config.write_text('model = "global"\n[features]\nmulti_agent_v2 = true\n')
25
+ self.manifest = {"version": 1, "server": "none", "accounts": [
26
+ {"id": "acct-01", "email": "fixture@example.test", "home": "mac"}]}
27
+ (self.pool / "accounts.json").write_text(json.dumps(self.manifest))
28
+ self.account = self.pool / "acct-01/config.toml"
29
+ self.account.parent.mkdir()
30
+ self.account.write_text('model = "account"\n[agents]\nmax_threads = 3\n')
31
+ self.env = {**os.environ, "HOME": str(self.home), "CODEX_ACCOUNTS_ROOT": str(self.pool),
32
+ "CLAUDE_MULTIACC_PYTHON": "python3"}
33
+
34
+ def command(self, *args):
35
+ return subprocess.run([str(REPO / "bin/codex-accounts"), *args],
36
+ env=self.env, capture_output=True, text=True, check=False)
37
+
38
+ def test_configure_opt_in_preserves_other_settings_and_retired_dirs(self):
39
+ retired = self.pool / "acct-99/config.toml"
40
+ retired.parent.mkdir()
41
+ retired.write_text("retired = true\n")
42
+ original_global = self.global_config.read_text()
43
+ for _ in range(2):
44
+ result = self.command("configure", "--max-subagents", "8", "--json")
45
+ self.assertEqual(result.returncode, 0, result.stderr)
46
+ report = json.loads(result.stdout)
47
+ self.assertTrue(report["verified"])
48
+ self.assertEqual(report["accounts_total"], 1)
49
+ self.assertEqual(report["accounts_configured"], 1)
50
+ self.assertNotIn("acct-01", result.stdout)
51
+ self.assertNotIn("fixture@example", result.stdout)
52
+ self.assertEqual(tomllib.loads(self.account.read_text())["model"], "account")
53
+ self.assertEqual(self.global_config.read_text(), original_global)
54
+ self.assertEqual(retired.read_text(), "retired = true\n")
55
+
56
+ def test_global_opt_in_and_read_only_drift_detection(self):
57
+ result = self.command("configure", "--max-subagents", "8", "--include-global", "--json")
58
+ self.assertEqual(result.returncode, 0, result.stderr)
59
+ self.assertTrue(json.loads(result.stdout)["global_configured"])
60
+ self.account.write_text('[agents]\nmax_concurrent_threads_per_session = 20\n')
61
+ before = self.account.read_text()
62
+ result = self.command("configure", "--max-subagents", "8", "--include-global", "--check", "--json")
63
+ self.assertEqual(result.returncode, 1)
64
+ self.assertEqual(json.loads(result.stdout)["mismatch_count"], 1)
65
+ self.assertFalse(json.loads(result.stdout)["verified"])
66
+ self.assertEqual(self.account.read_text(), before)
67
+
68
+ def test_future_seeding_and_local_sync_apply_policy(self):
69
+ result = self.command("configure", "--max-subagents", "8")
70
+ self.assertEqual(result.returncode, 0, result.stderr)
71
+ self.manifest["accounts"].append({"id": "acct-02", "email": "new@example.test", "home": "mac"})
72
+ (self.pool / "accounts.json").write_text(json.dumps(self.manifest))
73
+ self.account.write_text('model = "changed"\n')
74
+ result = self.command("sync", "--no-server")
75
+ self.assertEqual(result.returncode, 0, result.stderr)
76
+ for path in (self.account, self.pool / "acct-02/config.toml"):
77
+ self.assertEqual(tomllib.loads(path.read_text())["agents"][KEY], 8)
78
+ self.assertEqual(tomllib.loads(self.account.read_text())["model"], "changed")
79
+
80
+ def test_invalid_config_is_rejected_before_any_configuration_is_changed(self):
81
+ before = self.account.read_text()
82
+ self.global_config.write_text('[features.multi_agent_v2]\nmax_concurrent_threads_per_session = 20\n')
83
+ result = self.command("configure", "--max-subagents", "8", "--include-global", "--json")
84
+ self.assertNotEqual(result.returncode, 0)
85
+ self.assertIn("overrides", result.stderr)
86
+ self.assertEqual(self.account.read_text(), before)
87
+ self.assertFalse((self.pool / "codex-settings-policy.json").exists())
88
+
89
+ def test_symlinked_global_config_remains_linked(self):
90
+ self.account.unlink()
91
+ self.account.symlink_to(self.global_config)
92
+ result = self.command("configure", "--max-subagents", "8", "--include-global", "--json")
93
+ self.assertEqual(result.returncode, 0, result.stderr)
94
+ self.assertTrue(self.account.is_symlink())
95
+ self.assertTrue(json.loads(result.stdout)["verified"])
96
+
97
+ @unittest.skipUnless(Path("/usr/bin/python3").is_file(), "System Python required")
98
+ def test_configuration_via_system_python_uses_installed_modern_runtime(self):
99
+ self.env["CLAUDE_MULTIACC_PYTHON"] = "/usr/bin/python3"
100
+ result = self.command("configure", "--max-subagents", "8", "--include-global", "--json")
101
+ self.assertEqual(result.returncode, 0, result.stderr)
102
+ self.assertTrue(json.loads(result.stdout)["verified"])
103
+
104
+ def test_sync_verifies_opted_in_policy_on_remote_target(self):
105
+ result = self.command("configure", "--max-subagents", "8")
106
+ self.assertEqual(result.returncode, 0, result.stderr)
107
+ tools = self.home / "tools"
108
+ tools.mkdir()
109
+ for name, body in {"uname": "echo Darwin", "rsync": "exit 0",
110
+ "ssh": 'printf "%s\\n" "$*" >> "$HOME/ssh-calls"\nexit 0'}.items():
111
+ path = tools / name
112
+ path.write_text("#!/bin/sh\n" + body + "\n")
113
+ path.chmod(0o755)
114
+ self.env.update({"PATH": str(tools) + os.pathsep + self.env["PATH"],
115
+ "CODEX_MULTIACC_SYNC_TARGET": "operator@runner",
116
+ "CODEX_MULTIACC_SYNC_ROOT": "/remote/pool",
117
+ "CODEX_MULTIACC_SYNC_REPO": "/remote/plugin"})
118
+ result = self.command("sync")
119
+ self.assertEqual(result.returncode, 0, result.stderr)
120
+ calls = (self.home / "ssh-calls").read_text()
121
+ self.assertIn("configure --max-subagents '8' --json", calls)
122
+ self.assertIn("CODEX_ACCOUNTS_ROOT='/remote/pool'", calls)
123
+ (tools / "ssh").write_text('#!/bin/sh\ncase "$*" in *configure*) exit 1 ;; esac\nexit 0\n')
124
+ result = self.command("sync")
125
+ self.assertNotEqual(result.returncode, 0)
126
+ self.assertIn("settings policy was not verified", result.stderr)
127
+
128
+
129
+ if __name__ == "__main__":
130
+ unittest.main()
@@ -75,6 +75,18 @@ class PackagedCommandsTests(unittest.TestCase):
75
75
  result = run(prefix + ["verify"] + flags, self.env, self.work)
76
76
  self.assertEqual(result.returncode, 0, result.stderr)
77
77
  self.assertIn(f"{provider}@example.test{expected}", result.stdout)
78
+ for prefix in (["codex-accounts"], ["claude-multiacc", "codex"]):
79
+ for check in ([], ["--check"]):
80
+ result = run(prefix + ["configure", "--max-subagents", "8", "--include-global", "--json"]
81
+ + check, self.env, self.work)
82
+ self.assertEqual(result.returncode, 0, result.stderr)
83
+ report = json.loads(result.stdout)
84
+ self.assertTrue(report["verified"])
85
+ self.assertTrue(report["global_configured"])
86
+ self.assertEqual(report["accounts_configured"], 1)
87
+ self.assertEqual(report["plugin_version"],
88
+ json.loads((self.package / "package.json").read_text())["version"])
89
+ self.assertNotIn("acct-01", result.stdout)
78
90
 
79
91
  def test_self_update_uses_the_running_install_prefix(self):
80
92
  fake_npm = self.prefix / "bin/npm"