claude-multiacc 2.0.39 → 2.0.41

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.
@@ -3107,7 +3107,12 @@ EOF
3107
3107
  if [ -n "$peers" ]; then
3108
3108
  while IFS="$tab" read -r pt pr pp; do
3109
3109
  [ -n "$pt" ] || continue
3110
- sync_push_target "$pt" "$pr" "$pp"
3110
+ # </dev/null: the ssh/rsync calls inside read STDIN, and stdin here is the
3111
+ # peer list itself — the first peer's ssh swallowed every later line, so sync
3112
+ # reached gas-mini (first in the list) and never mini-3..mini-8 (peers since
3113
+ # 2026-09-04; the limits fan-out, which reads its list through process
3114
+ # substitution, was the only thing that ever reached them).
3115
+ sync_push_target "$pt" "$pr" "$pp" </dev/null
3111
3116
  npeers=$((npeers + 1))
3112
3117
  done <<EOF
3113
3118
  $peers
@@ -2208,7 +2208,12 @@ EOF
2208
2208
  if [ -n "$peers" ]; then
2209
2209
  while IFS="$tab" read -r pt pr pp; do
2210
2210
  [ -n "$pt" ] || continue
2211
- sync_push_target "$pt" "$pr" "$pp"
2211
+ # </dev/null: the ssh/rsync calls inside read STDIN, and stdin here is the
2212
+ # peer list itself — the first peer's ssh swallowed every later line, so sync
2213
+ # reached gas-mini (first in the list) and never mini-3..mini-8 (peers since
2214
+ # 2026-09-04; the limits fan-out, which reads its list through process
2215
+ # substitution, was the only thing that ever reached them).
2216
+ sync_push_target "$pt" "$pr" "$pp" </dev/null
2212
2217
  npeers=$((npeers + 1))
2213
2218
  done <<EOF
2214
2219
  $peers
package/lib/report.py CHANGED
@@ -46,7 +46,7 @@ import time
46
46
 
47
47
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
48
48
  import keychain # noqa: E402
49
- from audit import token_verified # noqa: E402
49
+ from audit import creds_doc_state, token_verified # noqa: E402
50
50
  from claude_reset import marker_superseded, record_from # noqa: E402
51
51
 
52
52
  SCHEMA = 'claude-multiacc/pool.v1'
@@ -194,6 +194,21 @@ def _claude_credentials(d):
194
194
  if isinstance(o, dict):
195
195
  detail['oauth_expires_at'] = _iso_ms(o.get('expiresAt'))
196
196
  detail['oauth_refresh_expires_at'] = _iso_ms(o.get('refreshTokenExpiresAt'))
197
+ # `oauth` says a sign-in EXISTS here; `oauth_alive` says whether it can still work —
198
+ # the limits writer's own rule (audit.creds_doc_state). A dead one (refresh token
199
+ # expired) keeps inference running on the portable token but can never read usage
200
+ # or limit resets again, so the panel must ask for a sign-in (2026-09-22: acct-02 and
201
+ # acct-07 sat dark for days behind `oauth: true`). None = could not be read here (a
202
+ # Keychain this session cannot open) or no sign-in at all.
203
+ detail['oauth_alive'] = None
204
+ if has_oauth and isinstance(o, dict):
205
+ try:
206
+ state, reason = creds_doc_state({'claudeAiOauth': o}, time.time())
207
+ detail['oauth_alive'] = state == 'ok'
208
+ if state != 'ok':
209
+ detail['oauth_problem'] = reason
210
+ except Exception:
211
+ pass
197
212
  if has_token:
198
213
  mt = _mtime(tpath)
199
214
  detail['token_minted_at'] = _iso(mt)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.39",
3
+ "version": "2.0.41",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3908,15 +3908,20 @@ EOF
3908
3908
  SSHFAKE="$WORK/sshfake"
3909
3909
  SSHLOG="$WORK/sshfake.log"
3910
3910
  mkdir -p "$SSHFAKE"
3911
+ # Both fakes DRAIN stdin like the real tools do: ssh forwards it to the remote
3912
+ # command and rsync's transport reads it. A fake that left stdin alone hid the
3913
+ # bug where the peer loop fed them its own peer list.
3911
3914
  cat > "$SSHFAKE/ssh" <<'EOF'
3912
3915
  #!/usr/bin/env bash
3913
3916
  printf 'ssh %s\n' "$*" >> "${SSHLOG:?}"
3917
+ [ -t 0 ] || cat >/dev/null
3914
3918
  case "$*" in *"${SSH_FAIL_HOST:-@@none@@}"*) exit 1 ;; esac
3915
3919
  exit 0
3916
3920
  EOF
3917
3921
  cat > "$SSHFAKE/rsync" <<'EOF'
3918
3922
  #!/usr/bin/env bash
3919
3923
  printf 'rsync %s\n' "$*" >> "${SSHLOG:?}"
3924
+ [ -t 0 ] || cat >/dev/null
3920
3925
  exit 0
3921
3926
  EOF
3922
3927
  chmod +x "$SSHFAKE/ssh" "$SSHFAKE/rsync"
@@ -3966,14 +3971,22 @@ EOF
3966
3971
  python3 - "$ACC/accounts.json" <<'EOF'
3967
3972
  import json, os, sys
3968
3973
  d = json.load(open(sys.argv[1]))
3969
- d['peers'] = [{'target': 'gas@peer1', 'root': '/Users/gas/.claude-accounts', 'repo': '/Users/gas/claude-multiacc'}]
3974
+ d['peers'] = [{'target': 'gas@peer1', 'root': '/Users/gas/.claude-accounts', 'repo': '/Users/gas/claude-multiacc'},
3975
+ {'target': 'gas@peer2', 'root': '/Users/gas/.claude-accounts', 'repo': '/Users/gas/claude-multiacc'},
3976
+ {'target': 'gas@peer3', 'root': '/Users/gas/.claude-accounts', 'repo': '/Users/gas/claude-multiacc'}]
3970
3977
  json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
3971
3978
  EOF
3972
3979
  : > "$SSHLOG"
3973
3980
  out="$(PATH="$SSHFAKE:$PATH" CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
3974
3981
  rc=$?
3975
3982
  [ "$rc" = "0" ] && t_ok "multi-target sync succeeds" || t_fail "multi-target sync" "rc=$rc: $out"
3976
- check "multi-target sync reports the peer" "+ 1 peer(s)" "$out"
3983
+ check "multi-target sync reports every peer" "+ 3 peer(s)" "$out"
3984
+ # EVERY peer is pushed, not just the first: the ssh/rsync inside the loop must not
3985
+ # be allowed to eat the peer list off stdin (mini-3..8 got nothing for 18 days).
3986
+ grep -q "rsync.*gas@peer2:/Users/gas/.claude-accounts/accounts.json" "$SSHLOG" \
3987
+ && grep -q "rsync.*gas@peer3:/Users/gas/.claude-accounts/accounts.json" "$SSHLOG" \
3988
+ && t_ok "the second and third peers are pushed too (stdin not eaten by the first)" \
3989
+ || t_fail "later peers skipped" "$(grep -c 'accounts.json' "$SSHLOG") manifest pushes: $(grep accounts.json "$SSHLOG" | tr '\n' '|')"
3977
3990
  grep -q "rsync.*root@203.0.113.1:/root/.claude-accounts/accounts.json" "$SSHLOG" \
3978
3991
  && grep -q "rsync.*gas@peer1:/Users/gas/.claude-accounts/accounts.json" "$SSHLOG" \
3979
3992
  && t_ok "manifest pushed to BOTH targets" \
@@ -3984,8 +3997,8 @@ EOF
3984
3997
  && t_ok "primary target pushed before the peer" \
3985
3998
  || t_fail "target order" "primary=$first_primary peer=$first_peer"
3986
3999
  n_remov="$(grep -c "for dd in acct-" "$SSHLOG")"
3987
- [ "$n_remov" = "2" ] && t_ok "removal propagation ran on each target" \
3988
- || t_fail "per-target removal" "expected 2 removal sweeps, saw $n_remov"
4000
+ [ "$n_remov" = "4" ] && t_ok "removal propagation ran on each target" \
4001
+ || t_fail "per-target removal" "expected 4 removal sweeps, saw $n_remov"
3989
4002
  grep -q "post-sync" "$SSHLOG" && t_ok "post-sync hooks invoked" || t_fail "post-sync hooks" "none recorded"
3990
4003
  # The MCP registry rides with the manifest to EVERY target; the machine-local overlay
3991
4004
  # (a runner daemon's view of THIS Mac) never leaves. Without a registry file nothing
@@ -660,6 +660,36 @@ class ClaudeResetFleetRecordTest(ClaudePoolSandbox, unittest.TestCase):
660
660
  self.assertIn("written before a confirmed limit reset", self.log())
661
661
 
662
662
 
663
+ class ClaudeDeadSignInReportTest(ClaudePoolSandbox, unittest.TestCase):
664
+ """A sign-in that EXISTS but can no longer work must say so (2026-09-22: acct-02 and
665
+ acct-07 read `oauth: true` with a refresh token expired days before, so the panel
666
+ never offered the sign-in that restores their telemetry and limit resets)."""
667
+
668
+ def listed(self) -> dict:
669
+ result = subprocess.run([REPO / "bin/claude-accounts", "list", "--json"],
670
+ capture_output=True, text=True, env=self.env(), timeout=60,
671
+ check=False)
672
+ self.assertEqual(result.returncode, 0, result.stderr)
673
+ return json.loads(result.stdout)["accounts"][0]["credentials"]
674
+
675
+ def test_a_live_sign_in_is_alive(self) -> None:
676
+ creds = self.listed()
677
+ self.assertTrue(creds["oauth"])
678
+ self.assertTrue(creds["oauth_alive"])
679
+ self.assertNotIn("oauth_problem", creds)
680
+
681
+ def test_an_expired_refresh_token_is_a_dead_sign_in(self) -> None:
682
+ (self.account / "server.token").write_text("sk-ant-oat01-portable\n", encoding="utf-8")
683
+ dead = {"claudeAiOauth": {"accessToken": "old", "refreshToken": "r",
684
+ "expiresAt": int(time.time() - 7 * 86400) * 1000,
685
+ "refreshTokenExpiresAt": int(time.time() - 3 * 86400) * 1000}}
686
+ (self.account / ".credentials.json").write_text(json.dumps(dead), encoding="utf-8")
687
+ creds = self.listed()
688
+ self.assertTrue(creds["oauth"]) # it exists...
689
+ self.assertFalse(creds["oauth_alive"]) # ...but cannot work
690
+ self.assertIn("refresh token expired", creds["oauth_problem"])
691
+
692
+
663
693
  class ClaudeResetReportingTest(ClaudePoolSandbox, unittest.TestCase):
664
694
  def report(self, **overrides) -> dict:
665
695
  result = self.run_limits("--json", **overrides)