claude-multiacc 2.0.31 → 2.0.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/bin/claude-accounts +24 -2
- package/docs/CODEX.md +12 -9
- package/docs/INSTALLATION.md +13 -4
- package/docs/VERIFICATION.md +13 -4
- 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/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/codex_config_edit.py +46 -25
- package/lib/codex_settings.py +3 -1
- package/lib/install_actions.sh +67 -13
- package/lib/shim_path.py +306 -0
- package/package.json +2 -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/run-tests.sh +114 -0
- package/tests/test_codex_config_edit.py +18 -2
- package/tests/test_codex_settings.py +19 -1
- package/tests/test_packaged_commands.py +39 -3
- package/tests/test_shim_path.py +285 -0
package/lib/shim_path.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Does a FRESH shell resolve `claude` / `codex` to this addon's shims?
|
|
2
|
+
|
|
3
|
+
The shims shadow the real binaries by PATH ORDER, and the rc-file block that puts them
|
|
4
|
+
first is only as good as the shell startup that runs it. Three ways it silently is not:
|
|
5
|
+
a ~/.profile with no block (every `bash -l` / `sh -l` on my-mini resolved the real
|
|
6
|
+
binary, 2026-09-17), an rc file interrupted half-way (a Ctrl-C at a slow `conda` hook
|
|
7
|
+
left ~/.local/bin in front, and the next `claude` at that prompt opened on the
|
|
8
|
+
un-pooled ~/.claude login: "Not logged in · Please run /login"), or a later
|
|
9
|
+
`export PATH=` that re-prepends ~/.local/bin. Nothing inside the shim can notice a
|
|
10
|
+
launch that never reached it, so this is checked from the OUTSIDE: start each login
|
|
11
|
+
shell the way a terminal or a `bash -lc` launcher would — clean environment, the user's
|
|
12
|
+
own rc files — and ask what the command names resolve to.
|
|
13
|
+
|
|
14
|
+
Interactive modes (`-li`) are fed the probe on stdin, so the shell runs its prompt loop
|
|
15
|
+
and the rc block's prompt hook (lib/install_actions.sh path_guard_body) gets its turn,
|
|
16
|
+
exactly as it does for a person at a prompt. The `-lc` modes are what a launcher that
|
|
17
|
+
runs `bash -lc claude` gets: rc order alone, no prompt hook.
|
|
18
|
+
|
|
19
|
+
What is measured is the PATH search — `whence -p` / `type -P`, aliases and functions set
|
|
20
|
+
aside — because the incident was a PATH-order failure and an `alias claude='claude
|
|
21
|
+
--flags'` still resolves through PATH. A function or alias that names the real binary
|
|
22
|
+
outright is not detected here; it is also not something an installer can fix.
|
|
23
|
+
|
|
24
|
+
Every answer line carries a sentinel, the first answer per name wins, and a name the
|
|
25
|
+
shell never answered for is an ERROR on that row, not a bypass: an rc file that reads a
|
|
26
|
+
line of stdin (an update prompt, a `read`) can eat part of the fed script. Nothing
|
|
27
|
+
touches the operator's shell history — the script disables it first thing.
|
|
28
|
+
|
|
29
|
+
probe(repo_dir) -> [ {shell, mode, argv, results: {name: {resolved, ok}}, error} ... ]
|
|
30
|
+
ok is True for the shim, False for anything else (the real binary, or nothing on
|
|
31
|
+
PATH at all), None when the shell could not answer (timeout / no output).
|
|
32
|
+
bypassed(rows) -> the (mode, name, resolved) triples with ok False.
|
|
33
|
+
|
|
34
|
+
Run directly: python3 lib/shim_path.py <repo-dir> [--json] [--timeout=SECONDS]
|
|
35
|
+
Prints the report; exit 1 when any mode bypasses the shim, 2 on usage, else 0.
|
|
36
|
+
CLAUDE_MULTIACC_PATH_PROBE=0 makes probe() return [] (the sandboxed suites run with the
|
|
37
|
+
operator's real HOME and must not depend on its rc files).
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import getpass
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import shutil
|
|
45
|
+
import signal
|
|
46
|
+
import subprocess
|
|
47
|
+
import sys
|
|
48
|
+
import tempfile
|
|
49
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
50
|
+
|
|
51
|
+
NAMES = ('claude', 'codex')
|
|
52
|
+
# The marker every shim carries in its header; lib/common.sh is_shim_file greps the
|
|
53
|
+
# same word, so a /usr/local/bin/claude -> shim symlink (Linux) counts as the shim too.
|
|
54
|
+
SHIM_MARK = 'multiacc-shim'
|
|
55
|
+
DEFAULT_TIMEOUT = 25
|
|
56
|
+
# (shell, flags, interactive) — a terminal is a login AND interactive shell; a
|
|
57
|
+
# `bash -lc` launcher is login only; sh has no interactive rc worth probing.
|
|
58
|
+
MODES = (
|
|
59
|
+
('zsh', '-li', True), ('zsh', '-lc', False),
|
|
60
|
+
('bash', '-li', True), ('bash', '-lc', False),
|
|
61
|
+
('sh', '-lc', False),
|
|
62
|
+
)
|
|
63
|
+
# What resolves a NAME to the executable a shell would run, per shell, bypassing
|
|
64
|
+
# aliases and functions (an interactive zsh answers `command -v claude` with the
|
|
65
|
+
# user's alias text, which says nothing about the file). POSIX sh has only `command -v`,
|
|
66
|
+
# which reports an alias or a function by name, so the sh script drops those first.
|
|
67
|
+
_RESOLVERS = {'zsh': 'whence -p', 'bash': 'type -P', 'sh': 'command -v'}
|
|
68
|
+
SENTINEL = '__claude_multiacc_probe__'
|
|
69
|
+
# The PATH a login shell STARTS from before any rc file runs — what sshd/login/launchd
|
|
70
|
+
# hand it. /usr/local/bin matters on Linux: the root install's /usr/local/bin/claude
|
|
71
|
+
# symlink is how a shell with no rc block still reaches the shim, so leaving it out
|
|
72
|
+
# would call a working server login a bypass. macOS's path_helper rebuilds PATH from
|
|
73
|
+
# /etc/paths anyway.
|
|
74
|
+
_SEED_PATH = {'darwin': '/usr/bin:/bin:/usr/sbin:/sbin'}
|
|
75
|
+
_SEED_PATH_DEFAULT = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def enabled():
|
|
79
|
+
return os.environ.get('CLAUDE_MULTIACC_PATH_PROBE', '').strip().lower() \
|
|
80
|
+
not in ('0', 'false', 'no', 'off')
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def shell_path(name):
|
|
84
|
+
"""The shell binary a terminal would start, or None when it is not installed."""
|
|
85
|
+
found = shutil.which(name, path='/bin:/usr/bin:/usr/local/bin:/opt/homebrew/bin')
|
|
86
|
+
if found:
|
|
87
|
+
return found
|
|
88
|
+
candidate = os.path.join('/bin', name)
|
|
89
|
+
return candidate if os.access(candidate, os.X_OK) else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _script(shell, names):
|
|
93
|
+
resolver = _RESOLVERS[shell]
|
|
94
|
+
# First thing, before any answer: no history. The interactive shells would otherwise
|
|
95
|
+
# save these lines into the operator's real ~/.zsh_history / ~/.bash_history on exit
|
|
96
|
+
# (macOS /etc/zshrc sets HISTFILE unconditionally, so an env var is not enough — this
|
|
97
|
+
# runs AFTER the rc files and wins). bash honours `unset HISTFILE`; zsh honours
|
|
98
|
+
# SAVEHIST=0. The stderr redirect swallows "no such option" from the other shell.
|
|
99
|
+
# `set +o history` is bash-only: zsh's `set` rejects the option, and a NON-interactive
|
|
100
|
+
# zsh exits on a special-builtin error — the whole `-lc` row would read as "exited
|
|
101
|
+
# without answering". zsh needs nothing beyond SAVEHIST=0.
|
|
102
|
+
lines = ['unset HISTFILE 2>/dev/null; HISTFILE=/dev/null; SAVEHIST=0; HISTSIZE=0; '
|
|
103
|
+
'[ -z "${BASH_VERSION:-}" ] || set +o history; true']
|
|
104
|
+
if shell == 'sh':
|
|
105
|
+
# `command -v` answers with alias text or a bare function name; the PATH search
|
|
106
|
+
# is the question, so those go first (the shell is a throwaway).
|
|
107
|
+
lines.append('unalias -a 2>/dev/null; unset -f ' + ' '.join(names) + ' 2>/dev/null; true')
|
|
108
|
+
lines += [f'printf \'%s %s=%s\\n\' {SENTINEL} {n} "$({resolver} {n} 2>/dev/null)"'
|
|
109
|
+
for n in names]
|
|
110
|
+
# The interactive shells read this on stdin; without the exit an rc file that
|
|
111
|
+
# left the shell in a state where EOF is ignored (IGNOREEOF) would hang to timeout.
|
|
112
|
+
lines.append('exit 0')
|
|
113
|
+
return '\n'.join(lines) + '\n'
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _env(home, user, shell):
|
|
117
|
+
# A TERM a terminal would set: rc files commonly bail out early on TERM=dumb (the
|
|
118
|
+
# Emacs TRAMP guard), which would judge a branch no person at a prompt ever sees.
|
|
119
|
+
return {'HOME': home, 'USER': user, 'LOGNAME': user, 'SHELL': shell,
|
|
120
|
+
'TERM': 'xterm-256color',
|
|
121
|
+
'PATH': _SEED_PATH.get(sys.platform, _SEED_PATH_DEFAULT),
|
|
122
|
+
'LANG': os.environ.get('LANG', 'C.UTF-8'),
|
|
123
|
+
'CLAUDE_MULTIACC_PATH_PROBE': '1'}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_shim(path, repo_dir, name):
|
|
127
|
+
if not path:
|
|
128
|
+
return False
|
|
129
|
+
try:
|
|
130
|
+
if os.path.realpath(path) == os.path.realpath(os.path.join(repo_dir, 'bin', name)):
|
|
131
|
+
return True
|
|
132
|
+
with open(path, 'rb') as f:
|
|
133
|
+
return SHIM_MARK.encode() in f.read(300)
|
|
134
|
+
except OSError:
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse(text, names):
|
|
139
|
+
"""First sentinel answer per name. Anything else the shell printed is ignored."""
|
|
140
|
+
answers = {}
|
|
141
|
+
for line in text.splitlines():
|
|
142
|
+
if not line.startswith(SENTINEL + ' '):
|
|
143
|
+
continue
|
|
144
|
+
body = line[len(SENTINEL) + 1:]
|
|
145
|
+
name, _sep, value = body.partition('=')
|
|
146
|
+
if name in names and name not in answers:
|
|
147
|
+
answers[name] = value.strip()
|
|
148
|
+
return answers
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _run_mode(shell, flags, interactive, names, repo_dir, home, user, timeout):
|
|
152
|
+
exe = shell_path(shell)
|
|
153
|
+
mode = f'{shell} {flags}'
|
|
154
|
+
row = {'shell': shell, 'mode': mode, 'interactive': interactive, 'argv': None,
|
|
155
|
+
'results': {}, 'error': None}
|
|
156
|
+
if not exe:
|
|
157
|
+
row['error'] = 'shell not installed'
|
|
158
|
+
return row
|
|
159
|
+
script = _script(shell, names)
|
|
160
|
+
argv = [exe, flags] if interactive else [exe, flags, script]
|
|
161
|
+
row['argv'] = argv
|
|
162
|
+
try:
|
|
163
|
+
text, timed_out, returncode = _run_shell(argv, script if interactive else '',
|
|
164
|
+
_env(home, user, exe), home, timeout)
|
|
165
|
+
except Exception as e: # noqa: BLE001 — one mode must never take down the report
|
|
166
|
+
row['error'] = f'probe could not run: {e!r}'[:200]
|
|
167
|
+
return row
|
|
168
|
+
answers = _parse(text, names)
|
|
169
|
+
missing = [n for n in names if n not in answers]
|
|
170
|
+
if timed_out and missing:
|
|
171
|
+
row['error'] = f'no answer within {timeout}s (an rc file is hanging or prompting)'
|
|
172
|
+
return row
|
|
173
|
+
if not answers:
|
|
174
|
+
row['error'] = (f'shell exited {returncode} without answering '
|
|
175
|
+
'(an rc file exec\'d something, exited early, or read the probe)')
|
|
176
|
+
return row
|
|
177
|
+
for n in names:
|
|
178
|
+
if n in answers:
|
|
179
|
+
resolved = answers[n] or None
|
|
180
|
+
row['results'][n] = {'resolved': resolved, 'ok': is_shim(resolved, repo_dir, n)}
|
|
181
|
+
else:
|
|
182
|
+
row['results'][n] = {'resolved': None, 'ok': None}
|
|
183
|
+
if missing:
|
|
184
|
+
row['error'] = (f'no answer for {", ".join(missing)} — an rc file consumed part of '
|
|
185
|
+
'the probe (a prompt or `read` on stdin?)')
|
|
186
|
+
return row
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _run_shell(argv, stdin_text, env, cwd, timeout):
|
|
190
|
+
"""(stdout text, timed_out, returncode). stdout goes to a temp FILE, not a pipe:
|
|
191
|
+
a pipe only closes when every process holding it is gone, so an rc line that
|
|
192
|
+
backgrounds something (`ssh-agent`, `tmux new -d`, `cmd &`) would keep a pipe open
|
|
193
|
+
long after the shell answered and exited, and the whole answer would be lost to
|
|
194
|
+
the timeout. The shell gets its own session so a timeout can kill everything it
|
|
195
|
+
started. Whatever was written before a timeout is still returned."""
|
|
196
|
+
with tempfile.TemporaryFile() as out:
|
|
197
|
+
p = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=out, stderr=subprocess.DEVNULL,
|
|
198
|
+
env=env, cwd=cwd if os.path.isdir(cwd) else None,
|
|
199
|
+
start_new_session=True)
|
|
200
|
+
timed_out = False
|
|
201
|
+
try:
|
|
202
|
+
p.communicate(stdin_text.encode('utf-8'), timeout=timeout)
|
|
203
|
+
except subprocess.TimeoutExpired:
|
|
204
|
+
timed_out = True
|
|
205
|
+
try:
|
|
206
|
+
os.killpg(p.pid, signal.SIGKILL)
|
|
207
|
+
except OSError:
|
|
208
|
+
pass
|
|
209
|
+
try:
|
|
210
|
+
p.wait(timeout=5)
|
|
211
|
+
except subprocess.TimeoutExpired:
|
|
212
|
+
pass
|
|
213
|
+
out.seek(0)
|
|
214
|
+
text = out.read().decode('utf-8', errors='replace')
|
|
215
|
+
return text, timed_out, p.returncode
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def probe(repo_dir, home=None, user=None, names=NAMES, timeout=DEFAULT_TIMEOUT, modes=MODES):
|
|
219
|
+
if not enabled():
|
|
220
|
+
return []
|
|
221
|
+
home = home or os.path.expanduser('~')
|
|
222
|
+
try:
|
|
223
|
+
user = user or getpass.getuser()
|
|
224
|
+
except Exception: # noqa: BLE001 — no passwd entry; the name only seeds $USER
|
|
225
|
+
user = os.environ.get('USER') or 'user'
|
|
226
|
+
with ThreadPoolExecutor(max_workers=len(modes)) as pool:
|
|
227
|
+
return list(pool.map(
|
|
228
|
+
lambda m: _run_mode(m[0], m[1], m[2], names, repo_dir, home, user, timeout), modes))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def bypassed(rows):
|
|
232
|
+
out = []
|
|
233
|
+
for row in rows:
|
|
234
|
+
for name, r in row['results'].items():
|
|
235
|
+
if r['ok'] is False:
|
|
236
|
+
out.append((row['mode'], name, r['resolved']))
|
|
237
|
+
return out
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def render(rows):
|
|
241
|
+
"""Operator-facing lines. The verdict line is the one that matters; the per-mode
|
|
242
|
+
lines say WHICH shell to open to reproduce it."""
|
|
243
|
+
lines = ['shim on PATH (fresh login shells, this user\'s rc files):']
|
|
244
|
+
if not rows:
|
|
245
|
+
lines.append(' (probe disabled: CLAUDE_MULTIACC_PATH_PROBE=0)')
|
|
246
|
+
return lines
|
|
247
|
+
for row in rows:
|
|
248
|
+
cell = f' {row["mode"]:<8}'
|
|
249
|
+
if row['error']:
|
|
250
|
+
lines.append(f'{cell} ? {row["error"]}')
|
|
251
|
+
continue
|
|
252
|
+
parts = []
|
|
253
|
+
for name, r in row['results'].items():
|
|
254
|
+
if r['ok'] is None:
|
|
255
|
+
parts.append(f'{name}: ?')
|
|
256
|
+
elif r['ok']:
|
|
257
|
+
parts.append(f'{name}: OK')
|
|
258
|
+
elif r['resolved']:
|
|
259
|
+
parts.append(f'{name}: BYPASSED -> {r["resolved"]}')
|
|
260
|
+
else:
|
|
261
|
+
parts.append(f'{name}: NOT ON PATH')
|
|
262
|
+
lines.append(f'{cell} {" ".join(parts)}' + (f' ({row["error"]})' if row['error'] else ''))
|
|
263
|
+
bad = bypassed(rows)
|
|
264
|
+
if bad:
|
|
265
|
+
modes = sorted({m for m, _n, _r in bad})
|
|
266
|
+
lines.append(f' ** {len(modes)} shell mode(s) start `claude`/`codex` OUTSIDE the pool '
|
|
267
|
+
f'({", ".join(modes)}): such a session runs the real binary on the '
|
|
268
|
+
'machine\'s own ~/.claude login — or on no login at all ("Not logged '
|
|
269
|
+
'in · Please run /login"). **')
|
|
270
|
+
lines.append(' fix: claude-multiacc install # rewrites the rc blocks (~/.zshenv, '
|
|
271
|
+
'~/.zprofile, ~/.zshrc, ~/.profile, bash rc files) with the prompt hook')
|
|
272
|
+
lines.append(' then open a new shell (or run: exec "$SHELL" -l) — an already-open '
|
|
273
|
+
'shell keeps the PATH it has')
|
|
274
|
+
unanswered = [row['mode'] for row in rows
|
|
275
|
+
if row['error'] and row['error'] != 'shell not installed' and not row['results']]
|
|
276
|
+
if unanswered:
|
|
277
|
+
lines.append(f' note: {", ".join(unanswered)} could not be probed — open one by hand '
|
|
278
|
+
'and run: command -v claude')
|
|
279
|
+
return lines
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def main(argv):
|
|
283
|
+
args = [a for a in argv[1:] if not a.startswith('--')]
|
|
284
|
+
flags = [a for a in argv[1:] if a.startswith('--')]
|
|
285
|
+
if len(args) != 1 or any(f not in ('--json',) and not f.startswith('--timeout=') for f in flags):
|
|
286
|
+
print('usage: shim_path.py <repo-dir> [--json] [--timeout=SECONDS]', file=sys.stderr)
|
|
287
|
+
return 2
|
|
288
|
+
timeout = DEFAULT_TIMEOUT
|
|
289
|
+
for f in flags:
|
|
290
|
+
if f.startswith('--timeout='):
|
|
291
|
+
try:
|
|
292
|
+
timeout = max(1, int(f.split('=', 1)[1]))
|
|
293
|
+
except ValueError:
|
|
294
|
+
print('usage: --timeout takes an integer number of seconds', file=sys.stderr)
|
|
295
|
+
return 2
|
|
296
|
+
rows = probe(args[0], timeout=timeout)
|
|
297
|
+
if '--json' in flags:
|
|
298
|
+
print(json.dumps({'repo_dir': args[0], 'enabled': enabled(), 'modes': rows,
|
|
299
|
+
'bypassed': [list(b) for b in bypassed(rows)]}, indent=2))
|
|
300
|
+
else:
|
|
301
|
+
print('\n'.join(render(rows)))
|
|
302
|
+
return 1 if bypassed(rows) else 0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == '__main__':
|
|
306
|
+
sys.exit(main(sys.argv))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.33",
|
|
4
4
|
"description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"scripts": {
|
|
57
57
|
"postinstall": "node scripts/postinstall.mjs",
|
|
58
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",
|
|
59
|
+
"test:core": "bash tests/run-tests.sh && python3 tests/test_selector.py && python3 tests/test_shim_path.py",
|
|
60
60
|
"test:seeding": "python3 tests/test_account_seeding.py && npm run test:codex-settings",
|
|
61
61
|
"test:codex-settings": "python3 tests/test_codex_config_edit.py && python3 tests/test_codex_settings.py",
|
|
62
62
|
"test:commands": "python3 tests/test_packaged_commands.py"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/tests/run-tests.sh
CHANGED
|
@@ -292,6 +292,10 @@ export CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-endpoint-missing.json"
|
|
|
292
292
|
# extra `security` process per account would only slow them down); section 17 turns
|
|
293
293
|
# them on explicitly against the fake `security` above.
|
|
294
294
|
export CLAUDE_MULTIACC_KEYCHAIN=0
|
|
295
|
+
# `status` probes fresh login shells with $HOME's rc files (lib/shim_path.py). This suite
|
|
296
|
+
# runs under the operator's real HOME, whose rc files are not under test — section 18
|
|
297
|
+
# turns the probe on against its own fake HOME.
|
|
298
|
+
export CLAUDE_MULTIACC_PATH_PROBE=0
|
|
295
299
|
|
|
296
300
|
now="$(date +%s)"
|
|
297
301
|
|
|
@@ -6897,6 +6901,116 @@ grep -q '"acct-04"' "$KCP/accounts.json" \
|
|
|
6897
6901
|
export CLAUDE_MULTIACC_KEYCHAIN=0
|
|
6898
6902
|
unset FAKE_KEYCHAIN_DIR
|
|
6899
6903
|
|
|
6904
|
+
# ---- 18. the rc block shadows `claude` even when the rc file that carries it is cut short ----
|
|
6905
|
+
# 2026-09-17: a Ctrl-C at the slow conda hook in ~/.zshrc stopped the file before the
|
|
6906
|
+
# multiacc block, ~/.local/bin stayed in front, and the next `claude` at that prompt ran
|
|
6907
|
+
# the real binary on ~/.claude — "Not logged in · Please run /login". Every `bash -l` and
|
|
6908
|
+
# `sh -l` on that Mac bypassed the shim too: ~/.profile (read when there is no
|
|
6909
|
+
# ~/.bash_profile) carried no block at all. The block now ends with a prompt hook that
|
|
6910
|
+
# re-asserts the shim dir before every prompt, the installer appends to ~/.profile, and
|
|
6911
|
+
# `status` probes fresh login shells so a bypass is reported instead of guessed at.
|
|
6912
|
+
RCH="$WORK/rc-home"
|
|
6913
|
+
mkdir -p "$RCH/.local/bin"
|
|
6914
|
+
printf '#!/bin/sh\necho REAL\n' > "$RCH/.local/bin/claude"
|
|
6915
|
+
cp "$RCH/.local/bin/claude" "$RCH/.local/bin/codex"
|
|
6916
|
+
chmod +x "$RCH/.local/bin/claude" "$RCH/.local/bin/codex"
|
|
6917
|
+
rc_block_text() { # the block the installer writes, for THIS repo
|
|
6918
|
+
REPO_DIR="$REPO_DIR" MARK_BEGIN='# >>> claude-multiacc >>>' MARK_END='# <<< claude-multiacc <<<' \
|
|
6919
|
+
bash -c '. "$REPO_DIR/lib/install_actions.sh"; rc_block'
|
|
6920
|
+
}
|
|
6921
|
+
rc_block_text > "$WORK/rc-block.sh"
|
|
6922
|
+
for rsh in sh bash zsh; do
|
|
6923
|
+
command -v "$rsh" >/dev/null 2>&1 || continue
|
|
6924
|
+
if "$rsh" -n "$WORK/rc-block.sh" 2>/dev/null; then t_ok "rc block parses under $rsh"
|
|
6925
|
+
else t_fail "rc block parses under $rsh" "$("$rsh" -n "$WORK/rc-block.sh" 2>&1 | head -2)"; fi
|
|
6926
|
+
done
|
|
6927
|
+
grep -q '_claude_multiacc_path_guard' "$WORK/rc-block.sh" \
|
|
6928
|
+
&& t_ok "rc block carries the prompt hook" || t_fail "rc block carries the prompt hook"
|
|
6929
|
+
# bash: the PROMPT_COMMAND hook undoes a later re-prepend, and registers itself once
|
|
6930
|
+
out="$(HOME="$RCH" bash -c '. "$1"; . "$1"; PATH="$HOME/.local/bin:$PATH"; eval "$PROMPT_COMMAND"; printf "%s|%s" "${PATH%%:*}" "$PROMPT_COMMAND"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
6931
|
+
[ "$out" = "$REPO_DIR/bin|_claude_multiacc_path_guard" ] \
|
|
6932
|
+
&& t_ok "bash prompt hook re-asserts the shim dir (registered once)" \
|
|
6933
|
+
|| t_fail "bash prompt hook re-asserts the shim dir" "got: $out"
|
|
6934
|
+
if command -v zsh >/dev/null 2>&1; then
|
|
6935
|
+
out="$(HOME="$RCH" zsh -c '. "$1"; . "$1"; PATH="$HOME/.local/bin:$PATH"; _claude_multiacc_path_guard; print -r -- "${PATH%%:*}|${(j:,:)precmd_functions}"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
6936
|
+
[ "$out" = "$REPO_DIR/bin|_claude_multiacc_path_guard" ] \
|
|
6937
|
+
&& t_ok "zsh precmd hook re-asserts the shim dir (registered once)" \
|
|
6938
|
+
|| t_fail "zsh precmd hook re-asserts the shim dir" "got: $out"
|
|
6939
|
+
fi
|
|
6940
|
+
# ORDER: a hook registered before ours that rewrites PATH (direnv, a venv) must not have
|
|
6941
|
+
# the final word — bash appends after it, zsh moves itself behind hooks ~/.zshrc added.
|
|
6942
|
+
out="$(HOME="$RCH" bash -c 'PROMPT_COMMAND="other_hook"; . "$1"; printf %s "$PROMPT_COMMAND"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
6943
|
+
[ "$out" = "other_hook;_claude_multiacc_path_guard" ] \
|
|
6944
|
+
&& t_ok "bash hook is appended after an existing PROMPT_COMMAND" \
|
|
6945
|
+
|| t_fail "bash hook is appended after an existing PROMPT_COMMAND" "got: $out"
|
|
6946
|
+
if command -v zsh >/dev/null 2>&1; then
|
|
6947
|
+
out="$(HOME="$RCH" zsh -c '. "$1"; precmd_functions+=(later_hook); . "$1"; print -r -- "${(j:,:)precmd_functions}"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
6948
|
+
[ "$out" = "later_hook,_claude_multiacc_path_guard" ] \
|
|
6949
|
+
&& t_ok "zsh hook re-registers itself LAST when the block is sourced again" \
|
|
6950
|
+
|| t_fail "zsh hook re-registers itself LAST" "got: $out"
|
|
6951
|
+
out="$(HOME="$RCH" zsh -c 'IFS=":"; . "$1"; . "$1"; print -r -- "${(j:,:)precmd_functions}"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
6952
|
+
[ "$out" = "_claude_multiacc_path_guard" ] \
|
|
6953
|
+
&& t_ok "zsh hook registers once whatever IFS is" \
|
|
6954
|
+
|| t_fail "zsh hook registers once whatever IFS is" "got: $out"
|
|
6955
|
+
fi
|
|
6956
|
+
# the hook is inert once the shim dir is gone: no error, PATH left alone
|
|
6957
|
+
out="$(HOME="$RCH" bash -c 'sed "s|'"$REPO_DIR"'|/nonexistent/multiacc|g" "$1" > "$1.gone"; . "$1.gone"; PATH="$HOME/.local/bin:$PATH"; _claude_multiacc_path_guard; printf "rc=%s %s" "$?" "${PATH%%:*}"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
6958
|
+
[ "$out" = "rc=0 $RCH/.local/bin" ] \
|
|
6959
|
+
&& t_ok "prompt hook is inert when the shim dir is gone" \
|
|
6960
|
+
|| t_fail "prompt hook is inert when the shim dir is gone" "got: $out"
|
|
6961
|
+
# ~/.profile: CREATED when absent (a stock Mac has none — without it every bash/sh login
|
|
6962
|
+
# shell stays bypassed and the probe would fail health with a remedy that does nothing),
|
|
6963
|
+
# appended to exactly once when present, and ~/.bash_profile is never created.
|
|
6964
|
+
rm -f "$RCH/.profile"
|
|
6965
|
+
HOME="$RCH" REPO_DIR="$REPO_DIR" MARK_BEGIN='# >>> claude-multiacc >>>' MARK_END='# <<< claude-multiacc <<<' \
|
|
6966
|
+
bash -c '. "$REPO_DIR/lib/install_actions.sh"; install_profile_block' >/dev/null
|
|
6967
|
+
[ -f "$RCH/.profile" ] && [ "$(grep -c '# >>> claude-multiacc >>>' "$RCH/.profile")" = "1" ] \
|
|
6968
|
+
&& t_ok "~/.profile is created with the block when absent" \
|
|
6969
|
+
|| t_fail "~/.profile is created with the block when absent"
|
|
6970
|
+
printf 'export PATH="$HOME/.local/bin:$PATH"\n' > "$RCH/.profile"
|
|
6971
|
+
for _ in 1 2; do
|
|
6972
|
+
HOME="$RCH" REPO_DIR="$REPO_DIR" MARK_BEGIN='# >>> claude-multiacc >>>' MARK_END='# <<< claude-multiacc <<<' \
|
|
6973
|
+
bash -c '. "$REPO_DIR/lib/install_actions.sh"; install_profile_block' >/dev/null
|
|
6974
|
+
done
|
|
6975
|
+
[ "$(grep -c '# >>> claude-multiacc >>>' "$RCH/.profile")" = "1" ] \
|
|
6976
|
+
&& t_ok "~/.profile gets the block exactly once" || t_fail "~/.profile gets the block exactly once"
|
|
6977
|
+
[ ! -e "$RCH/.bash_profile" ] && t_ok "installer never creates ~/.bash_profile" \
|
|
6978
|
+
|| t_fail "installer never creates ~/.bash_profile" "it exists"
|
|
6979
|
+
for lsh in sh bash; do
|
|
6980
|
+
out="$(env -i HOME="$RCH" USER="${USER:-tester}" TERM=dumb PATH=/usr/bin:/bin "$lsh" -lc 'command -v claude' 2>/dev/null)"
|
|
6981
|
+
[ "$out" = "$REPO_DIR/bin/claude" ] && t_ok "$lsh -l resolves the shim through ~/.profile" \
|
|
6982
|
+
|| t_fail "$lsh -l resolves the shim through ~/.profile" "got: $out"
|
|
6983
|
+
done
|
|
6984
|
+
# the interrupted-rc incident, end to end, through the probe `status` prints
|
|
6985
|
+
if command -v zsh >/dev/null 2>&1; then
|
|
6986
|
+
cp "$WORK/rc-block.sh" "$RCH/.zshenv"
|
|
6987
|
+
{ printf 'export PATH="$HOME/.local/bin:$PATH"\n'; cat "$WORK/rc-block.sh"; } > "$RCH/.zprofile"
|
|
6988
|
+
printf 'export PATH="$HOME/.local/bin:$PATH"\nreturn 0 # the rest of this rc file was never reached\n' > "$RCH/.zshrc"
|
|
6989
|
+
cat "$WORK/rc-block.sh" >> "$RCH/.zshrc"
|
|
6990
|
+
out="$(HOME="$RCH" CLAUDE_MULTIACC_PATH_PROBE=1 python3 "$REPO_DIR/lib/shim_path.py" "$REPO_DIR" 2>&1)"; rc=$?
|
|
6991
|
+
[ "$rc" = 0 ] && case "$out" in *"zsh -li"*"claude: OK"*) t_ok "interrupted ~/.zshrc: the prompt hook still hands claude to the shim" ;; *) t_fail "interrupted ~/.zshrc probe" "$out" ;; esac
|
|
6992
|
+
[ "$rc" = 0 ] || t_fail "interrupted ~/.zshrc probe exit" "rc=$rc: $out"
|
|
6993
|
+
# the same rc files with the OLD two-line block (no hook) are the incident itself
|
|
6994
|
+
for f in .zshenv .zprofile .zshrc; do
|
|
6995
|
+
awk '/^_claude_multiacc_path_guard\(\)/{skip=1} /^# <<< claude-multiacc <<</{skip=0} !skip' "$RCH/$f" > "$RCH/$f.old" && mv "$RCH/$f.old" "$RCH/$f"
|
|
6996
|
+
done
|
|
6997
|
+
out="$(HOME="$RCH" CLAUDE_MULTIACC_PATH_PROBE=1 python3 "$REPO_DIR/lib/shim_path.py" "$REPO_DIR" 2>&1)"; rc=$?
|
|
6998
|
+
case "$rc:$out" in 1:*"zsh -li"*"BYPASSED -> $RCH/.local/bin/claude"*) t_ok "the probe catches the pre-fix block (zsh -li bypassed)" ;;
|
|
6999
|
+
*) t_fail "the probe catches the pre-fix block" "rc=$rc: $out" ;; esac
|
|
7000
|
+
out="$(HOME="$RCH" CLAUDE_MULTIACC_PATH_PROBE=1 claude-accounts status 2>&1)"
|
|
7001
|
+
check "status reports the bypass" "BYPASSED -> $RCH/.local/bin/claude" "$out"
|
|
7002
|
+
check "status names the fix" "claude-multiacc install" "$out"
|
|
7003
|
+
# restore the hook: status is clean again
|
|
7004
|
+
cp "$WORK/rc-block.sh" "$RCH/.zshenv"
|
|
7005
|
+
{ printf 'export PATH="$HOME/.local/bin:$PATH"\n'; cat "$WORK/rc-block.sh"; } > "$RCH/.zprofile"
|
|
7006
|
+
out="$(HOME="$RCH" CLAUDE_MULTIACC_PATH_PROBE=1 claude-accounts status 2>&1)"
|
|
7007
|
+
case "$out" in *BYPASSED*|*"NOT ON PATH"*) t_fail "status is clean with the hooked block" "$out" ;;
|
|
7008
|
+
*"shim on PATH"*) t_ok "status is clean with the hooked block" ;;
|
|
7009
|
+
*) t_fail "status prints the shim probe" "$out" ;; esac
|
|
7010
|
+
fi
|
|
7011
|
+
out="$(HOME="$RCH" claude-accounts status 2>&1)"
|
|
7012
|
+
check "status honours CLAUDE_MULTIACC_PATH_PROBE=0" "probe disabled" "$out"
|
|
7013
|
+
|
|
6900
7014
|
# The reset-credit contract is easier to prove against a stateful local HTTP server
|
|
6901
7015
|
# than file:// fixtures: it pins thresholding, credit ordering and idempotent POST retry.
|
|
6902
7016
|
if CODEX_MULTIACC_AUTO_RESET=1 python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_codex_reset*.py'; then
|
|
@@ -33,14 +33,30 @@ class CodexConfigEditTests(unittest.TestCase):
|
|
|
33
33
|
self.assertEqual(tomllib.loads(updated)["agents"][KEY], 8)
|
|
34
34
|
self.assertEqual(updated_config(updated, 8), updated)
|
|
35
35
|
|
|
36
|
-
def
|
|
37
|
-
for source in ('[features.multi_agent_v2]\nmax_concurrent_threads_per_session = 20\n',
|
|
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
38
|
'[profiles.quick.agents]\nmax_threads = 20\n'):
|
|
39
39
|
with self.subTest(source=source), self.assertRaisesRegex(ValueError, "overrides"):
|
|
40
40
|
updated_config(source, 8)
|
|
41
41
|
source = '[features.multi_agent_v2]\nmax_concurrent_threads_per_session = 8\n'
|
|
42
42
|
self.assertEqual(tomllib.loads(updated_config(source, 8))["agents"][KEY], 8)
|
|
43
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
|
+
|
|
44
60
|
def test_unsupported_layouts_and_string_lookalikes_fail_safely(self):
|
|
45
61
|
for source in ('agents = { max_threads = 4 }\n',
|
|
46
62
|
'instructions = """\n[agents]\nmax_threads = 99\n"""\n'):
|
|
@@ -79,13 +79,31 @@ class CodexSettingsTests(unittest.TestCase):
|
|
|
79
79
|
|
|
80
80
|
def test_invalid_config_is_rejected_before_any_configuration_is_changed(self):
|
|
81
81
|
before = self.account.read_text()
|
|
82
|
-
self.global_config.write_text('[features.multi_agent_v2]\
|
|
82
|
+
self.global_config.write_text('[profiles.quick.features.multi_agent_v2]\n'
|
|
83
|
+
'max_concurrent_threads_per_session = 20\n')
|
|
83
84
|
result = self.command("configure", "--max-subagents", "8", "--include-global", "--json")
|
|
84
85
|
self.assertNotEqual(result.returncode, 0)
|
|
85
86
|
self.assertIn("overrides", result.stderr)
|
|
86
87
|
self.assertEqual(self.account.read_text(), before)
|
|
87
88
|
self.assertFalse((self.pool / "codex-settings-policy.json").exists())
|
|
88
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
|
+
|
|
89
107
|
def test_symlinked_global_config_remains_linked(self):
|
|
90
108
|
self.account.unlink()
|
|
91
109
|
self.account.symlink_to(self.global_config)
|
|
@@ -6,6 +6,7 @@ import plistlib
|
|
|
6
6
|
import re
|
|
7
7
|
import shlex
|
|
8
8
|
import shutil
|
|
9
|
+
import sys
|
|
9
10
|
import unittest
|
|
10
11
|
|
|
11
12
|
from packaged_command_support import ROOT, install_package, run, seed_pools
|
|
@@ -117,13 +118,46 @@ class PackagedCommandsTests(unittest.TestCase):
|
|
|
117
118
|
identity.write_text('#!/usr/bin/env bash\necho 0\n')
|
|
118
119
|
uname.chmod(0o755)
|
|
119
120
|
identity.chmod(0o755)
|
|
121
|
+
# bash/sh login shells read ~/.profile when ~/.bash_profile is absent, and a
|
|
122
|
+
# ~/.bashrc exists on most machines: both must end with the block, ~/.bash_profile
|
|
123
|
+
# must NOT be created (its existence would stop bash reading ~/.profile).
|
|
124
|
+
prepend = 'export PATH="$HOME/.local/bin:$PATH"\n'
|
|
125
|
+
for rc in (".profile", ".bashrc"):
|
|
126
|
+
(self.home / rc).write_text(prepend)
|
|
127
|
+
real_bin = self.home / ".local/bin"
|
|
128
|
+
real_bin.mkdir(parents=True)
|
|
129
|
+
for name in ("claude", "codex"):
|
|
130
|
+
(real_bin / name).write_text('#!/bin/sh\necho REAL\n')
|
|
131
|
+
(real_bin / name).chmod(0o755)
|
|
120
132
|
try:
|
|
121
133
|
for _ in range(2):
|
|
122
134
|
result = run(["claude-multiacc", "install", "--no-server", "--no-schedule"], self.env, self.work)
|
|
123
135
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
124
136
|
self.assertNotIn("status |", result.stdout)
|
|
125
|
-
for rc in (".zshenv", ".zprofile", ".zshrc"):
|
|
126
|
-
|
|
137
|
+
for rc in (".zshenv", ".zprofile", ".zshrc", ".profile", ".bashrc"):
|
|
138
|
+
text = (self.home / rc).read_text()
|
|
139
|
+
self.assertEqual(text.count("# >>> claude-multiacc >>>"), 1, rc)
|
|
140
|
+
self.assertEqual(text.count("_claude_multiacc_path_guard() {"), 1, rc)
|
|
141
|
+
self.assertTrue(text.rstrip().endswith("# <<< claude-multiacc <<<"), rc)
|
|
142
|
+
self.assertFalse((self.home / ".bash_profile").exists())
|
|
143
|
+
# ...and fresh login shells of every flavour now resolve the package's shims,
|
|
144
|
+
# the real binaries in ~/.local/bin notwithstanding (lib/shim_path.py is the
|
|
145
|
+
# same probe `claude-accounts status` and `health` print).
|
|
146
|
+
probe_env = {**self.env, "CLAUDE_MULTIACC_PATH_PROBE": "1", "USER": "tester"}
|
|
147
|
+
result = run([sys.executable, str(self.package / "lib/shim_path.py"), str(self.package), "--json"],
|
|
148
|
+
probe_env, self.work)
|
|
149
|
+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
150
|
+
report = json.loads(result.stdout)
|
|
151
|
+
self.assertEqual(report["bypassed"], [])
|
|
152
|
+
answered = [m for m in report["modes"] if m["error"] != "shell not installed"]
|
|
153
|
+
self.assertGreaterEqual(len(answered), 2)
|
|
154
|
+
for mode in answered:
|
|
155
|
+
self.assertIsNone(mode["error"], mode)
|
|
156
|
+
for name in ("claude", "codex"):
|
|
157
|
+
self.assertTrue(mode["results"][name]["ok"], mode)
|
|
158
|
+
status = run(["claude-accounts", "status"], probe_env, self.work)
|
|
159
|
+
self.assertIn("shim on PATH", status.stdout)
|
|
160
|
+
self.assertNotIn("BYPASSED", status.stdout)
|
|
127
161
|
calls = (self.home / "tool-calls").read_text()
|
|
128
162
|
self.assertNotRegex(calls, r"/(launchctl|crontab|ln) ")
|
|
129
163
|
script = 'source "$0" --no-server --no-schedule; mac_schedule_install'
|
|
@@ -137,7 +171,9 @@ class PackagedCommandsTests(unittest.TestCase):
|
|
|
137
171
|
self.assertTrue(Path(job["ProgramArguments"][0]).is_file())
|
|
138
172
|
result = run(["claude-multiacc", "uninstall"], self.env, self.work)
|
|
139
173
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
140
|
-
|
|
174
|
+
for rc in (".zshenv", ".zprofile", ".zshrc", ".profile", ".bashrc"):
|
|
175
|
+
self.assertNotIn("claude-multiacc", (self.home / rc).read_text(), rc)
|
|
176
|
+
self.assertEqual((self.home / ".profile").read_text(), prepend)
|
|
141
177
|
self.assertTrue((self.home / ".codex-accounts/accounts.json").exists())
|
|
142
178
|
uname.write_text('#!/usr/bin/env bash\necho Linux\n')
|
|
143
179
|
(self.home / "tool-calls").write_text("")
|