claude-multiacc 1.0.0
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/CLAUDE_ACCS_TASK.md +236 -0
- package/README.md +263 -0
- package/bin/claude +359 -0
- package/bin/claude-accounts +1127 -0
- package/bin/cli.mjs +112 -0
- package/install.sh +277 -0
- package/lib/common.sh +200 -0
- package/package.json +55 -0
- package/scripts/postinstall.mjs +32 -0
- package/tests/run-tests.sh +643 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# claude-multiacc sandboxed test suite.
|
|
3
|
+
# No network, no real accounts, no quota: fake `claude` binary + file:// usage fixtures.
|
|
4
|
+
set -u
|
|
5
|
+
|
|
6
|
+
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
7
|
+
WORK="$(mktemp -d "${TMPDIR:-/tmp}/multiacc-test.XXXXXX")"
|
|
8
|
+
trap 'rm -rf "$WORK"' EXIT
|
|
9
|
+
|
|
10
|
+
PASS=0
|
|
11
|
+
FAIL=0
|
|
12
|
+
t_ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
|
|
13
|
+
t_fail() { FAIL=$((FAIL+1)); printf 'FAIL %s%s\n' "$1" "${2:+ — $2}"; }
|
|
14
|
+
check() { # check <name> <expected-substring> <actual>
|
|
15
|
+
case "$3" in
|
|
16
|
+
*"$2"*) t_ok "$1" ;;
|
|
17
|
+
*) t_fail "$1" "expected substring '$2', got: $(printf '%s' "$3" | head -c 200)" ;;
|
|
18
|
+
esac
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# ---- sandbox layout -------------------------------------------------------
|
|
22
|
+
export CLAUDE_ACCOUNTS_DIR="$WORK/accounts"
|
|
23
|
+
ACC="$CLAUDE_ACCOUNTS_DIR"
|
|
24
|
+
FAKEBIN="$WORK/fakebin"
|
|
25
|
+
mkdir -p "$ACC/tmp" "$FAKEBIN"
|
|
26
|
+
|
|
27
|
+
# Fake "real" claude: prints which config dir/token it ran under; scriptable failures.
|
|
28
|
+
cat > "$FAKEBIN/claude" <<'EOF'
|
|
29
|
+
#!/usr/bin/env bash
|
|
30
|
+
# fake real claude for tests (not a multiacc shim)
|
|
31
|
+
if [ "${1:-}" = "auth" ] && [ "${2:-}" = "status" ]; then
|
|
32
|
+
printf '{"loggedIn": true, "email": "%s"}\n' "${FAKE_EMAIL:-fake@test}"
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
if [ "${1:-}" = "setup-token" ]; then
|
|
36
|
+
echo "Open this sign-in link: https://claude.ai/oauth/authorize?fake=1"
|
|
37
|
+
[ -n "${FAKE_TOKEN_FAIL:-}" ] && { echo "sign-in aborted" >&2; exit 1; }
|
|
38
|
+
echo "Your token: sk-ant-oat01-FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE"
|
|
39
|
+
exit 0
|
|
40
|
+
fi
|
|
41
|
+
if [ $# -eq 0 ] && [ -n "${FAKE_DO_LOGIN:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
|
|
42
|
+
# simulate an interactive session in which the user completed /login
|
|
43
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-new","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$CLAUDE_CONFIG_DIR/.credentials.json"
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
ctl="${FAKE_CTL:-/nonexistent}"
|
|
47
|
+
acct="$(basename "${CLAUDE_CONFIG_DIR:-none}")"
|
|
48
|
+
if [ -f "$ctl" ] && grep -qx "fail:$acct" "$ctl" 2>/dev/null; then
|
|
49
|
+
echo "API Error: 429 rate limit exceeded" >&2
|
|
50
|
+
exit 1
|
|
51
|
+
fi
|
|
52
|
+
for a in "$@"; do
|
|
53
|
+
case "$a" in
|
|
54
|
+
--exit7) echo "ordinary failure, not auth related" >&2; exit 7 ;;
|
|
55
|
+
--echo-stdin) cat; exit 0 ;;
|
|
56
|
+
esac
|
|
57
|
+
done
|
|
58
|
+
echo "CFG=$acct TOK=${CLAUDE_CODE_OAUTH_TOKEN:-none}"
|
|
59
|
+
EOF
|
|
60
|
+
chmod +x "$FAKEBIN/claude"
|
|
61
|
+
|
|
62
|
+
export PATH="$REPO_DIR/bin:$FAKEBIN:$PATH"
|
|
63
|
+
export FAKE_CTL="$WORK/ctl"
|
|
64
|
+
# Neutralize any ambient state from the invoking environment.
|
|
65
|
+
unset CLAUDE_CONFIG_DIR CLAUDE_CODE_OAUTH_TOKEN CLAUDE_ACCOUNT CLAUDE_SHIM_ACTIVE 2>/dev/null || true
|
|
66
|
+
export CLAUDE_MULTIACC_NO_SYNC=1
|
|
67
|
+
# Fixtures are local file:// URLs with no rate limit, so the anti-429 fetch throttle
|
|
68
|
+
# is off by default here; the throttle test re-enables it explicitly.
|
|
69
|
+
export CLAUDE_MULTIACC_MIN_FETCH=0
|
|
70
|
+
|
|
71
|
+
now="$(date +%s)"
|
|
72
|
+
|
|
73
|
+
# ---- 1. passthrough: no manifest yet -------------------------------------
|
|
74
|
+
out="$(claude 2>&1)"
|
|
75
|
+
check "passthrough without manifest" "CFG=none" "$out"
|
|
76
|
+
|
|
77
|
+
# ---- manifest + two oauth accounts ----------------------------------------
|
|
78
|
+
cat > "$ACC/accounts.json" <<EOF
|
|
79
|
+
{
|
|
80
|
+
"version": 1,
|
|
81
|
+
"server": "root@203.0.113.1",
|
|
82
|
+
"server_root": "/root/.claude-accounts",
|
|
83
|
+
"server_repo": "/root/claude-multiacc",
|
|
84
|
+
"threshold": 90,
|
|
85
|
+
"accounts": [
|
|
86
|
+
{"id": "acct-01", "email": "a@test", "home": "mac", "added_at": "2026-07-13T00:00:00Z"},
|
|
87
|
+
{"id": "acct-02", "email": "b@test", "home": "mac", "added_at": "2026-07-13T00:00:00Z"}
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
EOF
|
|
91
|
+
for i in 01 02; do
|
|
92
|
+
mkdir -p "$ACC/acct-$i"
|
|
93
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-test%s","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999,"scopes":["user:inference"],"subscriptionType":"max"}}' "$i" > "$ACC/acct-$i/.credentials.json"
|
|
94
|
+
done
|
|
95
|
+
|
|
96
|
+
# ---- 2-4. passthrough guards ----------------------------------------------
|
|
97
|
+
out="$(CLAUDE_CONFIG_DIR=/tmp/other claude 2>&1)"
|
|
98
|
+
check "passthrough with CLAUDE_CONFIG_DIR" "CFG=other" "$out"
|
|
99
|
+
out="$(CLAUDE_CODE_OAUTH_TOKEN=sk-test claude 2>&1)"
|
|
100
|
+
check "passthrough with CLAUDE_CODE_OAUTH_TOKEN" "CFG=none" "$out"
|
|
101
|
+
out="$(CLAUDE_MULTIACC_DISABLE=1 claude 2>&1)"
|
|
102
|
+
check "passthrough when disabled" "CFG=none" "$out"
|
|
103
|
+
|
|
104
|
+
# ---- 5. headroom selection: most WEEKLY headroom wins (session is only a tiebreaker) --
|
|
105
|
+
lj() { # lj <weekly> <session> <max> -> a fresh limits.json body
|
|
106
|
+
printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":%s,"max_percent":%s,"buckets":[]}' "$now" "$1" "$2" "$3"
|
|
107
|
+
}
|
|
108
|
+
# acct-01 weekly 80, acct-02 weekly 20 => always acct-02.
|
|
109
|
+
lj 80 10 80 > "$ACC/acct-01/limits.json"
|
|
110
|
+
lj 20 10 20 > "$ACC/acct-02/limits.json"
|
|
111
|
+
all2=1
|
|
112
|
+
for _ in $(seq 1 15); do
|
|
113
|
+
case "$(claude 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
|
|
114
|
+
done
|
|
115
|
+
[ "$all2" = "1" ] && t_ok "picks the highest weekly-headroom account (weekly 20 over 80)" \
|
|
116
|
+
|| t_fail "headroom selection" "picked the more-utilized account"
|
|
117
|
+
|
|
118
|
+
# THE KEY CASE from the research: a high (but sub-threshold) SESSION bucket must NOT
|
|
119
|
+
# deprioritize an account whose weekly headroom is better. acct-01: session 85, weekly 10;
|
|
120
|
+
# acct-02: session 20, weekly 70. Both eligible (max<90). acct-01 is the better pick —
|
|
121
|
+
# its near-full bucket is the 5h session (self-heals), weekly is nearly untouched.
|
|
122
|
+
lj 10 85 85 > "$ACC/acct-01/limits.json"
|
|
123
|
+
lj 70 20 70 > "$ACC/acct-02/limits.json"
|
|
124
|
+
all1=1
|
|
125
|
+
for _ in $(seq 1 15); do
|
|
126
|
+
case "$(claude 2>&1)" in *CFG=acct-01*) ;; *) all1=0 ;; esac
|
|
127
|
+
done
|
|
128
|
+
[ "$all1" = "1" ] && t_ok "high session does NOT beat better weekly headroom (10w/85s over 70w/20s)" \
|
|
129
|
+
|| t_fail "weekly-over-session" "ranked the account with less weekly headroom higher"
|
|
130
|
+
|
|
131
|
+
# equal weekly => session breaks the tie toward the account with more session headroom
|
|
132
|
+
lj 40 20 40 > "$ACC/acct-01/limits.json"
|
|
133
|
+
lj 40 80 80 > "$ACC/acct-02/limits.json"
|
|
134
|
+
all1=1
|
|
135
|
+
for _ in $(seq 1 15); do
|
|
136
|
+
case "$(claude 2>&1)" in *CFG=acct-01*) ;; *) all1=0 ;; esac
|
|
137
|
+
done
|
|
138
|
+
[ "$all1" = "1" ] && t_ok "equal weekly: lower session wins the tiebreak" \
|
|
139
|
+
|| t_fail "session tiebreak" "did not use session to break a weekly tie"
|
|
140
|
+
|
|
141
|
+
# fully equal scores spread load across accounts
|
|
142
|
+
lj 10 10 10 > "$ACC/acct-01/limits.json"
|
|
143
|
+
lj 10 10 10 > "$ACC/acct-02/limits.json"
|
|
144
|
+
hits1=0; hits2=0
|
|
145
|
+
for _ in $(seq 1 40); do
|
|
146
|
+
case "$(claude 2>&1)" in
|
|
147
|
+
*CFG=acct-01*) hits1=$((hits1+1)) ;;
|
|
148
|
+
*CFG=acct-02*) hits2=$((hits2+1)) ;;
|
|
149
|
+
esac
|
|
150
|
+
done
|
|
151
|
+
{ [ "$hits1" -gt 0 ] && [ "$hits2" -gt 0 ] && [ $((hits1+hits2)) -eq 40 ]; } \
|
|
152
|
+
&& t_ok "equal scores spread randomly (acct-01=$hits1 acct-02=$hits2)" \
|
|
153
|
+
|| t_fail "tie spreading" "acct-01=$hits1 acct-02=$hits2 (want both >0, total 40)"
|
|
154
|
+
|
|
155
|
+
# opt-out: legacy uniform-random mode still available
|
|
156
|
+
hits1=0; hits2=0
|
|
157
|
+
lj 80 80 80 > "$ACC/acct-01/limits.json"
|
|
158
|
+
for _ in $(seq 1 40); do
|
|
159
|
+
case "$(CLAUDE_SHIM_SELECT=random claude 2>&1)" in
|
|
160
|
+
*CFG=acct-01*) hits1=$((hits1+1)) ;;
|
|
161
|
+
*CFG=acct-02*) hits2=$((hits2+1)) ;;
|
|
162
|
+
esac
|
|
163
|
+
done
|
|
164
|
+
{ [ "$hits1" -gt 0 ] && [ "$hits2" -gt 0 ]; } \
|
|
165
|
+
&& t_ok "CLAUDE_SHIM_SELECT=random restores uniform spread" \
|
|
166
|
+
|| t_fail "random opt-out" "acct-01=$hits1 acct-02=$hits2"
|
|
167
|
+
|
|
168
|
+
# stale telemetry is neutral, never assumed free
|
|
169
|
+
printf '{"fetched_at":1,"weekly_percent":1,"session_percent":1,"max_percent":1,"buckets":[]}' > "$ACC/acct-01/limits.json"
|
|
170
|
+
lj 30 30 30 > "$ACC/acct-02/limits.json"
|
|
171
|
+
out="$(claude 2>&1)"
|
|
172
|
+
check "stale 1% loses to fresh 30% (stale is not trusted)" "CFG=acct-02" "$out"
|
|
173
|
+
rm -f "$ACC"/acct-*/limits.json
|
|
174
|
+
|
|
175
|
+
# ---- 6. explicit pin -------------------------------------------------------
|
|
176
|
+
out="$(CLAUDE_ACCOUNT=acct-02 claude 2>&1)"
|
|
177
|
+
check "CLAUDE_ACCOUNT pin" "CFG=acct-02" "$out"
|
|
178
|
+
|
|
179
|
+
# ---- 6b. pin works for an auth-less dir (login ceremony path) ----------------
|
|
180
|
+
mkdir -p "$ACC/acct-07"
|
|
181
|
+
out="$(CLAUDE_ACCOUNT=acct-07 claude 2>&1)"
|
|
182
|
+
check "pin to auth-less dir (ceremony)" "CFG=acct-07" "$out"
|
|
183
|
+
rmdir "$ACC/acct-07"
|
|
184
|
+
|
|
185
|
+
# ---- 7. limited marker excludes account ------------------------------------
|
|
186
|
+
printf '%s\nbucket=weekly_scoped:Fable percent=95 reason=limits\n' "$((now+3600))" > "$ACC/acct-01/.limited"
|
|
187
|
+
all2=1
|
|
188
|
+
for _ in $(seq 1 15); do
|
|
189
|
+
out="$(claude 2>&1)"
|
|
190
|
+
case "$out" in *CFG=acct-02*) ;; *) all2=0 ;; esac
|
|
191
|
+
done
|
|
192
|
+
[ "$all2" = "1" ] && t_ok "limited account excluded from pool" || t_fail "limited account excluded" "acct-01 was still picked"
|
|
193
|
+
|
|
194
|
+
# ---- 7b. pin overrides marker ----------------------------------------------
|
|
195
|
+
out="$(CLAUDE_ACCOUNT=acct-01 claude 2>&1)"
|
|
196
|
+
check "explicit pin wins over marker" "CFG=acct-01" "$out"
|
|
197
|
+
|
|
198
|
+
# ---- 8. expired marker auto-clears ------------------------------------------
|
|
199
|
+
printf '%s\nbucket=session percent=95 reason=limits\n' "$((now-10))" > "$ACC/acct-01/.limited"
|
|
200
|
+
claude >/dev/null 2>&1
|
|
201
|
+
[ ! -f "$ACC/acct-01/.limited" ] && t_ok "expired marker auto-cleared" || t_fail "expired marker auto-cleared" "marker still present"
|
|
202
|
+
|
|
203
|
+
# ---- 9. all limited -> least utilized fallback ------------------------------
|
|
204
|
+
printf '%s\nx\n' "$((now+3600))" > "$ACC/acct-01/.limited"
|
|
205
|
+
printf '%s\nx\n' "$((now+3600))" > "$ACC/acct-02/.limited"
|
|
206
|
+
printf '{"fetched_at":%s,"max_percent":97,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
|
|
207
|
+
printf '{"fetched_at":%s,"max_percent":91,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
|
|
208
|
+
out="$(claude 2>&1)"
|
|
209
|
+
check "all-limited falls back to least utilized" "CFG=acct-02" "$out"
|
|
210
|
+
grep -q "all-limited fallback=acct-02" "$ACC/selection.log" \
|
|
211
|
+
&& t_ok "fallback logged" || t_fail "fallback logged" "no all-limited line in selection.log"
|
|
212
|
+
rm -f "$ACC/acct-01/.limited" "$ACC/acct-02/.limited"
|
|
213
|
+
|
|
214
|
+
# ---- 9b. codex-review regressions: auth/marker/threshold hardening -------------
|
|
215
|
+
# empty .credentials.json must NOT count as auth (interrupted write)
|
|
216
|
+
mkdir -p "$ACC/acct-06"
|
|
217
|
+
: > "$ACC/acct-06/.credentials.json"
|
|
218
|
+
out="$(claude 2>&1)"
|
|
219
|
+
case "$out" in *CFG=acct-06*) t_fail "empty creds not selectable" "acct-06 was picked" ;;
|
|
220
|
+
*) t_ok "empty .credentials.json is not treated as auth" ;; esac
|
|
221
|
+
rm -rf "$ACC/acct-06"
|
|
222
|
+
|
|
223
|
+
# fresh telemetry >= threshold excludes even when the .limited marker is missing
|
|
224
|
+
printf '{"fetched_at":%s,"max_percent":95,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
|
|
225
|
+
printf '{"fetched_at":%s,"max_percent":10,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
|
|
226
|
+
rm -f "$ACC"/acct-*/.limited
|
|
227
|
+
all2=1
|
|
228
|
+
for _ in $(seq 1 12); do
|
|
229
|
+
out="$(claude 2>&1)"
|
|
230
|
+
case "$out" in *CFG=acct-02*) ;; *) all2=0 ;; esac
|
|
231
|
+
done
|
|
232
|
+
[ "$all2" = "1" ] && t_ok "telemetry backstop excludes >=90% without a marker" \
|
|
233
|
+
|| t_fail "telemetry backstop" "acct-01 (95%) was still selected"
|
|
234
|
+
|
|
235
|
+
# ...but STALE >=90% telemetry must not exclude (fail open, no invented exclusions)
|
|
236
|
+
printf '{"fetched_at":1,"max_percent":95,"buckets":[]}' > "$ACC/acct-01/limits.json"
|
|
237
|
+
rm -f "$ACC/acct-02/.credentials.json" # leave acct-01 as the only candidate
|
|
238
|
+
out="$(claude 2>&1)"
|
|
239
|
+
check "stale >=90% telemetry does not block (fail open)" "CFG=acct-01" "$out"
|
|
240
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-test02","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999,"scopes":["user:inference"],"subscriptionType":"max"}}' > "$ACC/acct-02/.credentials.json"
|
|
241
|
+
|
|
242
|
+
# a garbled/partial marker is treated as ACTIVE and never deleted (concurrent-write race)
|
|
243
|
+
printf 'GARBAGE-NOT-AN-EPOCH\n' > "$ACC/acct-01/.limited"
|
|
244
|
+
printf '{"fetched_at":%s,"max_percent":10,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
|
|
245
|
+
out="$(claude 2>&1)"
|
|
246
|
+
case "$out" in *CFG=acct-02*) t_ok "garbled marker treated as active (excluded)" ;;
|
|
247
|
+
*) t_fail "garbled marker" "acct-01 was selected despite an unparseable marker" ;; esac
|
|
248
|
+
[ -f "$ACC/acct-01/.limited" ] && t_ok "garbled marker not deleted by the shim" \
|
|
249
|
+
|| t_fail "garbled marker deleted" "shim destroyed a possibly-mid-write marker"
|
|
250
|
+
rm -f "$ACC"/acct-*/.limited "$ACC"/acct-*/limits.json
|
|
251
|
+
|
|
252
|
+
# ---- 10. token-only account exports CLAUDE_CODE_OAUTH_TOKEN ------------------
|
|
253
|
+
mkdir -p "$ACC/acct-03"
|
|
254
|
+
printf 'sk-ant-oat01-tok-for-03' > "$ACC/acct-03/server.token"
|
|
255
|
+
out="$(CLAUDE_ACCOUNT=acct-03 claude 2>&1)"
|
|
256
|
+
check "token-only account exports token" "TOK=sk-ant-oat01-tok-for-03" "$out"
|
|
257
|
+
|
|
258
|
+
# ---- 10b. user args survive selection (regression: pick_best must not touch "$@") --
|
|
259
|
+
out="$(claude --echo-stdin </dev/null 2>&1)"
|
|
260
|
+
[ -z "$out" ] && t_ok "args reach the real binary intact (no positional clobber)" \
|
|
261
|
+
|| t_fail "arg passthrough" "--echo-stdin ignored, got: $out"
|
|
262
|
+
|
|
263
|
+
# ---- 11. exit code passthrough (no retry on non-auth failure) ----------------
|
|
264
|
+
claude -p --exit7 >/dev/null 2>"$WORK/err11"
|
|
265
|
+
rc=$?
|
|
266
|
+
[ "$rc" = "7" ] && t_ok "exit code passthrough (rc=7)" || t_fail "exit code passthrough" "rc=$rc"
|
|
267
|
+
|
|
268
|
+
# ---- 12. -p retry on rate limit switches account -----------------------------
|
|
269
|
+
rm -f "$ACC"/acct-*/.limited
|
|
270
|
+
echo "fail:acct-01" > "$FAKE_CTL"
|
|
271
|
+
ok12=1
|
|
272
|
+
for _ in $(seq 1 10); do
|
|
273
|
+
out="$(claude -p hello < /dev/null 2>/dev/null)"
|
|
274
|
+
rc=$?
|
|
275
|
+
{ [ "$rc" = "0" ] && case "$out" in *CFG=acct-0[23]*) true ;; *) false ;; esac; } || ok12=0
|
|
276
|
+
done
|
|
277
|
+
[ "$ok12" = "1" ] && t_ok "-p retry recovers via another account" || t_fail "-p retry" "some run failed or used acct-01 output"
|
|
278
|
+
[ -f "$ACC/acct-01/.limited" ] && t_ok "failed account got error-cooldown marker" || t_fail "cooldown marker" "missing"
|
|
279
|
+
rm -f "$FAKE_CTL" "$ACC/acct-01/.limited"
|
|
280
|
+
|
|
281
|
+
# ---- 12b. pipe stdin skips retry buffering but passes bytes through -----------
|
|
282
|
+
out="$(printf 'pipe-data' | claude -p --echo-stdin 2>/dev/null)"
|
|
283
|
+
[ "$out" = "pipe-data" ] && t_ok "pipe stdin passes through (no retry buffering)" || t_fail "pipe stdin passthrough" "got: $out"
|
|
284
|
+
|
|
285
|
+
# ---- 12c. HOME unset: shim still fails open into passthrough ------------------
|
|
286
|
+
out="$(env -u HOME -u CLAUDE_ACCOUNTS_DIR claude 2>&1)"
|
|
287
|
+
check "HOME unset -> passthrough, no crash" "CFG=none" "$out"
|
|
288
|
+
|
|
289
|
+
# ---- 12d. error-cooldown marker survives a clean limits pass -------------------
|
|
290
|
+
printf '%s\nbucket=error-cooldown percent=? reason=error-cooldown\n' "$(( $(date +%s) + 600 ))" > "$ACC/acct-01/.limited"
|
|
291
|
+
cat > "$WORK/usage-mid.json" <<'EOF'
|
|
292
|
+
{"limits":[{"kind":"session","percent":10,"resets_at":"2099-01-01T00:00:00+00:00","scope":null}]}
|
|
293
|
+
EOF
|
|
294
|
+
CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-mid.json" claude-accounts limits --quiet
|
|
295
|
+
[ -f "$ACC/acct-01/.limited" ] && t_ok "error-cooldown marker survives clean limits refresh" || t_fail "cooldown vs limits" "marker was cleared early"
|
|
296
|
+
rm -f "$ACC/acct-01/.limited"
|
|
297
|
+
|
|
298
|
+
# ---- 12e. TTY stdin: retry disabled so the terminal is never swapped for /dev/null --
|
|
299
|
+
if command -v script >/dev/null 2>&1; then
|
|
300
|
+
# `claude -p` on a TTY with no prompt arg reads the terminal. Under a PTY the shim
|
|
301
|
+
# must take the plain exec path (stdin inherited), never the buffered retry path.
|
|
302
|
+
if [ "$(uname -s)" = "Darwin" ]; then
|
|
303
|
+
ptyout="$(script -q /dev/null env PATH="$PATH" CLAUDE_ACCOUNTS_DIR="$ACC" FAKE_CTL="$FAKE_CTL" claude -p --echo-stdin <<'PTYIN' 2>/dev/null
|
|
304
|
+
tty-typed-prompt
|
|
305
|
+
PTYIN
|
|
306
|
+
)"
|
|
307
|
+
else
|
|
308
|
+
ptyout="$(script -qec "claude -p --echo-stdin" /dev/null <<'PTYIN' 2>/dev/null
|
|
309
|
+
tty-typed-prompt
|
|
310
|
+
PTYIN
|
|
311
|
+
)"
|
|
312
|
+
fi
|
|
313
|
+
case "$ptyout" in
|
|
314
|
+
*tty-typed-prompt*) t_ok "TTY stdin reaches claude (retry path does not eat it)" ;;
|
|
315
|
+
*) t_fail "TTY stdin" "terminal input was lost: $(printf '%s' "$ptyout" | head -c 80)" ;;
|
|
316
|
+
esac
|
|
317
|
+
else
|
|
318
|
+
t_ok "TTY stdin test skipped (no script(1))"
|
|
319
|
+
fi
|
|
320
|
+
|
|
321
|
+
# ---- 13. stdin/stdout byte fidelity through retry path -----------------------
|
|
322
|
+
printf 'line1\nline2 with spaces\n' > "$WORK/stdin13"
|
|
323
|
+
out="$(claude -p --echo-stdin < "$WORK/stdin13" 2>/dev/null)"
|
|
324
|
+
expected="$(cat "$WORK/stdin13")"
|
|
325
|
+
[ "$out" = "$expected" ] && t_ok "stdin/stdout byte fidelity (-p pipe)" || t_fail "stdin fidelity" "got: $out"
|
|
326
|
+
|
|
327
|
+
# ---- 14. selection log written ------------------------------------------------
|
|
328
|
+
grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}T.*acct-0[123] weekly=[0-9?]+% session=[0-9?]+% pwd=' "$ACC/selection.log" \
|
|
329
|
+
&& t_ok "selection.log format" || t_fail "selection.log format" "no matching lines"
|
|
330
|
+
grep -qE 'sk-ant-oat|accessToken|refreshToken' "$ACC/selection.log" \
|
|
331
|
+
&& t_fail "selection.log has no secrets" "a token leaked into the log" \
|
|
332
|
+
|| t_ok "selection.log leaks no secrets"
|
|
333
|
+
|
|
334
|
+
# ---- 15. CLI: list / import / remove ------------------------------------------
|
|
335
|
+
out="$(claude-accounts list 2>&1)"
|
|
336
|
+
check "list shows accounts" "acct-01" "$out"
|
|
337
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-t4","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$WORK/import-creds.json"
|
|
338
|
+
out="$(claude-accounts import c@test --id acct-04 --creds "$WORK/import-creds.json" --mode copy --no-sync 2>&1)"
|
|
339
|
+
check "import account" "Imported acct-04" "$out"
|
|
340
|
+
[ -f "$ACC/acct-04/.credentials.json" ] && t_ok "import copied credentials" || t_fail "import copied credentials" "file missing"
|
|
341
|
+
out="$(claude-accounts list 2>&1)"
|
|
342
|
+
check "imported account listed" "c@test" "$out"
|
|
343
|
+
out="$(claude-accounts remove acct-04 --yes 2>&1)"
|
|
344
|
+
check "remove account" "Removed acct-04" "$out"
|
|
345
|
+
[ ! -d "$ACC/acct-04" ] && t_ok "remove deleted dir" || t_fail "remove deleted dir" "dir still there"
|
|
346
|
+
|
|
347
|
+
# ---- 15b. duplicate-email guards ------------------------------------------------
|
|
348
|
+
out="$(claude-accounts add a@test 2>&1)"
|
|
349
|
+
rc=$?
|
|
350
|
+
check "add refuses duplicate email" "already registered as acct-01" "$out"
|
|
351
|
+
[ "$rc" != "0" ] && t_ok "duplicate add exits nonzero" || t_fail "duplicate add rc" "rc=0"
|
|
352
|
+
[ ! -d "$ACC/acct-04" ] && t_ok "duplicate add created nothing" || t_fail "duplicate add" "dir created"
|
|
353
|
+
out="$(claude-accounts import a@test --id acct-09 --no-sync 2>&1)"
|
|
354
|
+
rc=$?
|
|
355
|
+
check "import refuses duplicate email" "already registered as acct-01" "$out"
|
|
356
|
+
[ ! -d "$ACC/acct-09" ] && t_ok "duplicate import created nothing" || t_fail "duplicate import" "dir created"
|
|
357
|
+
out="$(claude-accounts import a@test --id acct-01 --no-sync 2>&1)"
|
|
358
|
+
check "import same-id update allowed" "Imported acct-01" "$out"
|
|
359
|
+
|
|
360
|
+
# ---- 15c. login-first add (default link+code flow): registers after verified auth --
|
|
361
|
+
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=new@test claude-accounts add new@test 2>&1)"
|
|
362
|
+
check "add shows sign-in link" "sign-in link" "$out"
|
|
363
|
+
check "add registers after verified sign-in" "Registered acct-04 for new@test" "$out"
|
|
364
|
+
[ -s "$ACC/acct-04/server.token" ] && t_ok "token landed in the new dir" || t_fail "add token" "missing"
|
|
365
|
+
out="$(CLAUDE_ACCOUNT=acct-04 claude 2>&1)"
|
|
366
|
+
check "new account usable via pin (token auth)" "TOK=sk-ant-oat01-FAKE" "$out"
|
|
367
|
+
claude-accounts remove acct-04 --yes >/dev/null 2>&1
|
|
368
|
+
|
|
369
|
+
# ---- 15c2. --tui variant registers via /login creds --------------------------------
|
|
370
|
+
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_DO_LOGIN=1 FAKE_EMAIL=tui@test claude-accounts add tui@test --tui 2>&1)"
|
|
371
|
+
check "add --tui registers after verified login" "Registered acct-04 for tui@test" "$out"
|
|
372
|
+
[ -f "$ACC/acct-04/.credentials.json" ] && t_ok "tui login creds landed in the new dir" || t_fail "tui creds" "missing"
|
|
373
|
+
claude-accounts remove acct-04 --yes >/dev/null 2>&1
|
|
374
|
+
|
|
375
|
+
# ---- 15d. aborted sign-in leaves zero traces ----------------------------------------
|
|
376
|
+
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_TOKEN_FAIL=1 claude-accounts add ghost@test 2>&1)"
|
|
377
|
+
rc=$?
|
|
378
|
+
check "aborted sign-in detected" "sign-in failed or aborted" "$out"
|
|
379
|
+
[ "$rc" != "0" ] && t_ok "aborted add exits nonzero" || t_fail "aborted add rc" "rc=0"
|
|
380
|
+
[ ! -d "$ACC/acct-04" ] && t_ok "aborted add cleaned up its dir" || t_fail "aborted add cleanup" "dir left behind"
|
|
381
|
+
claude-accounts list 2>&1 | grep -q ghost@test && t_fail "aborted add not in manifest" "ghost@test registered" || t_ok "aborted add not in manifest"
|
|
382
|
+
|
|
383
|
+
# ---- 15e. sign-in as an already-registered email is rejected + cleaned ---------------
|
|
384
|
+
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=a@test claude-accounts add brand@test 2>&1)"
|
|
385
|
+
rc=$?
|
|
386
|
+
check "wrong-account sign-in rejected" "already registered as acct-01" "$out"
|
|
387
|
+
[ ! -d "$ACC/acct-04" ] && t_ok "wrong-account sign-in cleaned up" || t_fail "wrong-account cleanup" "dir left behind"
|
|
388
|
+
|
|
389
|
+
# ---- 15f. login command completes auth for an existing auth-less account -------------
|
|
390
|
+
claude-accounts import pending@test --id acct-08 --no-sync >/dev/null 2>&1
|
|
391
|
+
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=pending@test claude-accounts login acct-08 2>&1)"
|
|
392
|
+
check "login completes existing account" "acct-08 auth saved" "$out"
|
|
393
|
+
[ -s "$ACC/acct-08/server.token" ] && t_ok "login saved token" || t_fail "login token" "missing"
|
|
394
|
+
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=other@test claude-accounts login acct-08 2>&1)"
|
|
395
|
+
rc=$?
|
|
396
|
+
check "login email mismatch refused" "nothing saved" "$out"
|
|
397
|
+
[ "$rc" != "0" ] && t_ok "mismatched login exits nonzero" || t_fail "mismatched login rc" "rc=0"
|
|
398
|
+
claude-accounts remove acct-08 --yes >/dev/null 2>&1
|
|
399
|
+
|
|
400
|
+
# ---- 16. CLI: limits marking via fixture endpoint ------------------------------
|
|
401
|
+
cat > "$WORK/usage-high.json" <<'EOF'
|
|
402
|
+
{"limits":[
|
|
403
|
+
{"kind":"session","percent":29,"resets_at":"2099-01-01T00:00:00+00:00","scope":null},
|
|
404
|
+
{"kind":"weekly_all","percent":55,"resets_at":"2099-01-02T00:00:00+00:00","scope":null},
|
|
405
|
+
{"kind":"weekly_scoped","percent":93,"resets_at":"2099-01-03T00:00:00+00:00","scope":{"model":{"display_name":"Fable"}}}
|
|
406
|
+
]}
|
|
407
|
+
EOF
|
|
408
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-high.json" claude-accounts limits 2>&1)"
|
|
409
|
+
check "limits marks Fable bucket >=90%" "LIMITED weekly_scoped:Fable at 93%" "$out"
|
|
410
|
+
[ -f "$ACC/acct-01/.limited" ] && t_ok ".limited written by limits" || t_fail ".limited written" "missing"
|
|
411
|
+
grep -q "weekly_scoped:Fable" "$ACC/acct-01/limits.json" \
|
|
412
|
+
&& t_ok "limits.json has Fable bucket" || t_fail "limits.json Fable bucket" "missing"
|
|
413
|
+
out="$(CLAUDE_ACCOUNT='' claude 2>&1)" # pool should now avoid marked accounts (all marked -> fallback fine)
|
|
414
|
+
cat > "$WORK/usage-low.json" <<'EOF'
|
|
415
|
+
{"limits":[
|
|
416
|
+
{"kind":"session","percent":10,"resets_at":"2099-01-01T00:00:00+00:00","scope":null},
|
|
417
|
+
{"kind":"weekly_scoped","percent":45,"resets_at":"2099-01-03T00:00:00+00:00","scope":{"model":{"display_name":"Fable"}}}
|
|
418
|
+
]}
|
|
419
|
+
EOF
|
|
420
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
421
|
+
check "limits clears marker under threshold" "marker cleared" "$out"
|
|
422
|
+
[ ! -f "$ACC/acct-01/.limited" ] && t_ok "marker removed after clear" || t_fail "marker removed" "still present"
|
|
423
|
+
|
|
424
|
+
# ---- 16-weekly. limits classifies session vs weekly and records both signals --------
|
|
425
|
+
cat > "$WORK/usage-3bucket.json" <<'EOF'
|
|
426
|
+
{"limits":[
|
|
427
|
+
{"kind":"session","group":"session","percent":88,"resets_at":"2099-01-01T00:00:00+00:00","scope":null},
|
|
428
|
+
{"kind":"weekly_all","group":"weekly","percent":40,"resets_at":"2099-01-05T00:00:00+00:00","scope":null},
|
|
429
|
+
{"kind":"weekly_scoped","group":"weekly","percent":55,"resets_at":"2099-01-05T00:00:00+00:00","scope":{"model":{"display_name":"Fable"}}}
|
|
430
|
+
]}
|
|
431
|
+
EOF
|
|
432
|
+
CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-3bucket.json" claude-accounts limits --quiet
|
|
433
|
+
python3 - "$ACC/acct-01/limits.json" <<'EOF'
|
|
434
|
+
import json, sys
|
|
435
|
+
d = json.load(open(sys.argv[1]))
|
|
436
|
+
assert d["session_percent"] == 88, d
|
|
437
|
+
assert d["weekly_percent"] == 55, d # max of the two weekly buckets, not session
|
|
438
|
+
assert d["max_percent"] == 88, d # peak of ALL buckets (drives exclusion)
|
|
439
|
+
groups = {b["name"]: b["group"] for b in d["buckets"]}
|
|
440
|
+
assert groups["session"] == "session", groups
|
|
441
|
+
assert groups["weekly_all"] == "weekly", groups
|
|
442
|
+
assert groups["weekly_scoped:Fable"] == "weekly", groups
|
|
443
|
+
EOF
|
|
444
|
+
[ $? -eq 0 ] && t_ok "limits records weekly_percent(55) + session_percent(88) with correct groups" \
|
|
445
|
+
|| t_fail "weekly/session classification" "see limits.json"
|
|
446
|
+
# a session bucket at 88 must NOT poison weekly ranking: score uses weekly(55), not 88
|
|
447
|
+
sc="$(python3 -c "import json;d=json.load(open('$ACC/acct-01/limits.json'));print(d['weekly_percent']*1000+d['session_percent'])")"
|
|
448
|
+
[ "$sc" = "55088" ] && t_ok "ranking score weighs weekly over session (55088)" \
|
|
449
|
+
|| t_fail "ranking score" "got $sc"
|
|
450
|
+
rm -f "$ACC/acct-01/limits.json" "$ACC/acct-01/.limited"
|
|
451
|
+
|
|
452
|
+
# ---- 16a. future-proofing: works if the Fable bucket separation disappears ---------
|
|
453
|
+
cat > "$WORK/usage-no-fable.json" <<'EOF'
|
|
454
|
+
{"limits":[
|
|
455
|
+
{"kind":"session","percent":20,"resets_at":"2099-01-01T00:00:00+00:00","scope":null},
|
|
456
|
+
{"kind":"weekly_all","percent":95,"resets_at":"2099-01-02T00:00:00+00:00","scope":null}
|
|
457
|
+
]}
|
|
458
|
+
EOF
|
|
459
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-no-fable.json" claude-accounts limits 2>&1)"
|
|
460
|
+
check "no-Fable payload still marks on weekly_all" "LIMITED weekly_all at 95%" "$out"
|
|
461
|
+
grep -q "weekly_scoped" "$ACC/acct-01/limits.json" && t_fail "no stale Fable bucket" "old bucket kept" || t_ok "buckets reflect current payload only"
|
|
462
|
+
|
|
463
|
+
# ---- 16a2. legacy payload (no limits[] at all) falls back to five_hour/seven_day ----
|
|
464
|
+
cat > "$WORK/usage-legacy.json" <<'EOF'
|
|
465
|
+
{"five_hour":{"utilization":12.0,"resets_at":"2099-01-01T00:00:00+00:00"},
|
|
466
|
+
"seven_day":{"utilization":34.0,"resets_at":"2099-01-02T00:00:00+00:00"}}
|
|
467
|
+
EOF
|
|
468
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-legacy.json" claude-accounts limits 2>&1)"
|
|
469
|
+
check "legacy payload parsed via fallback" "five_hour=12%" "$out"
|
|
470
|
+
check "legacy marker cleared under threshold" "acct-01: ok" "$out"
|
|
471
|
+
|
|
472
|
+
# ---- 16a3. malformed/garbage payload entries never crash the refresher --------------
|
|
473
|
+
cat > "$WORK/usage-garbage.json" <<'EOF'
|
|
474
|
+
{"limits":[
|
|
475
|
+
{"kind":"session","percent":"NaNsense","scope":{"model":"stringnotdict"}},
|
|
476
|
+
"not-even-a-dict",
|
|
477
|
+
{"percent":41,"scope":{"model":{"display_name":null,"id":"claude-fable-5"}}},
|
|
478
|
+
{"kind":"weekly_all","percent":null}
|
|
479
|
+
]}
|
|
480
|
+
EOF
|
|
481
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-garbage.json" claude-accounts limits 2>&1)"
|
|
482
|
+
rc=$?
|
|
483
|
+
[ "$rc" = "0" ] && t_ok "garbage payload exits 0 (fail open)" || t_fail "garbage payload rc" "rc=$rc: $out"
|
|
484
|
+
check "parseable entry survives garbage siblings" "unknown:claude-fable-5=41%" "$out"
|
|
485
|
+
|
|
486
|
+
# ---- 16a4. fetch throttle: fresh data is not re-fetched -----------------------------
|
|
487
|
+
CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --quiet
|
|
488
|
+
before="$(python3 -c "import json; print(json.load(open('$ACC/acct-01/limits.json'))['fetched_at'])")"
|
|
489
|
+
mv "$WORK/usage-low.json" "$WORK/usage-low.hidden" # a real fetch would now fail loudly
|
|
490
|
+
out="$(CLAUDE_MULTIACC_MIN_FETCH=45 CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
491
|
+
after="$(python3 -c "import json; print(json.load(open('$ACC/acct-01/limits.json'))['fetched_at'])")"
|
|
492
|
+
{ [ "$before" = "$after" ] && ! printf '%s' "$out" | grep -q "fetch failed"; } \
|
|
493
|
+
&& t_ok "fresh data skips re-fetch (rate-limit protection)" \
|
|
494
|
+
|| t_fail "fetch throttle" "re-fetched despite fresh data: $out"
|
|
495
|
+
mv "$WORK/usage-low.hidden" "$WORK/usage-low.json"
|
|
496
|
+
out="$(CLAUDE_MULTIACC_MIN_FETCH=45 CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
497
|
+
check "--force bypasses the throttle" "acct-01: ok" "$out"
|
|
498
|
+
|
|
499
|
+
# ---- 16a5. 429 sets backoff, is honored, and clears on success -----------------------
|
|
500
|
+
python3 - "$ACC/acct-01/limits.json" <<'EOF'
|
|
501
|
+
import json, os, sys, time
|
|
502
|
+
p = sys.argv[1]
|
|
503
|
+
d = json.load(open(p))
|
|
504
|
+
d['fetched_at'] = 0 # stale enough to fetch
|
|
505
|
+
d['retry_after'] = int(time.time()) + 600 # but a 429 backoff is in force
|
|
506
|
+
d['backoff'] = 600
|
|
507
|
+
json.dump(d, open(p + '.tmp', 'w'), indent=1); os.replace(p + '.tmp', p)
|
|
508
|
+
EOF
|
|
509
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
510
|
+
check "429 backoff honored" "acct-01: backing off after 429" "$out"
|
|
511
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
512
|
+
check "--force overrides backoff" "acct-01: ok" "$out"
|
|
513
|
+
python3 -c "
|
|
514
|
+
import json, sys
|
|
515
|
+
d = json.load(open('$ACC/acct-01/limits.json'))
|
|
516
|
+
sys.exit(0 if 'retry_after' not in d and 'backoff' not in d else 1)" \
|
|
517
|
+
&& t_ok "successful fetch clears backoff state" || t_fail "backoff cleared" "retry_after/backoff persisted"
|
|
518
|
+
|
|
519
|
+
# ---- 16b. expired-bearer account skipped gracefully (fail open) -----------------
|
|
520
|
+
mkdir -p "$ACC/acct-05"
|
|
521
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"r","expiresAt":1000,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-05/.credentials.json"
|
|
522
|
+
claude-accounts import e@test --id acct-05 --no-sync >/dev/null 2>&1
|
|
523
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
524
|
+
rc=$?
|
|
525
|
+
check "expired bearer logged, not fatal" "acct-05: no fresh bearer" "$out"
|
|
526
|
+
[ "$rc" = "0" ] && t_ok "limits exits 0 with expired-bearer account" || t_fail "limits exit code" "rc=$rc"
|
|
527
|
+
[ ! -f "$ACC/acct-05/limits.json" ] && t_ok "no limits.json fabricated for expired account" || t_fail "expired acct limits.json" "unexpectedly written"
|
|
528
|
+
claude-accounts remove acct-05 --yes >/dev/null 2>&1
|
|
529
|
+
|
|
530
|
+
# ---- 16c. codex-review: security hardening -----------------------------------------
|
|
531
|
+
# path traversal via a hand-edited manifest id must never touch the filesystem
|
|
532
|
+
mkdir -p "$WORK/canary" && : > "$WORK/canary/DO_NOT_DELETE"
|
|
533
|
+
cp "$ACC/accounts.json" "$WORK/manifest.bak"
|
|
534
|
+
python3 - "$ACC/accounts.json" <<'EOF'
|
|
535
|
+
import json, os, sys
|
|
536
|
+
d = json.load(open(sys.argv[1]))
|
|
537
|
+
d['accounts'].append({'id': '../canary', 'email': 'evil@test', 'home': 'mac'})
|
|
538
|
+
json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
|
|
539
|
+
EOF
|
|
540
|
+
out="$(claude-accounts remove ../canary --yes 2>&1)"
|
|
541
|
+
rc=$?
|
|
542
|
+
[ -f "$WORK/canary/DO_NOT_DELETE" ] && t_ok "remove refuses path-traversal id (no deletion)" \
|
|
543
|
+
|| t_fail "path traversal" "remove ../canary DELETED files outside the pool"
|
|
544
|
+
[ "$rc" != "0" ] && t_ok "traversal id rejected nonzero" || t_fail "traversal rc" "rc=0"
|
|
545
|
+
claude-accounts list 2>&1 | grep -q "\.\./canary" \
|
|
546
|
+
&& t_fail "invalid ids filtered from listings" "traversal id surfaced" \
|
|
547
|
+
|| t_ok "invalid manifest ids are filtered out"
|
|
548
|
+
cp "$WORK/manifest.bak" "$ACC/accounts.json"
|
|
549
|
+
|
|
550
|
+
# sync is Mac-only (source of truth); its input-validation guards can only be exercised
|
|
551
|
+
# on Darwin. On Linux `sync` refuses up front, so skip with a note rather than fail.
|
|
552
|
+
if [ "$(uname -s)" = "Darwin" ]; then
|
|
553
|
+
# remote command injection via manifest server_root
|
|
554
|
+
python3 - "$ACC/accounts.json" <<'EOF'
|
|
555
|
+
import json, os, sys
|
|
556
|
+
d = json.load(open(sys.argv[1]))
|
|
557
|
+
d['server_root'] = "/tmp/x'; touch /tmp/multiacc_PWNED; #"
|
|
558
|
+
json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
|
|
559
|
+
EOF
|
|
560
|
+
rm -f /tmp/multiacc_PWNED
|
|
561
|
+
out="$(CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
|
|
562
|
+
[ ! -f /tmp/multiacc_PWNED ] && t_ok "sync rejects injected server_root (no command executed)" \
|
|
563
|
+
|| { t_fail "command injection" "server_root injection EXECUTED"; rm -f /tmp/multiacc_PWNED; }
|
|
564
|
+
check "injected server_root refused" "not a plain absolute path" "$out"
|
|
565
|
+
cp "$WORK/manifest.bak" "$ACC/accounts.json"
|
|
566
|
+
|
|
567
|
+
# accountless manifest must not blank the server pool
|
|
568
|
+
python3 - "$ACC/accounts.json" <<'EOF'
|
|
569
|
+
import json, os, sys
|
|
570
|
+
d = json.load(open(sys.argv[1])); d['accounts'] = []
|
|
571
|
+
json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
|
|
572
|
+
EOF
|
|
573
|
+
out="$(CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
|
|
574
|
+
rc=$?
|
|
575
|
+
check "empty manifest refuses to sync" "refusing to blank the server pool" "$out"
|
|
576
|
+
[ "$rc" != "0" ] && t_ok "empty-manifest sync exits nonzero" || t_fail "empty sync rc" "rc=0"
|
|
577
|
+
cp "$WORK/manifest.bak" "$ACC/accounts.json"
|
|
578
|
+
else
|
|
579
|
+
t_ok "sync validation tests skipped (Mac-only feature; server refuses sync by design)"
|
|
580
|
+
fi
|
|
581
|
+
|
|
582
|
+
# API keys are never accepted as credentials (subscription-only requirement)
|
|
583
|
+
printf 'sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' > "$WORK/apikey.txt"
|
|
584
|
+
out="$(claude-accounts import apikey@test --id acct-11 --token-file "$WORK/apikey.txt" --no-sync 2>&1)"
|
|
585
|
+
rc=$?
|
|
586
|
+
check "import rejects an API key as token" "not a subscription setup-token" "$out"
|
|
587
|
+
[ ! -d "$ACC/acct-11" ] && t_ok "API-key import created nothing" || t_fail "apikey import" "acct-11 created"
|
|
588
|
+
out="$(printf 'sk-ant-api03-ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ' | claude-accounts mint acct-01 --paste 2>&1)"
|
|
589
|
+
check "mint --paste rejects an API key" "not a subscription setup-token" "$out"
|
|
590
|
+
|
|
591
|
+
out="$(claude-accounts verify --quick 2>&1)"
|
|
592
|
+
check "verify --quick passes oauth accounts" "acct-01 a@test: OK" "$out"
|
|
593
|
+
check "verify --quick counts" "0 failure(s)" "$out"
|
|
594
|
+
|
|
595
|
+
# ---- 18. CLI: status renders ------------------------------------------------------
|
|
596
|
+
out="$(claude-accounts status 2>&1)"
|
|
597
|
+
check "status shows threshold" "90%" "$out"
|
|
598
|
+
check "status shows account" "a@test" "$out"
|
|
599
|
+
|
|
600
|
+
# ---- 19. npm layer: cli.mjs dispatch + self-update + postinstall guards -------------
|
|
601
|
+
if command -v node >/dev/null 2>&1; then
|
|
602
|
+
CLI="$REPO_DIR/bin/cli.mjs"
|
|
603
|
+
pkgver="$(node -e "console.log(require('$REPO_DIR/package.json').version)")"
|
|
604
|
+
out="$(node "$CLI" --version 2>&1)"
|
|
605
|
+
check "cli --version matches package.json" "$pkgver" "$out"
|
|
606
|
+
out="$(node "$CLI" --help 2>&1)"
|
|
607
|
+
check "cli --help documents install" "install or update the addon" "$out"
|
|
608
|
+
# passthrough to claude-accounts
|
|
609
|
+
out="$(node "$CLI" list 2>&1)"
|
|
610
|
+
check "cli passes through to claude-accounts (list)" "acct-01" "$out"
|
|
611
|
+
out="$(node "$CLI" status 2>&1)"
|
|
612
|
+
check "cli doctor/status passthrough" "threshold" "$out"
|
|
613
|
+
# self-update on a non-npm, non-git tree is a logged no-op (never errors)
|
|
614
|
+
out="$(claude-accounts self-update 2>&1)"
|
|
615
|
+
rc=$?
|
|
616
|
+
[ "$rc" = "0" ] && t_ok "self-update no-op exits 0 on a plain checkout" || t_fail "self-update rc" "rc=$rc"
|
|
617
|
+
case "$out" in *"update manually"*|*"already latest"*|*"git pull"*) t_ok "self-update reports its path" ;;
|
|
618
|
+
*) t_fail "self-update message" "unexpected: $out" ;; esac
|
|
619
|
+
# postinstall must SKIP for a non-global install and never fail
|
|
620
|
+
out="$(node "$REPO_DIR/scripts/postinstall.mjs" 2>&1)"
|
|
621
|
+
rc=$?
|
|
622
|
+
{ [ "$rc" = "0" ] && printf '%s' "$out" | grep -q "skipping auto-setup"; } \
|
|
623
|
+
&& t_ok "postinstall skips (and exits 0) for a non-global install" \
|
|
624
|
+
|| t_fail "postinstall guard" "rc=$rc out=$out"
|
|
625
|
+
out="$(CI=1 npm_config_global=true node "$REPO_DIR/scripts/postinstall.mjs" 2>&1)"
|
|
626
|
+
printf '%s' "$out" | grep -q "CI environment" \
|
|
627
|
+
&& t_ok "postinstall skips under CI even when global" || t_fail "postinstall CI guard" "$out"
|
|
628
|
+
# package.json is valid and ships the essential files list
|
|
629
|
+
node -e "
|
|
630
|
+
const p=require('$REPO_DIR/package.json');
|
|
631
|
+
const need=['bin/','lib/','install.sh','scripts/postinstall.mjs'];
|
|
632
|
+
if(!/^(\.\/)?bin\/cli\.mjs$/.test(p.bin['claude-multiacc'])) { console.error('bad bin'); process.exit(1); }
|
|
633
|
+
for(const f of need) if(!p.files.includes(f)) { console.error('missing file entry: '+f); process.exit(1); }
|
|
634
|
+
if(p.scripts.postinstall!=='node scripts/postinstall.mjs'){ console.error('bad postinstall'); process.exit(1); }
|
|
635
|
+
" && t_ok "package.json bin/files/postinstall wired correctly" || t_fail "package.json" "see errors above"
|
|
636
|
+
else
|
|
637
|
+
t_ok "npm-layer tests skipped (node not installed)"
|
|
638
|
+
fi
|
|
639
|
+
|
|
640
|
+
# ---- summary ---------------------------------------------------------------------
|
|
641
|
+
echo
|
|
642
|
+
echo "passed: $PASS failed: $FAIL"
|
|
643
|
+
[ "$FAIL" = "0" ] || exit 1
|