claude-multiacc 2.0.42 → 2.0.44
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 +17 -1
- package/bin/claude +78 -18
- package/bin/claude-accounts +112 -22
- package/docs/ACCOUNT_OPERATIONS.md +5 -0
- package/docs/claude-token-identity.md +147 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.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__/mcp_registry.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/audit.py +10 -0
- package/lib/claude_token_identity.py +233 -0
- package/lib/common.sh +27 -2
- package/lib/credential.py +9 -2
- package/lib/keychain.py +274 -59
- package/package.json +1 -1
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.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/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/fake_security.sh +90 -0
- package/tests/run-tests.sh +254 -66
- package/tests/test_claude_token_identity.py +259 -0
- package/tests/test_keychain.py +323 -50
- package/tests/test_token_identity_ceremony.py +167 -0
- package/tests/test_token_identity_integration.py +207 -0
- package/tests/test_token_identity_retry.py +95 -0
- package/tests/test_token_identity_sync.py +139 -0
package/README.md
CHANGED
|
@@ -126,7 +126,7 @@ later.
|
|
|
126
126
|
**Unusable accounts are excluded, not rotated into.** An account that cannot serve a
|
|
127
127
|
request is dropped from selection before anything else is considered — it is not eligible
|
|
128
128
|
as the all-limited fallback either, because a limit-marked account is *degraded* while
|
|
129
|
-
this one is *down*, and handing work to it guarantees a hard failure.
|
|
129
|
+
this one is *down*, and handing work to it guarantees a hard failure. Reported states include:
|
|
130
130
|
|
|
131
131
|
- **Dead login** (`EXPIRED`) — refresh token expired, grant revoked, or a real call that
|
|
132
132
|
came back *"OAuth session expired and could not be refreshed"*. Fixed by
|
|
@@ -135,6 +135,11 @@ this one is *down*, and handing work to it guarantees a hard failure. Two kinds:
|
|
|
135
135
|
prove inference works. Before a portable token carries its first real command on a
|
|
136
136
|
machine, the shim privately verifies it. A 401 parks that exact account and reselects,
|
|
137
137
|
so direct, TUI, and `--resume` calls do not expose the rejected token's failure.
|
|
138
|
+
- **Wrong token organization** (`TOKEN INVALID`) — an authenticated API response identifies
|
|
139
|
+
a different organization from the slot's saved OAuth profile. The shim excludes that
|
|
140
|
+
portable token, including on retries; a healthy local OAuth login remains usable.
|
|
141
|
+
Replace it with `claude-accounts login acct-NN --token` and sync. See
|
|
142
|
+
[token identity checks and recovery](docs/claude-token-identity.md).
|
|
138
143
|
- **Org-blocked** (`BLOCKED`) — the account authenticates fine, but the run comes back
|
|
139
144
|
*"Your organization has disabled Claude subscription access for Claude Code"*. Handled
|
|
140
145
|
like any other dead login (`claude-accounts relogin` re-issues the grant and normally
|
|
@@ -274,6 +279,17 @@ account's `.credentials.json` (0600), or its macOS Keychain item.
|
|
|
274
279
|
> login made over ssh migrates into the Keychain the first time a GUI-session process
|
|
275
280
|
> refreshes its token — the pool reads both places (`lib/keychain.py`), and an account
|
|
276
281
|
> that must work from everywhere should carry a portable token (`claude-accounts mint`).
|
|
282
|
+
> The item's **account name** is the client's `$USER` — and a client started without USER
|
|
283
|
+
> files it under `unknown`, beside the older item, where the next client never looks. So
|
|
284
|
+
> `claude-accounts`/`codex-accounts` and the shim fill `USER`/`LOGNAME` from `id -un` when
|
|
285
|
+
> the caller's env has none, and the pool judges only the item under that canonical name —
|
|
286
|
+
> the one the client reads (one `security` call per lookup). A login found only under
|
|
287
|
+
> another name counts nowhere until the scheduled limits pass moves it under the canonical
|
|
288
|
+
> name. A token refresh writes back under the canonical name and drops the siblings it
|
|
289
|
+
> made stale (the same grant, a dead one) but keeps another live grant; a sign-in whose
|
|
290
|
+
> identity checked out prunes every sibling; `remove` deletes every item.
|
|
291
|
+
> `python3 lib/keychain.py prune <acct dir>` prunes by hand (only when the canonical item
|
|
292
|
+
> is a live login; it never writes).
|
|
277
293
|
> Override: `CLAUDE_MULTIACC_KEYCHAIN=0` disables the lookup. This is what keeps idle accounts' telemetry fresh so they win
|
|
278
294
|
selection over busy accounts; without it, stale telemetry ranks neutral and a truly-idle
|
|
279
295
|
account would lose to a busy-but-fresh one. Refresh failures fail open and back off via
|
package/bin/claude
CHANGED
|
@@ -12,6 +12,17 @@
|
|
|
12
12
|
|
|
13
13
|
set -u
|
|
14
14
|
|
|
15
|
+
# Claude Code names its macOS Keychain item after $USER, and under Bun a client with no
|
|
16
|
+
# USER calls the user "unknown" — so a caller whose env lacks it (app-robot's panel
|
|
17
|
+
# ceremony, 2026-09-24) gets a login filed under a name the next client never reads
|
|
18
|
+
# (lib/keychain.py). Every client this shim starts, passthrough included, inherits a
|
|
19
|
+
# real name; lib/common.sh does the same for claude-accounts.
|
|
20
|
+
if [ -z "${USER:-}" ]; then
|
|
21
|
+
USER="$(id -un 2>/dev/null || true)"
|
|
22
|
+
if [ -n "$USER" ]; then export USER; else unset USER; fi
|
|
23
|
+
fi
|
|
24
|
+
if [ -z "${LOGNAME:-}" ] && [ -n "${USER:-}" ]; then export LOGNAME="$USER"; fi
|
|
25
|
+
|
|
15
26
|
# ${HOME:-} guards: with HOME stripped (env -i, some cron/systemd units) the shim
|
|
16
27
|
# must still fail OPEN into plain passthrough, never abort on an unbound variable.
|
|
17
28
|
# CLAUDE_ACCOUNTS_ROOT scopes the pool to one app-robot instance; CLAUDE_ACCOUNTS_DIR
|
|
@@ -177,7 +188,8 @@ mcp_nested_mirror() {
|
|
|
177
188
|
}
|
|
178
189
|
|
|
179
190
|
# Fast passthrough: caller pinned a config dir or token, addon disabled, recursion
|
|
180
|
-
# guard, or no account data yet. Byte-identical behavior to stock claude
|
|
191
|
+
# guard, or no account data yet. Byte-identical behavior to stock claude (a USER the
|
|
192
|
+
# caller left out is filled at the top — see there).
|
|
181
193
|
if [ -n "${CLAUDE_CONFIG_DIR:-}" ] || [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] \
|
|
182
194
|
|| [ "${CLAUDE_MULTIACC_DISABLE:-0}" = "1" ] || [ -n "${CLAUDE_SHIM_ACTIVE:-}" ] \
|
|
183
195
|
|| [ ! -f "$MANIFEST" ]; then
|
|
@@ -216,6 +228,11 @@ case "${CLAUDE_MULTIACC_KEYCHAIN:-}" in
|
|
|
216
228
|
*) if [ "$(uname -s)" = "Darwin" ]; then KC_ON=1; else KC_ON=0; fi ;;
|
|
217
229
|
esac
|
|
218
230
|
if [ "$KC_ON" = "1" ] && ! command -v security >/dev/null 2>&1; then KC_ON=0; fi
|
|
231
|
+
# The client reads ONLY the item under its own account name — $USER (filled above),
|
|
232
|
+
# anything outside [A-Za-z0-9._-] becoming "claude-code-user" — so that one item is what
|
|
233
|
+
# this shim judges: a sibling under another name is invisible to the client it launches.
|
|
234
|
+
KC_ACCOUNT="${USER:-}"
|
|
235
|
+
case "$KC_ACCOUNT" in ''|*[!A-Za-z0-9._-]*) KC_ACCOUNT=claude-code-user ;; esac
|
|
219
236
|
kc_service() { # $1 = acct dir -> the keychain service name the client uses for it
|
|
220
237
|
local h=""
|
|
221
238
|
if command -v shasum >/dev/null 2>&1; then h="$(printf '%s' "$1" | shasum -a 256 2>/dev/null)"
|
|
@@ -230,7 +247,7 @@ kc_lookup() { # $1 = acct dir; sets KC_JSON; rc 0 readable, 1 none, 2 exists but
|
|
|
230
247
|
[ "$KC_ON" = "1" ] || { KC_JSON=""; return 1; }
|
|
231
248
|
if [ "$KC_MEMO_DIR" = "$1" ]; then return "$KC_MEMO_RC"; fi
|
|
232
249
|
local out rc
|
|
233
|
-
out="$(security find-generic-password -s "$(kc_service "$1")" -w 2>/dev/null)"; rc=$?
|
|
250
|
+
out="$(security find-generic-password -a "$KC_ACCOUNT" -s "$(kc_service "$1")" -w 2>/dev/null)"; rc=$?
|
|
234
251
|
KC_MEMO_DIR="$1"; KC_JSON=""; KC_MEMO_RC=1
|
|
235
252
|
case "$rc" in
|
|
236
253
|
0) case "$out" in *claudeAiOauth*) KC_JSON="$out"; KC_MEMO_RC=0 ;; esac ;;
|
|
@@ -242,7 +259,7 @@ kc_mtime() { # $1 = acct dir -> epoch of the item's last write (0 when none); at
|
|
|
242
259
|
# are readable even when the secret is locked, so a marker still self-heals
|
|
243
260
|
[ "$KC_ON" = "1" ] || { echo 0; return 0; }
|
|
244
261
|
local stamp
|
|
245
|
-
stamp="$(security find-generic-password -s "$(kc_service "$1")" 2>/dev/null \
|
|
262
|
+
stamp="$(security find-generic-password -a "$KC_ACCOUNT" -s "$(kc_service "$1")" 2>/dev/null \
|
|
246
263
|
| LC_ALL=C sed -n 's/.*"mdat"<timedate>=0x[0-9A-Fa-f]* *"\([0-9]\{14\}\)Z.*/\1/p' | head -1)"
|
|
247
264
|
case "$stamp" in
|
|
248
265
|
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9])
|
|
@@ -1629,6 +1646,12 @@ expired_marked() { # $1 = acct dir
|
|
|
1629
1646
|
# OAuth credential beside it is dead — the token is exported instead.
|
|
1630
1647
|
auth_dead() { # $1 = acct dir
|
|
1631
1648
|
expired_marked "$1" && return 0
|
|
1649
|
+
if [ -s "$1/server.token" ] && { ! has_oauth "$1" || creds_dead "$1"; }; then
|
|
1650
|
+
case ":${CLAUDE_MULTIACC_PREFLIGHT_SKIP:-}:" in *":${1##*/}:"*) return 0 ;; esac
|
|
1651
|
+
if [ -f "$1/.server-token-identity.json" ]; then
|
|
1652
|
+
[ "$(token_identity_state cached "$1")" = mismatch ] && return 0
|
|
1653
|
+
fi
|
|
1654
|
+
fi
|
|
1632
1655
|
[ -s "$1/server.token" ] && return 1
|
|
1633
1656
|
creds_dead "$1"
|
|
1634
1657
|
}
|
|
@@ -1641,10 +1664,21 @@ acct_token() { # $1 = acct dir; prints token if the dir must authenticate by tok
|
|
|
1641
1664
|
fi
|
|
1642
1665
|
}
|
|
1643
1666
|
|
|
1644
|
-
#
|
|
1645
|
-
#
|
|
1646
|
-
#
|
|
1647
|
-
|
|
1667
|
+
# auth status cannot identify a setup-token, but authenticated API response headers
|
|
1668
|
+
# name its organization, even on a quota rejection. Check that binding independently
|
|
1669
|
+
# of the old inference proof: a token can answer OK while spending another account.
|
|
1670
|
+
token_identity_state() { # $1 check|cached, $2 account dir, $3 exact token for check
|
|
1671
|
+
local helper="$SELF_DIR/../lib/claude_token_identity.py" py="${CLAUDE_MULTIACC_PYTHON:-python3}"
|
|
1672
|
+
[ -r "$helper" ] || return 0
|
|
1673
|
+
if [ "$1" = check ]; then
|
|
1674
|
+
printf '%s' "${3:-}" | "$py" "$helper" check "$2" --token-stdin --real "$REAL" --format state 2>/dev/null
|
|
1675
|
+
else
|
|
1676
|
+
"$py" "$helper" cached "$2" --format state 2>/dev/null
|
|
1677
|
+
fi
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
# Test authentication independently; organization matching cannot prove a token is
|
|
1681
|
+
# still valid. The token and identity receipts are both bound to its exact digest.
|
|
1648
1682
|
token_digest() { # $1 token; prints a non-secret sha256 digest
|
|
1649
1683
|
local h=""
|
|
1650
1684
|
if command -v shasum >/dev/null 2>&1; then
|
|
@@ -1655,8 +1689,16 @@ token_digest() { # $1 token; prints a non-secret sha256 digest
|
|
|
1655
1689
|
printf '%s' "$h" | cut -d ' ' -f1
|
|
1656
1690
|
}
|
|
1657
1691
|
|
|
1658
|
-
|
|
1692
|
+
TOKEN_PREFLIGHT_FAILURE=""
|
|
1693
|
+
token_preflight() { # $1 acct dir, $2 setup-token; rc 1 only for proven unusable auth
|
|
1659
1694
|
local d="$1" tok="$2" digest marker tmpd token_auth_pat rc=0
|
|
1695
|
+
TOKEN_PREFLIGHT_FAILURE="invalid portable OAuth token (401)"
|
|
1696
|
+
if [ "$(token_identity_state check "$d" "$tok")" = mismatch ]; then
|
|
1697
|
+
TOKEN_PREFLIGHT_FAILURE="portable setup-token for a different organization; run: "
|
|
1698
|
+
TOKEN_PREFLIGHT_FAILURE="${TOKEN_PREFLIGHT_FAILURE}claude-accounts login ${d##*/} --token"
|
|
1699
|
+
sel_log "${d##*/} skipped (setup-token identity mismatch)"
|
|
1700
|
+
return 1
|
|
1701
|
+
fi
|
|
1660
1702
|
digest="$(token_digest "$tok")"
|
|
1661
1703
|
[ -n "$digest" ] || return 0
|
|
1662
1704
|
marker="$d/.server-token-verified"
|
|
@@ -1706,8 +1748,8 @@ if [ -n "${CLAUDE_ACCOUNT:-}" ]; then
|
|
|
1706
1748
|
tok="$(acct_token "$d")"
|
|
1707
1749
|
if [ -n "$tok" ]; then
|
|
1708
1750
|
if ! token_preflight "$d" "$tok"; then
|
|
1709
|
-
printf 'claude-multiacc: pinned account %s has
|
|
1710
|
-
"$CLAUDE_ACCOUNT" >&2
|
|
1751
|
+
printf 'claude-multiacc: pinned account %s has a %s\n' \
|
|
1752
|
+
"$CLAUDE_ACCOUNT" "$TOKEN_PREFLIGHT_FAILURE" >&2
|
|
1711
1753
|
exit 1
|
|
1712
1754
|
fi
|
|
1713
1755
|
export CLAUDE_CODE_OAUTH_TOKEN="$tok"
|
|
@@ -2041,13 +2083,22 @@ if [ -n "$tok" ] && ! token_preflight "$pick" "$tok"; then
|
|
|
2041
2083
|
num_ok "$preflight_depth" || preflight_depth=0
|
|
2042
2084
|
if [ "$preflight_depth" -lt 64 ]; then
|
|
2043
2085
|
export CLAUDE_MULTIACC_PREFLIGHT_DEPTH=$((preflight_depth + 1))
|
|
2086
|
+
# Persistence can fail on a read-only pool. Exclude this token for the re-entry
|
|
2087
|
+
# anyway, and never fall through into a token the preflight just rejected.
|
|
2088
|
+
preflight_skip="${CLAUDE_MULTIACC_PREFLIGHT_SKIP:+$CLAUDE_MULTIACC_PREFLIGHT_SKIP:}"
|
|
2089
|
+
export CLAUDE_MULTIACC_PREFLIGHT_SKIP="${preflight_skip}${pick##*/}"
|
|
2044
2090
|
# An auto-resume chain survives the re-entry (see ---- auto-resume ----). '|' is
|
|
2045
2091
|
# outside every chain field's charset, so it separates them unambiguously.
|
|
2046
2092
|
[ -n "$AR_CHAIN" ] && export CLAUDE_MULTIACC_AR_CARRY="$AR_DEPTH|$AR_AVOID|$AR_HIST|$AR_CHAIN"
|
|
2047
2093
|
exec "$SELF" "$@"
|
|
2048
2094
|
fi
|
|
2095
|
+
printf 'claude-multiacc: portable token preflight retry limit reached\n' >&2
|
|
2096
|
+
exit 1
|
|
2049
2097
|
fi
|
|
2050
|
-
unset CLAUDE_MULTIACC_PREFLIGHT_DEPTH
|
|
2098
|
+
unset CLAUDE_MULTIACC_PREFLIGHT_DEPTH CLAUDE_MULTIACC_PREFLIGHT_SKIP
|
|
2099
|
+
# Use the exact token that passed preflight. A concurrent mint/sync may replace the
|
|
2100
|
+
# file before exec; rereading it here would run an unchecked credential.
|
|
2101
|
+
PICK_TOKEN="$tok"
|
|
2051
2102
|
|
|
2052
2103
|
# The picked account may be eligible only because its exhausted bucket belongs to a
|
|
2053
2104
|
# model this run did not ask for. Handing it the default model anyway would open the
|
|
@@ -2148,7 +2199,7 @@ export CLAUDE_SHIM_ACTIVE=1
|
|
|
2148
2199
|
# mirrored to every other one (exits inside); anything else falls through to exec.
|
|
2149
2200
|
if [ "${1:-}" = "mcp" ] || { [ "${1:-}" = "--model" ] && [ "${3:-}" = "mcp" ]; }; then
|
|
2150
2201
|
export CLAUDE_CONFIG_DIR="$pick"
|
|
2151
|
-
tok="$
|
|
2202
|
+
tok="$PICK_TOKEN"
|
|
2152
2203
|
[ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
|
|
2153
2204
|
mcp_mirror_run "$pick" "$@" || true
|
|
2154
2205
|
fi
|
|
@@ -2180,7 +2231,7 @@ fi
|
|
|
2180
2231
|
|
|
2181
2232
|
if [ "$wants_retry" = "0" ]; then
|
|
2182
2233
|
export CLAUDE_CONFIG_DIR="$pick"
|
|
2183
|
-
tok="$
|
|
2234
|
+
tok="$PICK_TOKEN"
|
|
2184
2235
|
[ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
|
|
2185
2236
|
sel_capture_session "$pick"
|
|
2186
2237
|
ar_trust "$pick"
|
|
@@ -2192,7 +2243,7 @@ fi
|
|
|
2192
2243
|
mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
|
|
2193
2244
|
tmpd="$(mktemp -d "$ACC_ROOT/tmp/shim.XXXXXX" 2>/dev/null)" || {
|
|
2194
2245
|
export CLAUDE_CONFIG_DIR="$pick"
|
|
2195
|
-
tok="$
|
|
2246
|
+
tok="$PICK_TOKEN"
|
|
2196
2247
|
[ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
|
|
2197
2248
|
sel_capture_session "$pick"
|
|
2198
2249
|
exec "$REAL" "$@"
|
|
@@ -2204,7 +2255,7 @@ trap 'rm -rf "$tmpd"' EXIT
|
|
|
2204
2255
|
# nonzero — a hard failure. Verify now, fall back to plain exec if we cannot.
|
|
2205
2256
|
if ! : > "$tmpd/out" 2>/dev/null || ! : > "$tmpd/err" 2>/dev/null; then
|
|
2206
2257
|
export CLAUDE_CONFIG_DIR="$pick"
|
|
2207
|
-
tok="$
|
|
2258
|
+
tok="$PICK_TOKEN"
|
|
2208
2259
|
[ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
|
|
2209
2260
|
sel_capture_session "$pick"
|
|
2210
2261
|
exec "$REAL" "$@"
|
|
@@ -2289,6 +2340,7 @@ stream_reported_error() {
|
|
|
2289
2340
|
|
|
2290
2341
|
attempt=1
|
|
2291
2342
|
cur="$pick"
|
|
2343
|
+
cur_token="$PICK_TOKEN"
|
|
2292
2344
|
rc=0
|
|
2293
2345
|
stream_err=0 # the run exited 0 but the stream said otherwise
|
|
2294
2346
|
rotated=0 # at most one account rotation, exactly as before
|
|
@@ -2298,7 +2350,7 @@ while :; do
|
|
|
2298
2350
|
# Per ATTEMPT: a stream error seen on an earlier account must never decide the
|
|
2299
2351
|
# exit status of a later one that failed for its own, real reason.
|
|
2300
2352
|
stream_err=0
|
|
2301
|
-
tok="$
|
|
2353
|
+
tok="$cur_token"
|
|
2302
2354
|
if [ -n "$stdin_file" ]; then exec 3< "$stdin_file"; else exec 3< /dev/null; fi
|
|
2303
2355
|
if [ -n "$tok" ]; then
|
|
2304
2356
|
CLAUDE_CONFIG_DIR="$cur" CLAUDE_CODE_OAUTH_TOKEN="$tok" "$REAL" "${ARGV[@]}" <&3 > "$tmpd/out" 2> "$tmpd/err"
|
|
@@ -2374,14 +2426,22 @@ while :; do
|
|
|
2374
2426
|
n="${#eligible[@]}"
|
|
2375
2427
|
start=$((RANDOM % n))
|
|
2376
2428
|
i=0
|
|
2377
|
-
while [ "$i" -lt "$n" ]; do
|
|
2429
|
+
while [ "$rotated" = 0 ] && [ "$i" -lt "$n" ]; do
|
|
2378
2430
|
c="${eligible[$(((start + i) % n))]}"
|
|
2379
|
-
if [ "$c" != "$cur" ]
|
|
2431
|
+
if [ "$c" != "$cur" ] && ! auth_dead "$c"; then
|
|
2432
|
+
candidate_token="$(acct_token "$c")"
|
|
2433
|
+
if [ -z "$candidate_token" ] || token_preflight "$c" "$candidate_token"; then
|
|
2434
|
+
next="$c"
|
|
2435
|
+
next_token="$candidate_token"
|
|
2436
|
+
break
|
|
2437
|
+
fi
|
|
2438
|
+
fi
|
|
2380
2439
|
i=$((i+1))
|
|
2381
2440
|
done
|
|
2382
2441
|
if [ -n "$next" ] && [ "$rotated" = 0 ]; then
|
|
2383
2442
|
sel_log "retry from=$(basename "$cur") to=$(basename "$next") rc=$rc"
|
|
2384
2443
|
cur="$next"
|
|
2444
|
+
cur_token="$next_token"
|
|
2385
2445
|
# The account that actually serves the work is the one the next run should rotate
|
|
2386
2446
|
# away from — not the one that bounced.
|
|
2387
2447
|
remember_pick "$cur"
|
package/bin/claude-accounts
CHANGED
|
@@ -635,7 +635,7 @@ cmd_add() {
|
|
|
635
635
|
# Portable setup-token variant (works on Mac AND server; needs a recent sign-in).
|
|
636
636
|
echo "Preparing $id for $elabel (portable token) — NOTHING is registered until sign-in completes."
|
|
637
637
|
run_token_ceremony "$d" || die "sign-in failed or aborted — nothing was created${CEREMONY_LAST_WORDS:+ — the client said: $CEREMONY_LAST_WORDS}${CEREMONY_TRANSCRIPT:+ (transcript: $CEREMONY_TRANSCRIPT)}"
|
|
638
|
-
commit_ceremony_token "$d" "$CEREMONY_TOKEN" "$id"
|
|
638
|
+
commit_ceremony_token "$d" "$CEREMONY_TOKEN" "$id" captured "$email"
|
|
639
639
|
got="$(token_email "$CEREMONY_TOKEN")"
|
|
640
640
|
else
|
|
641
641
|
# Default: full Claude Code login (full scopes, no long-lived-token step-up).
|
|
@@ -678,6 +678,8 @@ cmd_add() {
|
|
|
678
678
|
mutate_unlock
|
|
679
679
|
RESERVED_DIR="" # committed — the trap must not delete it now
|
|
680
680
|
trap - EXIT INT TERM
|
|
681
|
+
# Registered under the identity it read back: leave one Keychain item for the dir.
|
|
682
|
+
[ "$token" = "1" ] || keychain_prune "$d"
|
|
681
683
|
# Only the full-login path actually READ the identity back. Saying "verified" for a
|
|
682
684
|
# setup token would relaunder the very assumption this flow just warned about.
|
|
683
685
|
local verdict="sign-in verified"
|
|
@@ -747,6 +749,12 @@ cmd_import() {
|
|
|
747
749
|
fi
|
|
748
750
|
local d="$ACC_ROOT/$id"
|
|
749
751
|
seed_account_dir "$d"
|
|
752
|
+
if [ -n "$tok" ]; then
|
|
753
|
+
ceremony_token_identity "$d" "$tok" "$email"
|
|
754
|
+
if [ "$CEREMONY_IDENTITY_STATE" = "mismatch" ]; then
|
|
755
|
+
die "the token's organization does not match $id — nothing imported; mint a token for the named account"
|
|
756
|
+
fi
|
|
757
|
+
fi
|
|
750
758
|
if [ -n "$creds" ]; then
|
|
751
759
|
case "$mode" in
|
|
752
760
|
copy) cp "$creds" "$d/.credentials.json" ;;
|
|
@@ -784,9 +792,10 @@ def has_auth(aid):
|
|
|
784
792
|
if (os.path.isfile(c) and os.path.getsize(c) > 0) or (os.path.isfile(t) and os.path.getsize(t) > 0):
|
|
785
793
|
return True
|
|
786
794
|
# A login the client moved into the macOS Keychain still counts — deduping must
|
|
787
|
-
# not throw away the one duplicate that actually holds the grant
|
|
795
|
+
# not throw away the one duplicate that actually holds the grant, even one filed
|
|
796
|
+
# under a name its client no longer reads (the limits pass moves those back).
|
|
788
797
|
try:
|
|
789
|
-
return keychain.probe(d)['state'] in ('present', 'locked', 'corrupt')
|
|
798
|
+
return keychain.probe(d, legacy=True)['state'] in ('present', 'locked', 'corrupt')
|
|
790
799
|
except Exception:
|
|
791
800
|
return False
|
|
792
801
|
by_email = {}
|
|
@@ -1030,11 +1039,27 @@ ceremony_token_check() { # $1 = token
|
|
|
1030
1039
|
esac
|
|
1031
1040
|
}
|
|
1032
1041
|
|
|
1033
|
-
#
|
|
1034
|
-
|
|
1042
|
+
# A candidate must not replace the current token's identity receipt until it is saved.
|
|
1043
|
+
CEREMONY_IDENTITY_STATE="unknown"
|
|
1044
|
+
ceremony_token_identity() { # $1 = account dir, $2 = candidate token, $3 = optional named email
|
|
1045
|
+
local d="$1" tok="$2" email="${3:-}" real
|
|
1046
|
+
real="$(find_real_claude "$_self")" || real=""
|
|
1047
|
+
set -- check "$d" --token-stdin --real "$real" --format state --no-store
|
|
1048
|
+
[ -z "$email" ] || set -- "$@" --expected-email "$email"
|
|
1049
|
+
CEREMONY_IDENTITY_STATE="$(printf '%s' "$tok" \
|
|
1050
|
+
| "$PYBIN" "$LIB_DIR/claude_token_identity.py" "$@" 2>/dev/null)"
|
|
1051
|
+
[ -n "$CEREMONY_IDENTITY_STATE" ] || CEREMONY_IDENTITY_STATE="unknown"
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
# The ONE place a ceremony's token is written: only after organization and auth checks,
|
|
1055
|
+
# so a known wrong-organization or rejected token is never saved or distributed. $1 = acct dir,
|
|
1035
1056
|
# $2 = token, $3 = acct id (messages only).
|
|
1036
1057
|
commit_ceremony_token() {
|
|
1037
1058
|
local d="$1" tok="$2" id="$3" src="${4:-captured}" rc=0
|
|
1059
|
+
ceremony_token_identity "$d" "$tok" "${5:-}"
|
|
1060
|
+
if [ "$CEREMONY_IDENTITY_STATE" = "mismatch" ]; then
|
|
1061
|
+
die "the token's organization does not match $id — nothing saved; sign in as the named account and mint again"
|
|
1062
|
+
fi
|
|
1038
1063
|
ceremony_token_check "$tok" || rc=$?
|
|
1039
1064
|
# A short token is an incomplete one unless Claude itself has just answered with it:
|
|
1040
1065
|
# the length alone convicts it, so an inconclusive probe (429, network) must not let
|
|
@@ -1052,6 +1077,7 @@ commit_ceremony_token() {
|
|
|
1052
1077
|
fi
|
|
1053
1078
|
( umask 077; printf '%s' "$tok" > "$d/server.token" )
|
|
1054
1079
|
chmod 600 "$d/server.token"
|
|
1080
|
+
[ "$CEREMONY_IDENTITY_STATE" != "match" ] || echo "Token organization matches the account's saved OAuth profile."
|
|
1055
1081
|
if [ "$rc" -eq 0 ]; then
|
|
1056
1082
|
record_token_verified "$d" "$tok"
|
|
1057
1083
|
echo "Token verified by a real inference."
|
|
@@ -1228,10 +1254,9 @@ TIP
|
|
|
1228
1254
|
|
|
1229
1255
|
cmd_mint() {
|
|
1230
1256
|
# The minted token is the ONLY credential that reaches the server and every peer, so a
|
|
1231
|
-
# mint under the wrong browser session
|
|
1232
|
-
#
|
|
1233
|
-
#
|
|
1234
|
-
# that exists; the old prompt said "THIS account" and named nobody.
|
|
1257
|
+
# mint under the wrong browser session can hand this slot another subscription. Name the
|
|
1258
|
+
# expected account before the ceremony and verify the token's response organization
|
|
1259
|
+
# against its saved OAuth profile before accepting it. The token cannot report an email.
|
|
1235
1260
|
require_manifest
|
|
1236
1261
|
local id="" paste=0
|
|
1237
1262
|
while [ $# -gt 0 ]; do
|
|
@@ -1276,7 +1301,9 @@ cmd_mint() {
|
|
|
1276
1301
|
clear_auth_markers "$d"
|
|
1277
1302
|
log_to ops.log "mint $id"
|
|
1278
1303
|
echo "Token saved to $d/server.token"
|
|
1279
|
-
[ -
|
|
1304
|
+
if [ -z "$got" ] && [ "$CEREMONY_IDENTITY_STATE" != "match" ]; then
|
|
1305
|
+
warn "the token's organization could not be checked — $id is trusted to hold ${email:-the named account}"
|
|
1306
|
+
fi
|
|
1280
1307
|
auto_sync
|
|
1281
1308
|
}
|
|
1282
1309
|
|
|
@@ -1311,7 +1338,9 @@ cmd_login() {
|
|
|
1311
1338
|
fi
|
|
1312
1339
|
commit_ceremony_token "$d" "$CEREMONY_TOKEN" "$id"
|
|
1313
1340
|
clear_auth_markers "$d"
|
|
1314
|
-
[ -
|
|
1341
|
+
if [ -z "$got" ] && [ "$CEREMONY_IDENTITY_STATE" != "match" ]; then
|
|
1342
|
+
warn "the token's organization could not be checked — $id is trusted to hold $email as requested"
|
|
1343
|
+
fi
|
|
1315
1344
|
echo "$id token saved (portable — works on Mac and server)."
|
|
1316
1345
|
else
|
|
1317
1346
|
run_login_ceremony "$d" "$email" \
|
|
@@ -1323,6 +1352,9 @@ cmd_login() {
|
|
|
1323
1352
|
if [ -z "$got" ] && [ "$force" != "1" ]; then
|
|
1324
1353
|
die "signed in, but the account identity could not be read back — refusing to call $id fixed (retry, or pass --force)"
|
|
1325
1354
|
fi
|
|
1355
|
+
# Only now, with the identity confirmed, may the stale Keychain items go: a sign-in
|
|
1356
|
+
# as the wrong account (died above) must leave every item as it was.
|
|
1357
|
+
keychain_prune "$d"
|
|
1326
1358
|
clear_auth_markers "$d" keep-token-park
|
|
1327
1359
|
if [ -s "$d/.credentials.json" ]; then
|
|
1328
1360
|
echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
|
|
@@ -1540,6 +1572,26 @@ os.execv(sys.argv[1], [sys.argv[1], "limits-distribute-now"])
|
|
|
1540
1572
|
return 0
|
|
1541
1573
|
}
|
|
1542
1574
|
|
|
1575
|
+
token_identities_check() {
|
|
1576
|
+
# A source may always use Keychain OAuth locally while its peers use setup tokens.
|
|
1577
|
+
# Prove those tokens before distributing telemetry, even if nobody launched them here.
|
|
1578
|
+
local id d real
|
|
1579
|
+
real="$(find_real_claude "$_self")" || real=""
|
|
1580
|
+
for id in $(account_ids); do
|
|
1581
|
+
d="$ACC_ROOT/$id"
|
|
1582
|
+
[ -s "$d/server.token" ] || continue
|
|
1583
|
+
"$PYBIN" "$LIB_DIR/claude_token_identity.py" check "$d" --real "$real" >/dev/null 2>&1 || true
|
|
1584
|
+
done
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
token_identity_receipt() { # Unknown results must never overwrite a peer's proven mismatch.
|
|
1588
|
+
local verdict
|
|
1589
|
+
[ -f "$1/.server-token-identity.json" ] || return 1
|
|
1590
|
+
verdict="$("$PYBIN" "$LIB_DIR/claude_token_identity.py" cached "$1" 2>/dev/null)"
|
|
1591
|
+
printf '%s' "$verdict" | grep -q '"cached": true' || return 1
|
|
1592
|
+
printf '%s' "$verdict" | grep -qE '"state": "(match|mismatch)"'
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1543
1595
|
limits_distribute_now() {
|
|
1544
1596
|
local lock="$ACC_ROOT/tmp/limits-push.lock"
|
|
1545
1597
|
mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || return 0
|
|
@@ -1564,6 +1616,7 @@ limits_distribute_now() {
|
|
|
1564
1616
|
list="$ACC_ROOT/tmp/limits-push.$$"
|
|
1565
1617
|
mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || return 0
|
|
1566
1618
|
: > "$list" 2>/dev/null || return 0
|
|
1619
|
+
token_identities_check
|
|
1567
1620
|
for id in $(account_ids); do
|
|
1568
1621
|
d="$ACC_ROOT/$id"
|
|
1569
1622
|
[ -f "$d/limits.json" ] && printf '%s/limits.json\n' "$id" >> "$list"
|
|
@@ -1571,6 +1624,7 @@ limits_distribute_now() {
|
|
|
1571
1624
|
# .limited on its own, so a stale one costs an account some eligibility and
|
|
1572
1625
|
# never grants any. Erring toward exclusion is the safe direction here.
|
|
1573
1626
|
[ -f "$d/.limited" ] && printf '%s/.limited\n' "$id" >> "$list"
|
|
1627
|
+
token_identity_receipt "$d" && printf '%s/.server-token-identity.json\n' "$id" >> "$list"
|
|
1574
1628
|
done
|
|
1575
1629
|
# The MCP registry rides along: a server the shim mirrored from a stock `claude mcp
|
|
1576
1630
|
# add` reaches every peer within one limits cadence, with no explicit sync. The
|
|
@@ -1859,36 +1913,49 @@ class FileStore:
|
|
|
1859
1913
|
|
|
1860
1914
|
class KeychainStore:
|
|
1861
1915
|
"""The same credential, held in the macOS Keychain by the client itself (see
|
|
1862
|
-
lib/keychain.py). Read
|
|
1863
|
-
|
|
1864
|
-
|
|
1916
|
+
lib/keychain.py). Read from the item the client reads (the canonical name; this
|
|
1917
|
+
pass alone also accepts the legacy first match, which oauth_store moves) and written
|
|
1918
|
+
back under the canonical name, which also drops the siblings holding the grant the
|
|
1919
|
+
refresh started from — NEVER copied out to a file: a file beside a Keychain item is
|
|
1920
|
+
a second copy of a ROTATING refresh grant, and the client reads the Keychain first,
|
|
1921
|
+
so the two would drift apart and strand one."""
|
|
1865
1922
|
kind = 'keychain'
|
|
1866
1923
|
|
|
1867
1924
|
def __init__(self, d, probe):
|
|
1868
1925
|
self.d = d
|
|
1869
|
-
self.
|
|
1926
|
+
self.grant = None
|
|
1927
|
+
self.moved = False
|
|
1870
1928
|
|
|
1871
1929
|
def read(self):
|
|
1872
|
-
p = keychain.probe(self.d)
|
|
1930
|
+
p = keychain.probe(self.d, legacy=True)
|
|
1873
1931
|
if p['state'] != 'present':
|
|
1874
1932
|
raise ValueError(f'keychain credential {p["state"]}')
|
|
1933
|
+
grant = p['doc']['claudeAiOauth'].get('refreshToken')
|
|
1934
|
+
self.grant = grant if isinstance(grant, str) else None
|
|
1875
1935
|
return p['doc']
|
|
1876
1936
|
|
|
1877
1937
|
def write(self, doc):
|
|
1878
|
-
if not keychain.write(self.d, doc,
|
|
1938
|
+
if not keychain.write(self.d, doc, supersedes=self.grant):
|
|
1879
1939
|
raise OSError('keychain write refused')
|
|
1880
1940
|
|
|
1881
1941
|
|
|
1882
1942
|
def oauth_store(d):
|
|
1883
1943
|
"""Where <d>'s OAuth login lives for THIS process: a FileStore, a KeychainStore,
|
|
1884
1944
|
the string 'locked' (Keychain item exists but this session cannot open it), or
|
|
1885
|
-
None. The file wins when both exist — same rule as lib/audit.oauth_login.
|
|
1945
|
+
None. The file wins when both exist — same rule as lib/audit.oauth_login.
|
|
1946
|
+
|
|
1947
|
+
A Keychain login found only under a name its client no longer reads (a USER-less
|
|
1948
|
+
sign-in's "unknown", lib/keychain.py) is moved under the canonical name here, once:
|
|
1949
|
+
until then the client, the shim and the audit all see no login."""
|
|
1886
1950
|
cpath = os.path.join(d, '.credentials.json')
|
|
1887
1951
|
if os.path.isfile(cpath):
|
|
1888
1952
|
return FileStore(cpath)
|
|
1889
|
-
p = keychain.probe(d)
|
|
1953
|
+
p = keychain.probe(d, legacy=True)
|
|
1890
1954
|
if p['state'] == 'present':
|
|
1891
|
-
|
|
1955
|
+
store = KeychainStore(d, p)
|
|
1956
|
+
if p['account'] != p['canonical']:
|
|
1957
|
+
store.moved = keychain.write(d, p['doc'])
|
|
1958
|
+
return store
|
|
1892
1959
|
if p['state'] == 'locked':
|
|
1893
1960
|
return 'locked'
|
|
1894
1961
|
return None
|
|
@@ -2096,6 +2163,9 @@ for acct in manifest.get('accounts', []):
|
|
|
2096
2163
|
except Exception as e:
|
|
2097
2164
|
say(f'{aid}: could not locate the oauth credential ({str(e)[:120]}); failing open')
|
|
2098
2165
|
store = None
|
|
2166
|
+
if getattr(store, 'moved', False):
|
|
2167
|
+
say(f'{aid}: Keychain login moved under {keychain.canonical_account()}, '
|
|
2168
|
+
f'the name its client reads')
|
|
2099
2169
|
locked = store == 'locked'
|
|
2100
2170
|
if locked:
|
|
2101
2171
|
store = None
|
|
@@ -2720,6 +2790,8 @@ root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
|
|
|
2720
2790
|
sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
|
|
2721
2791
|
from audit import audit_account, creds_state, creds_doc_state # noqa: E402 (shared with the shim's rule)
|
|
2722
2792
|
import keychain # noqa: E402
|
|
2793
|
+
from claude_token_identity import check_identity # noqa: E402
|
|
2794
|
+
from claude_reset import cli_version # noqa: E402
|
|
2723
2795
|
machine = sys.argv[5]
|
|
2724
2796
|
now = time.time()
|
|
2725
2797
|
manifest = json.load(open(os.path.join(root, 'accounts.json')))
|
|
@@ -2784,6 +2856,9 @@ for acct in manifest.get('accounts', []):
|
|
|
2784
2856
|
# The login may be in the macOS Keychain (lib/keychain.py). A Keychain this
|
|
2785
2857
|
# session cannot open is not a failure of the ACCOUNT: the real call below
|
|
2786
2858
|
# would fail for the session, not the grant, so it is a skip with a reason.
|
|
2859
|
+
# Only the canonical item counts — the one the client below reads; testing an
|
|
2860
|
+
# item under another name would run the client on a login it cannot see and
|
|
2861
|
+
# park an account its token serves.
|
|
2787
2862
|
kc = keychain.probe(d)
|
|
2788
2863
|
if kc['state'] == 'present':
|
|
2789
2864
|
has_creds, cred_doc = True, kc['doc']
|
|
@@ -2837,6 +2912,15 @@ for acct in manifest.get('accounts', []):
|
|
|
2837
2912
|
env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
|
|
2838
2913
|
t0 = time.time()
|
|
2839
2914
|
uses_token = 'CLAUDE_CODE_OAUTH_TOKEN' in env
|
|
2915
|
+
identity = {'state': 'unknown'}
|
|
2916
|
+
if has_token:
|
|
2917
|
+
portable = env['CLAUDE_CODE_OAUTH_TOKEN'] if uses_token else open(tpath).read().strip()
|
|
2918
|
+
identity = check_identity(d, portable, version=cli_version(real))
|
|
2919
|
+
if uses_token and identity['state'] == 'mismatch':
|
|
2920
|
+
print(f'{aid} {acct["email"]}: FAIL (portable setup-token belongs to a different '
|
|
2921
|
+
f'organization — run: claude-accounts login {aid} --token)')
|
|
2922
|
+
failures += 1
|
|
2923
|
+
continue
|
|
2840
2924
|
try:
|
|
2841
2925
|
r = subprocess.run([real, '-p', '--output-format', 'text', '--max-turns', '1'],
|
|
2842
2926
|
env=env, capture_output=True, text=True, timeout=240,
|
|
@@ -2864,7 +2948,7 @@ for acct in manifest.get('accounts', []):
|
|
|
2864
2948
|
pass
|
|
2865
2949
|
if uses_token:
|
|
2866
2950
|
try:
|
|
2867
|
-
digest = hashlib.sha256(
|
|
2951
|
+
digest = hashlib.sha256(env['CLAUDE_CODE_OAUTH_TOKEN'].encode()).hexdigest()
|
|
2868
2952
|
temp = os.path.join(d, '.server-token-verified.tmp')
|
|
2869
2953
|
with open(temp, 'w') as f:
|
|
2870
2954
|
f.write(digest + '\n')
|
|
@@ -2873,6 +2957,9 @@ for acct in manifest.get('accounts', []):
|
|
|
2873
2957
|
except OSError:
|
|
2874
2958
|
pass
|
|
2875
2959
|
print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
|
|
2960
|
+
if not uses_token and identity['state'] == 'mismatch':
|
|
2961
|
+
print(f' portable setup-token belongs to a different organization; OAuth login passed; '
|
|
2962
|
+
f'replace the token with: claude-accounts login {aid} --token')
|
|
2876
2963
|
else:
|
|
2877
2964
|
raw = (r.stderr or '').strip()
|
|
2878
2965
|
err = raw[:200]
|
|
@@ -2960,7 +3047,8 @@ sync_push_target() {
|
|
|
2960
3047
|
done
|
|
2961
3048
|
# Advisory limit state for accounts the target may lack a bearer for.
|
|
2962
3049
|
local extra
|
|
2963
|
-
for extra in limits.json .limited; do
|
|
3050
|
+
for extra in limits.json .limited .server-token-identity.json; do
|
|
3051
|
+
if [ "$extra" = .server-token-identity.json ]; then token_identity_receipt "$d" || continue; fi
|
|
2964
3052
|
if [ -f "$d/$extra" ]; then
|
|
2965
3053
|
rsync -az "$d/$extra" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 || true
|
|
2966
3054
|
fi
|
|
@@ -3053,6 +3141,7 @@ cmd_sync() {
|
|
|
3053
3141
|
sync_is_replica && role="replica"
|
|
3054
3142
|
log_to sync.log "sync (local-only, role=$role): no server target${no_server:+ (--no-server)}"
|
|
3055
3143
|
local_pool_fixup
|
|
3144
|
+
[ "$role" != source ] || token_identities_check
|
|
3056
3145
|
if [ "$role" = "replica" ]; then
|
|
3057
3146
|
echo "sync ok (local-only, and this pool is a sync replica — nothing pushed either way)"
|
|
3058
3147
|
else
|
|
@@ -3102,6 +3191,7 @@ EOF
|
|
|
3102
3191
|
fail "manifest has zero accounts — refusing to blank the target pools (use 'sync --allow-empty' if that is really intended)"
|
|
3103
3192
|
fi
|
|
3104
3193
|
|
|
3194
|
+
token_identities_check
|
|
3105
3195
|
sync_push_target "$server" "$sroot" "$srepo"
|
|
3106
3196
|
local npeers=0
|
|
3107
3197
|
if [ -n "$peers" ]; then
|
|
@@ -73,6 +73,11 @@ auto-refreshing login — `.credentials.json`, or on macOS the login Keychain wh
|
|
|
73
73
|
the session can open it (Claude Code migrates the file into the Keychain on the first
|
|
74
74
|
refresh from a keychain-capable session; ssh sessions then see the account as
|
|
75
75
|
`KEYCHAIN LOCKED` and cannot run it, while the Mac's own session uses it normally).
|
|
76
|
+
The Keychain item is named after `$USER`; the CLI fills it from `id -un` when the caller's
|
|
77
|
+
env has none (a USER-less client files its login under `unknown`, where the next client
|
|
78
|
+
never reads it). The pool judges only the item under that name, as the client does; a
|
|
79
|
+
login found only under another name is moved there by the next limits pass, and a
|
|
80
|
+
sign-in whose identity matched leaves exactly one item, under that name.
|
|
76
81
|
That credential is **machine-local** (never synced),
|
|
77
82
|
which is what keeps two machines from invalidating each other's refresh token — so an
|
|
78
83
|
account you `add` on the Mac runs on the Mac, and you `add` it on the server (over SSH) if
|