claude-multiacc 2.0.30 → 2.0.32

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,50 @@ 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 7 --include-global --json
92
+ codex-accounts configure --max-subagents 7 --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 `features.multi_agent_v2.max_concurrent_threads_per_session`
100
+ and the compatible `agents.max_concurrent_threads_per_session` in every manifest
101
+ account's `config.toml`, migrating its legacy `agents.max_threads` key when present.
102
+ An existing boolean `multi_agent_v2` becomes a table with the same `enabled` value;
103
+ an existing feature cap is updated along with the agents cap.
104
+ `--include-global` also updates the machine's `~/.codex/config.toml`; without it,
105
+ the global file is untouched. Unrelated models, instructions, MCP servers, project
106
+ trust and custom agents remain intact. Retired directories outside the manifest
107
+ are never changed. This is a concurrency ceiling per parent session, not an
108
+ instruction to spawn that many agents or a pool-wide account limit.
109
+
110
+ An opted-in pool persists `codex-settings-policy.json`. Future account seeding and
111
+ local sync apply that policy to existing and new accounts. Normal remote sync
112
+ explicitly configures and verifies the same pool policy on each target using its
113
+ installed addon; update the target addon before syncing. A failed policy readback
114
+ fails sync instead of reporting success. Remote sync does not change the target's
115
+ global config. Without the policy file, existing settings keep their usual
116
+ copy-only-if-missing behavior and no new remote command runs.
117
+
118
+ `--check` never writes; it checks both caps against the persisted policy
119
+ and requested value in selected configs, returning exit 1 on drift. Without
120
+ `--max-subagents`, the command only reads the current pool policy and configuration. JSON reports contain
121
+ the installed `plugin_version`, `max_subagents`, `policy_max_subagents`,
122
+ `accounts_total`, `accounts_configured`, `global_configured`, `mismatch_count` and
123
+ `verified`, without account identities, paths, credentials or arbitrary config.
124
+
125
+ Conflicting profile overrides are rejected.
126
+ Unusual inline-table layouts are also rejected before configuration changes;
127
+ use ordinary `[agents]` and `[features.multi_agent_v2]` tables for the managed settings.
128
+ All target configs are validated before writes, each replacement is atomic, and repeating the command
129
+ repairs an interrupted write. Existing running sessions retain the settings they
130
+ loaded; the policy applies to subsequent sessions.
@@ -0,0 +1,92 @@
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
+
12
+
13
+ def limits(document):
14
+ """Report only delegation settings; never expose arbitrary configuration values."""
15
+ agents = document.get("agents", {})
16
+ feature = document.get("features", {}).get("multi_agent_v2")
17
+ override = feature.get(KEY) if isinstance(feature, dict) else None
18
+ configured = agents.get(KEY, agents.get("max_threads"))
19
+ return {"configured": configured, "feature_override": override,
20
+ "effective": override if override is not None else configured}
21
+
22
+
23
+ def validate_overrides(document, value):
24
+ """Reject conflicting feature/profile overrides instead of silently missing the cap."""
25
+ for name, item in [("default", document), *document.get("profiles", {}).items()]:
26
+ if not isinstance(item, dict):
27
+ continue
28
+ current = limits(item)
29
+ if current["feature_override"] not in (None, value):
30
+ raise ValueError(f"{name}: features.multi_agent_v2 overrides the requested subagent limit")
31
+ if name != "default" and current["configured"] not in (None, value):
32
+ raise ValueError(f"{name}: profile overrides the requested subagent limit")
33
+
34
+
35
+ def dotted_pattern(parts):
36
+ return r'\s*\.\s*'.join(r'(?:' + re.escape(part) + r'|"' + re.escape(part)
37
+ + r'"|\x27' + re.escape(part) + r'\x27)' for part in parts)
38
+
39
+
40
+ def edit_setting(source, table, key, literal, aliases=()):
41
+ """Edit table/dotted assignments, or remove one when literal is None."""
42
+ headers = [re.compile(r'^\s*\[\s*' + dotted_pattern(table[:depth]) + r'\s*\]\s*(?:#.*)?$')
43
+ for depth in range(1, len(table) + 1)]
44
+ assignments = [re.compile(r'^\s*(?:' + '|'.join(dotted_pattern((*table[depth:], name))
45
+ for name in (key, *aliases)) + r')\s*=')
46
+ for depth in range(len(table) + 1)]
47
+ section, output, inserted = 0, [], False
48
+ for line in source.splitlines(keepends=True):
49
+ if line.lstrip().startswith("["):
50
+ section = next((depth for depth, header in enumerate(headers, 1)
51
+ if header.match(line.rstrip("\r\n"))), None)
52
+ if section == len(table) and literal is not None and not inserted:
53
+ output.extend([line.rstrip("\r\n") + "\n", f"{key} = {literal}\n"])
54
+ inserted = True
55
+ continue
56
+ if section is not None and assignments[section].match(line):
57
+ if literal is not None and not inserted:
58
+ output.append(f"{'.'.join((*table[section:], key))} = {literal}\n")
59
+ inserted = True
60
+ continue
61
+ output.append(line)
62
+ if literal is not None and not inserted:
63
+ output.extend([f"\n[{'.'.join(table)}]\n", f"{key} = {literal}\n"])
64
+ return "".join(output)
65
+
66
+
67
+ def updated_config(source, value):
68
+ """Set the v2 feature cap and agents fallback, preserving enablement and unrelated values."""
69
+ original = tomllib.loads(source)
70
+ validate_overrides({"profiles": original.get("profiles", {})}, value)
71
+ expected = copy.deepcopy(original)
72
+ agents = expected.setdefault("agents", {})
73
+ agents.pop("max_threads", None)
74
+ agents[KEY] = value
75
+ features = expected.setdefault("features", {})
76
+ feature = features.get("multi_agent_v2")
77
+ result = edit_setting(source, ("agents",), KEY, value, ("max_threads",))
78
+ if isinstance(feature, bool):
79
+ result = edit_setting(result, ("features",), "multi_agent_v2", None)
80
+ result = edit_setting(result, ("features", "multi_agent_v2"), "enabled", str(feature).lower())
81
+ features["multi_agent_v2"] = {"enabled": feature}
82
+ elif feature is None:
83
+ features["multi_agent_v2"] = {}
84
+ features["multi_agent_v2"][KEY] = value
85
+ result = edit_setting(result, ("features", "multi_agent_v2"), KEY, value)
86
+ try:
87
+ unchanged = tomllib.loads(result) == expected
88
+ except tomllib.TOMLDecodeError:
89
+ unchanged = False
90
+ if not unchanged:
91
+ raise ValueError("unsupported Codex TOML layout; use normal [agents] and [features.multi_agent_v2] tables")
92
+ 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,171 @@
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
+ current = limits(document)
96
+ matches = (all(current[key] == value for key in ("configured", "feature_override"))
97
+ if value is not None else True)
98
+ try:
99
+ validate_overrides(document, value)
100
+ except ValueError:
101
+ matches = False
102
+ result[path] = matches
103
+ return result
104
+
105
+
106
+ def plugin_version():
107
+ repo = Path(__file__).resolve().parents[1]
108
+ if (repo / ".git").exists():
109
+ try:
110
+ result = subprocess.run(["git", "describe", "--tags", "--exact-match"], cwd=repo,
111
+ capture_output=True, text=True, check=False, timeout=5)
112
+ tag = result.stdout.strip()
113
+ if result.returncode == 0 and re.fullmatch(r"v\d+\.\d+\.\d+", tag):
114
+ return tag[1:]
115
+ except (OSError, subprocess.TimeoutExpired):
116
+ pass
117
+ return str(json.loads((repo / "package.json").read_text())["version"])
118
+
119
+
120
+ def report_for(root, paths, value, include_global):
121
+ matches = readback(paths, value)
122
+ global_path = Path.home() / ".codex/config.toml"
123
+ global_configured = matches.pop(global_path, None) if include_global else None
124
+ configured, total = sum(matches.values()), len(matches)
125
+ policy = policy_value(root)
126
+ mismatches = total - configured + int(global_configured is False)
127
+ return {"provider": "codex", "plugin_version": plugin_version(), "max_subagents": value,
128
+ "policy_max_subagents": policy, "accounts_total": total, "accounts_configured": configured,
129
+ "global_configured": global_configured, "mismatch_count": mismatches,
130
+ "verified": mismatches == 0 and policy == value}
131
+
132
+
133
+ def main():
134
+ parser = argparse.ArgumentParser(description=__doc__)
135
+ parser.add_argument("mode", choices=("configure", "seed", "policy-value"))
136
+ parser.add_argument("root", type=Path)
137
+ parser.add_argument("--max-subagents", type=positive_integer)
138
+ parser.add_argument("--include-global", action="store_true")
139
+ parser.add_argument("--json", action="store_true")
140
+ parser.add_argument("--check", action="store_true")
141
+ parser.add_argument("--account-dir", type=Path)
142
+ args = parser.parse_args()
143
+ value = args.max_subagents if args.max_subagents is not None else policy_value(args.root)
144
+ if args.mode == "policy-value":
145
+ if value is not None:
146
+ print(value)
147
+ return
148
+ if args.mode == "seed":
149
+ if value is not None:
150
+ path = args.account_dir / "config.toml"
151
+ source = path.read_text() if path.exists() else ""
152
+ updated = updated_config(source, value)
153
+ if source != updated:
154
+ atomic_write(path, updated)
155
+ return
156
+ paths = target_paths(args.root, args.include_global)
157
+ if args.max_subagents is not None and not args.check:
158
+ configure(args.root, paths, value)
159
+ report = report_for(args.root, paths, value, args.include_global)
160
+ print(json.dumps(report) if args.json else
161
+ f"Codex subagent policy: {value if value is not None else 'unmanaged'}; checked {len(paths)} configs")
162
+ if not report["verified"]:
163
+ sys.exit(1)
164
+
165
+
166
+ if __name__ == "__main__":
167
+ try:
168
+ main()
169
+ except (ValueError, OSError, KeyError, TypeError) as error:
170
+ print(f"codex-accounts configure: {error}", file=sys.stderr)
171
+ 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.32",
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,98 @@
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_profile_overrides_are_rejected(self):
37
+ for source in ('[profiles.quick.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_existing_eight_thread_policies_and_feature_overrides_change_to_seven(self):
45
+ for feature in ('[features]\nmulti_agent_v2 = true\n',
46
+ '[features]\nmulti_agent_v2 = false\n',
47
+ '[features.multi_agent_v2]\nenabled = true\nmax_concurrent_threads_per_session = 20\n',
48
+ 'features.multi_agent_v2.max_concurrent_threads_per_session = 8\n',
49
+ '[features]\nmulti_agent_v2.max_concurrent_threads_per_session = 8\n'):
50
+ source = feature + '[agents]\nmax_concurrent_threads_per_session = 8\n'
51
+ with self.subTest(feature=feature):
52
+ updated = updated_config(source, 7)
53
+ document = tomllib.loads(updated)
54
+ self.assertEqual(document["agents"][KEY], 7)
55
+ self.assertEqual(document["features"]["multi_agent_v2"][KEY], 7)
56
+ if 'false' in feature:
57
+ self.assertFalse(document["features"]["multi_agent_v2"]["enabled"])
58
+ self.assertEqual(updated_config(updated, 7), updated)
59
+
60
+ def test_unsupported_layouts_and_string_lookalikes_fail_safely(self):
61
+ for source in ('agents = { max_threads = 4 }\n',
62
+ 'instructions = """\n[agents]\nmax_threads = 99\n"""\n'):
63
+ with self.subTest(source=source), self.assertRaisesRegex(ValueError, "unsupported"):
64
+ updated_config(source, 8)
65
+
66
+
67
+ class CodexPythonTests(unittest.TestCase):
68
+ def test_explicit_modern_python_is_preserved(self):
69
+ with mock.patch("codex_python.sys.version_info", (3, 12)), \
70
+ mock.patch("codex_python.subprocess.run") as probe:
71
+ require_python311("settings.py")
72
+ probe.assert_not_called()
73
+
74
+ def test_old_python_reexecutes_available_modern_interpreter_with_same_args(self):
75
+ with mock.patch("codex_python.sys.version_info", (3, 9)), \
76
+ mock.patch("codex_python.shutil.which", return_value="/installed/python3.12"), \
77
+ mock.patch("codex_python.os.access", return_value=True), \
78
+ mock.patch("codex_python.sys.argv", ["settings.py", "configure", "pool", "--check"]), \
79
+ mock.patch("codex_python.subprocess.run", return_value=mock.Mock(returncode=0)) as probe, \
80
+ mock.patch("codex_python.os.execv", side_effect=SystemExit(42)) as replace:
81
+ with self.assertRaises(SystemExit):
82
+ require_python311("settings.py")
83
+ self.assertEqual(probe.call_args.kwargs["timeout"], 3)
84
+ replace.assert_called_once_with("/installed/python3.12",
85
+ ["/installed/python3.12", "settings.py", "configure", "pool", "--check"])
86
+
87
+ def test_missing_modern_python_fails_without_downloads(self):
88
+ with mock.patch("codex_python.sys.version_info", (3, 9)), \
89
+ mock.patch("codex_python.shutil.which", return_value=None), \
90
+ mock.patch("codex_python.os.access", return_value=False), \
91
+ mock.patch("codex_python.subprocess.run") as probe:
92
+ with self.assertRaisesRegex(SystemExit, "requires an installed Python 3.11"):
93
+ require_python311("settings.py")
94
+ probe.assert_not_called()
95
+
96
+
97
+ if __name__ == "__main__":
98
+ unittest.main()
@@ -0,0 +1,148 @@
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('[profiles.quick.features.multi_agent_v2]\n'
83
+ 'max_concurrent_threads_per_session = 20\n')
84
+ result = self.command("configure", "--max-subagents", "8", "--include-global", "--json")
85
+ self.assertNotEqual(result.returncode, 0)
86
+ self.assertIn("overrides", result.stderr)
87
+ self.assertEqual(self.account.read_text(), before)
88
+ self.assertFalse((self.pool / "codex-settings-policy.json").exists())
89
+
90
+ def test_seven_thread_policy_requires_v2_setting_and_repairs_legacy_only_configs(self):
91
+ result = self.command("configure", "--max-subagents", "8", "--include-global", "--json")
92
+ self.assertEqual(result.returncode, 0, result.stderr)
93
+ result = self.command("configure", "--max-subagents", "7", "--include-global", "--json")
94
+ self.assertEqual(result.returncode, 0, result.stderr)
95
+ for path in (self.account, self.global_config):
96
+ config = tomllib.loads(path.read_text())
97
+ self.assertEqual(config["features"]["multi_agent_v2"][KEY], 7)
98
+ self.assertEqual(config["agents"][KEY], 7)
99
+ self.account.write_text('[agents]\nmax_concurrent_threads_per_session = 7\n')
100
+ result = self.command("configure", "--include-global", "--check", "--json")
101
+ self.assertEqual(result.returncode, 1, result.stderr)
102
+ self.assertEqual(json.loads(result.stdout)["mismatch_count"], 1)
103
+ result = self.command("configure", "--max-subagents", "7", "--include-global", "--json")
104
+ self.assertEqual(result.returncode, 0, result.stderr)
105
+ self.assertTrue(json.loads(result.stdout)["verified"])
106
+
107
+ def test_symlinked_global_config_remains_linked(self):
108
+ self.account.unlink()
109
+ self.account.symlink_to(self.global_config)
110
+ result = self.command("configure", "--max-subagents", "8", "--include-global", "--json")
111
+ self.assertEqual(result.returncode, 0, result.stderr)
112
+ self.assertTrue(self.account.is_symlink())
113
+ self.assertTrue(json.loads(result.stdout)["verified"])
114
+
115
+ @unittest.skipUnless(Path("/usr/bin/python3").is_file(), "System Python required")
116
+ def test_configuration_via_system_python_uses_installed_modern_runtime(self):
117
+ self.env["CLAUDE_MULTIACC_PYTHON"] = "/usr/bin/python3"
118
+ result = self.command("configure", "--max-subagents", "8", "--include-global", "--json")
119
+ self.assertEqual(result.returncode, 0, result.stderr)
120
+ self.assertTrue(json.loads(result.stdout)["verified"])
121
+
122
+ def test_sync_verifies_opted_in_policy_on_remote_target(self):
123
+ result = self.command("configure", "--max-subagents", "8")
124
+ self.assertEqual(result.returncode, 0, result.stderr)
125
+ tools = self.home / "tools"
126
+ tools.mkdir()
127
+ for name, body in {"uname": "echo Darwin", "rsync": "exit 0",
128
+ "ssh": 'printf "%s\\n" "$*" >> "$HOME/ssh-calls"\nexit 0'}.items():
129
+ path = tools / name
130
+ path.write_text("#!/bin/sh\n" + body + "\n")
131
+ path.chmod(0o755)
132
+ self.env.update({"PATH": str(tools) + os.pathsep + self.env["PATH"],
133
+ "CODEX_MULTIACC_SYNC_TARGET": "operator@runner",
134
+ "CODEX_MULTIACC_SYNC_ROOT": "/remote/pool",
135
+ "CODEX_MULTIACC_SYNC_REPO": "/remote/plugin"})
136
+ result = self.command("sync")
137
+ self.assertEqual(result.returncode, 0, result.stderr)
138
+ calls = (self.home / "ssh-calls").read_text()
139
+ self.assertIn("configure --max-subagents '8' --json", calls)
140
+ self.assertIn("CODEX_ACCOUNTS_ROOT='/remote/pool'", calls)
141
+ (tools / "ssh").write_text('#!/bin/sh\ncase "$*" in *configure*) exit 1 ;; esac\nexit 0\n')
142
+ result = self.command("sync")
143
+ self.assertNotEqual(result.returncode, 0)
144
+ self.assertIn("settings policy was not verified", result.stderr)
145
+
146
+
147
+ if __name__ == "__main__":
148
+ 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"