claude-multiacc 2.0.29 → 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.
- package/bin/codex-accounts +33 -1
- package/docs/ACCOUNT_OPERATIONS.md +9 -0
- package/docs/CODEX.md +44 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/codex_config_edit.py +71 -0
- package/lib/codex_python.py +27 -0
- package/lib/codex_settings.py +169 -0
- package/lib/common.sh +8 -1
- package/package.json +5 -2
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/test_account_seeding.py +112 -0
- package/tests/test_codex_config_edit.py +82 -0
- package/tests/test_codex_settings.py +130 -0
- package/tests/test_packaged_commands.py +12 -0
package/bin/codex-accounts
CHANGED
|
@@ -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 "$@" ;;
|
|
@@ -131,6 +131,15 @@ manually via:
|
|
|
131
131
|
claude-accounts sync # and codex-accounts sync for the codex pool
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
+
Account creation and sync seed missing settings from the machine's default provider
|
|
135
|
+
directory and link its global instructions: `~/.claude/CLAUDE.md` for Claude,
|
|
136
|
+
`~/.codex/AGENTS.md` for Codex. Existing account settings (including models) and
|
|
137
|
+
instruction files or links are preserved. The addon does not generate instructions
|
|
138
|
+
or enable/disable agents. To share newly created global instructions with existing
|
|
139
|
+
accounts, run `claude-accounts sync --no-server` and/or `codex-accounts sync --no-server`.
|
|
140
|
+
This only repairs accounts in the manifest, leaving retired directories untouched.
|
|
141
|
+
Global instruction files remain machine-local; sync does not copy them between hosts.
|
|
142
|
+
|
|
134
143
|
Targets are the resolved sync target — env override, else the manifest's
|
|
135
144
|
`server`/`server_root`/`server_repo` (the Linux box), see *Pointing sync somewhere else*
|
|
136
145
|
below — plus an optional `peers` array for additional machines (e.g. a second Mac):
|
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.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
@@ -677,7 +677,7 @@ codex_share_state_index() { # $1 acct dir — seed time: whatever index names ex
|
|
|
677
677
|
}
|
|
678
678
|
|
|
679
679
|
# Seed an account config dir so headless runs never prompt.
|
|
680
|
-
# claude: stripped .claude.json, copied settings.json, shared projects/
|
|
680
|
+
# claude: stripped .claude.json, copied settings.json, shared CLAUDE.md + projects/
|
|
681
681
|
# (shared history => --continue/--resume work regardless of picked account).
|
|
682
682
|
# codex: copied config.toml (carries project trust + settings, no identity),
|
|
683
683
|
# shared sessions/ symlink (=> `codex resume` finds every session) with the
|
|
@@ -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
|
|
@@ -718,6 +722,9 @@ PYEOF
|
|
|
718
722
|
if [ ! -f "$d/settings.json" ] && [ -f "$HOME/.claude/settings.json" ]; then
|
|
719
723
|
cp "$HOME/.claude/settings.json" "$d/settings.json" 2>/dev/null || true
|
|
720
724
|
fi
|
|
725
|
+
if [ ! -e "$d/CLAUDE.md" ] && [ -f "$HOME/.claude/CLAUDE.md" ]; then
|
|
726
|
+
ln -s "$HOME/.claude/CLAUDE.md" "$d/CLAUDE.md" 2>/dev/null || true
|
|
727
|
+
fi
|
|
721
728
|
if [ ! -e "$d/projects" ] && [ -d "$HOME/.claude/projects" ]; then
|
|
722
729
|
ln -s "$HOME/.claude/projects" "$d/projects" 2>/dev/null || true
|
|
723
730
|
fi
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "2.0.
|
|
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": {
|
|
@@ -55,7 +55,10 @@
|
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"postinstall": "node scripts/postinstall.mjs",
|
|
58
|
-
"test": "
|
|
58
|
+
"test": "npm run test:core && npm run test:seeding && npm run test:commands",
|
|
59
|
+
"test:core": "bash tests/run-tests.sh && python3 tests/test_selector.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",
|
|
59
62
|
"test:commands": "python3 tests/test_packaged_commands.py"
|
|
60
63
|
},
|
|
61
64
|
"dependencies": {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Account seeding preserves settings and shares machine-local global instructions."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
13
|
+
PROVIDERS = (("claude", "CLAUDE.md", "settings.json"), ("codex", "AGENTS.md", "config.toml"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AccountSeedingTests(unittest.TestCase):
|
|
17
|
+
def setUp(self):
|
|
18
|
+
self.work = tempfile.TemporaryDirectory()
|
|
19
|
+
self.addCleanup(self.work.cleanup)
|
|
20
|
+
self.home = Path(self.work.name)
|
|
21
|
+
self.env = {**os.environ, "HOME": str(self.home), "CLAUDE_MULTIACC_PYTHON": "python3"}
|
|
22
|
+
for provider, _, _ in PROVIDERS:
|
|
23
|
+
(self.home / f".{provider}").mkdir()
|
|
24
|
+
self.env[f"{provider.upper()}_ACCOUNTS_ROOT"] = str(self.home / f".{provider}-accounts")
|
|
25
|
+
|
|
26
|
+
def seed(self, provider, directory=None):
|
|
27
|
+
account = directory or self.home / f".{provider}-accounts" / "acct-01"
|
|
28
|
+
subprocess.run(
|
|
29
|
+
["bash", "-c", '. "$1"; seed_account_dir "$2"', "seed", str(REPO / "lib/common.sh"), str(account)],
|
|
30
|
+
env={**self.env, "MULTIACC_PROVIDER": provider}, check=True, capture_output=True, text=True,
|
|
31
|
+
)
|
|
32
|
+
return account
|
|
33
|
+
|
|
34
|
+
def test_instructions_are_shared_and_updates_remain_visible(self):
|
|
35
|
+
for provider, name, _ in PROVIDERS:
|
|
36
|
+
with self.subTest(provider=provider):
|
|
37
|
+
source = self.home / f".{provider}" / name
|
|
38
|
+
source.write_text("Global preferences\n")
|
|
39
|
+
account = self.seed(provider)
|
|
40
|
+
self.assertTrue((account / name).is_symlink())
|
|
41
|
+
self.assertEqual((account / name).resolve(), source.resolve())
|
|
42
|
+
source.write_text("Updated preferences\n")
|
|
43
|
+
self.seed(provider)
|
|
44
|
+
self.assertEqual((account / name).read_text(), "Updated preferences\n")
|
|
45
|
+
|
|
46
|
+
def test_existing_files_and_links_are_preserved(self):
|
|
47
|
+
for provider, name, _ in PROVIDERS:
|
|
48
|
+
with self.subTest(provider=provider):
|
|
49
|
+
(self.home / f".{provider}" / name).write_text("Global preferences\n")
|
|
50
|
+
account = self.seed(provider)
|
|
51
|
+
target = account / name
|
|
52
|
+
target.unlink()
|
|
53
|
+
target.write_text("Account preferences\n")
|
|
54
|
+
self.seed(provider)
|
|
55
|
+
self.assertFalse(target.is_symlink())
|
|
56
|
+
self.assertEqual(target.read_text(), "Account preferences\n")
|
|
57
|
+
target.unlink()
|
|
58
|
+
local = self.home / f"{provider}-local.md"
|
|
59
|
+
target.symlink_to(local)
|
|
60
|
+
self.seed(provider)
|
|
61
|
+
self.assertEqual(target.readlink(), local) # Dangling custom link is also owned by the user.
|
|
62
|
+
local.write_text("Custom preferences\n")
|
|
63
|
+
self.seed(provider)
|
|
64
|
+
self.assertEqual(target.read_text(), "Custom preferences\n")
|
|
65
|
+
|
|
66
|
+
def test_missing_global_file_never_creates_a_dangling_or_self_link(self):
|
|
67
|
+
for provider, name, _ in PROVIDERS:
|
|
68
|
+
with self.subTest(provider=provider):
|
|
69
|
+
account = self.seed(provider)
|
|
70
|
+
self.assertFalse(os.path.lexists(account / name))
|
|
71
|
+
base = self.home / f".{provider}"
|
|
72
|
+
self.seed(provider, base)
|
|
73
|
+
self.assertFalse(os.path.lexists(base / name))
|
|
74
|
+
(base / name).write_text("Global preferences\n")
|
|
75
|
+
self.seed(provider, base)
|
|
76
|
+
self.assertFalse((base / name).is_symlink())
|
|
77
|
+
|
|
78
|
+
def test_existing_settings_and_model_are_not_overwritten(self):
|
|
79
|
+
for provider, _, name in PROVIDERS:
|
|
80
|
+
with self.subTest(provider=provider):
|
|
81
|
+
source = self.home / f".{provider}" / name
|
|
82
|
+
source.write_text('{"model":"global"}\n' if provider == "claude" else 'model = "global"\n')
|
|
83
|
+
account = self.seed(provider)
|
|
84
|
+
self.assertEqual((account / name).read_bytes(), source.read_bytes())
|
|
85
|
+
original = '{"model":"account"}\n' if provider == "claude" else 'model = "account"\n'
|
|
86
|
+
(account / name).write_text(original)
|
|
87
|
+
self.seed(provider)
|
|
88
|
+
self.assertEqual((account / name).read_text(), original)
|
|
89
|
+
|
|
90
|
+
def test_local_sync_repairs_only_manifest_accounts(self):
|
|
91
|
+
for provider, name, _ in PROVIDERS:
|
|
92
|
+
with self.subTest(provider=provider):
|
|
93
|
+
(self.home / f".{provider}" / name).write_text("Global preferences\n")
|
|
94
|
+
root = self.home / f".{provider}-accounts"
|
|
95
|
+
(root / "acct-01").mkdir(parents=True)
|
|
96
|
+
(root / "acct-99").mkdir()
|
|
97
|
+
(root / "accounts.json").write_text(json.dumps({
|
|
98
|
+
"version": 1, "server": "none",
|
|
99
|
+
"accounts": [{"id": "acct-01", "email": "fixture@example.test", "home": "mac"}],
|
|
100
|
+
}))
|
|
101
|
+
for _ in range(2):
|
|
102
|
+
result = subprocess.run(
|
|
103
|
+
[str(REPO / "bin" / f"{provider}-accounts"), "sync", "--no-server"],
|
|
104
|
+
env=self.env, check=True, capture_output=True, text=True,
|
|
105
|
+
)
|
|
106
|
+
self.assertIn("nothing pushed", result.stdout)
|
|
107
|
+
self.assertTrue((root / "acct-01" / name).is_symlink())
|
|
108
|
+
self.assertFalse(os.path.lexists(root / "acct-99" / name))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
unittest.main()
|
|
@@ -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"
|