claude-multiacc 2.0.58 → 2.0.60
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 +125 -4
- package/bin/claude +389 -94
- package/bin/claude-accounts +276 -42
- package/bin/codex +69 -5
- package/bin/codex-accounts +33 -4
- package/docs/ACCOUNT_OPERATIONS.md +62 -2
- package/docs/AUTORESUME-HANDOFF.md +28 -3
- package/docs/AUTORESUME.md +275 -18
- package/docs/CODEX.md +1 -0
- package/docs/INSTALLATION.md +7 -1
- package/docs/VERIFICATION.md +4 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.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_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.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__/session_settings.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/audit.py +88 -22
- package/lib/autoresume.py +1197 -82
- package/lib/autoresume_cross.py +3 -1
- package/lib/autoresume_handoff.py +81 -9
- package/lib/autoresume_probe.py +2 -2
- package/lib/claude_client_error.py +9 -4
- package/lib/codex_trust.py +263 -0
- package/lib/common.sh +146 -3
- package/lib/credential.py +5 -2
- package/lib/install_actions.sh +19 -0
- package/lib/keychain.py +180 -36
- package/lib/mcp_oauth.py +1748 -0
- package/lib/mcp_registry.py +230 -8
- package/lib/model_catalog.json +404 -0
- package/lib/model_catalog.py +201 -0
- package/lib/model_match.py +104 -0
- package/lib/report.py +8 -5
- package/lib/session_settings.py +123 -0
- package/package.json +2 -2
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- 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_autoresume_catalog.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.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_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.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_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.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_codex_tui.py +25 -0
- package/tests/fake_security.sh +24 -2
- package/tests/fixtures/cross_provider_cli.py +109 -0
- package/tests/run-tests.sh +1171 -23
- package/tests/test_autoresume.py +215 -8
- package/tests/test_autoresume_catalog.py +226 -0
- package/tests/test_autoresume_catalog_cli.py +185 -0
- package/tests/test_autoresume_codex_records.py +16 -0
- package/tests/test_autoresume_codex_trust.py +247 -0
- package/tests/test_autoresume_cross_guards.py +28 -0
- package/tests/test_autoresume_cross_tmux.py +27 -2
- package/tests/test_autoresume_cross_tokens.py +32 -2
- package/tests/test_autoresume_handoff_argv.py +120 -16
- package/tests/test_autoresume_handoff_origins.py +14 -0
- package/tests/test_autoresume_memory.py +1141 -0
- package/tests/test_autoresume_memory_tmux.py +94 -0
- package/tests/test_autoresume_probe.py +2 -1
- package/tests/test_autoresume_relaunch_settings.py +317 -0
- package/tests/test_autoresume_settings.py +135 -0
- package/tests/test_autoresume_sidechain.py +1518 -0
- package/tests/test_autoresume_sidechain_tmux.py +79 -0
- package/tests/test_autoresume_trust_tmux.py +63 -0
- package/tests/test_claude_client_error.py +10 -7
- package/tests/test_claude_org_block.py +1 -0
- package/tests/test_claude_pool_prepare.py +4 -1
- package/tests/test_claude_reset.py +3 -0
- package/tests/test_keychain_nologin.py +340 -0
- package/tests/test_mcp_oauth.py +1414 -0
- package/tests/test_mcp_registry.py +234 -3
- package/tests/test_packaged_commands.py +41 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""A workflow agent's five-hour limit moves a claude session: actual tmux, shell, production
|
|
2
|
+
shim and watcher, with the cross-provider suite's disposable fake Claude TUI."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import signal
|
|
7
|
+
import time
|
|
8
|
+
import unittest
|
|
9
|
+
|
|
10
|
+
from autoresume_cross_tmux_support import CrossScenario, TMUX
|
|
11
|
+
|
|
12
|
+
AGENT = "a33659a7131e55067"
|
|
13
|
+
AGENT2 = "ab12cd34ef56a7890"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def stamp(now):
|
|
17
|
+
"""A transcript timestamp to the millisecond, as claude 2.1.303 writes them."""
|
|
18
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(now)) + ".%03dZ" % int(now % 1 * 1000)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@unittest.skipUnless(TMUX, "real tmux is unavailable")
|
|
22
|
+
class SidechainTmuxTests(unittest.TestCase):
|
|
23
|
+
def test_a_workflow_agents_limit_resumes_the_idle_session_on_another_account(self):
|
|
24
|
+
# The suite's short grace (0.4 s): the relaunch starts a second or two after the
|
|
25
|
+
# rejection, and one more agent is rejected as the client is stopped (the fake TUI
|
|
26
|
+
# records it on SIGTERM). The relaunched watcher reads both in the same agent
|
|
27
|
+
# transcripts and must act on neither: they are the replaced process's.
|
|
28
|
+
case = CrossScenario(CLAUDE_MULTIACC_AR_SUBAGENT_SCAN="0.2",
|
|
29
|
+
FAKE_AGENT_LIMIT_ON_TERM=AGENT2)
|
|
30
|
+
self.addCleanup(case.close)
|
|
31
|
+
case.start("claude")
|
|
32
|
+
ready = case.records("ready")[0]
|
|
33
|
+
# this launch's own agents start a second after it at the soonest
|
|
34
|
+
state = case.pools["claude"] / "tmp" / "autoresume" / ("%d.state" % ready["pid"])
|
|
35
|
+
launched = int(dict(line.split("=", 1) for line in state.read_text().splitlines()
|
|
36
|
+
if "=" in line)["launched"])
|
|
37
|
+
time.sleep(max(0.0, launched + 1.05 - time.time()))
|
|
38
|
+
# the main transcript stays quiet; only the workflow's agent records the limit
|
|
39
|
+
agent = (Path(ready["path"]).with_suffix("") / "subagents" / "workflows" / "wf_e2e"
|
|
40
|
+
/ ("agent-%s.jsonl" % AGENT))
|
|
41
|
+
agent.parent.mkdir(parents=True)
|
|
42
|
+
reset = int(time.time()) + 3600
|
|
43
|
+
agent.write_text(json.dumps({
|
|
44
|
+
"type": "assistant", "isSidechain": True, "agentId": AGENT, "sessionId": ready["sid"],
|
|
45
|
+
"timestamp": stamp(time.time()),
|
|
46
|
+
"isApiErrorMessage": True, "error": "rate_limit", "apiError": "usage_limit_reached",
|
|
47
|
+
"apiErrorStatus": 429, "quotaLimits": {"status": "rejected", "resetsAt": reset,
|
|
48
|
+
"rateLimitType": "five_hour",
|
|
49
|
+
"overageStatus": "rejected"},
|
|
50
|
+
"message": {"model": "<synthetic>", "role": "assistant", "content": [
|
|
51
|
+
{"type": "text", "text": "You've hit your session limit"}]}}, separators=(",", ":"))
|
|
52
|
+
+ "\n")
|
|
53
|
+
case.wait(lambda: len(case.records("ready")) >= 2, "same session resumed on another account")
|
|
54
|
+
case.wait(lambda: "autoresume switch" in case.logs("claude"), "watcher confirms switch")
|
|
55
|
+
first, second = case.records("launch")[:2]
|
|
56
|
+
self.assertEqual([first["provider"], second["provider"]], ["claude", "claude"])
|
|
57
|
+
self.assertEqual((first["account"], second["account"]), ("acct-01", "acct-02"))
|
|
58
|
+
self.assertEqual(first["sid"], second["sid"])
|
|
59
|
+
self.assertEqual(second["argv"][second["argv"].index("--resume") + 1], first["sid"])
|
|
60
|
+
self.assertIn("Continue", second["argv"][-1])
|
|
61
|
+
self.assertIn("class=quota code=rate_limit sid=%s acct=acct-01 reset=%d src=sidechain agent=%s"
|
|
62
|
+
% (first["sid"], reset, AGENT), case.logs("claude"))
|
|
63
|
+
self.assertEqual(case.records("keys"), [], "no keystroke may reach either TUI")
|
|
64
|
+
self.assertEqual(case.records("signal")[0]["signal"], signal.SIGTERM)
|
|
65
|
+
self.assertEqual([row["agent"] for row in case.records("agent-limit")], [AGENT2])
|
|
66
|
+
marker = (case.pools["claude"] / "acct-01" / ".limited").read_text().split("\n")
|
|
67
|
+
self.assertEqual(marker[0], str(reset))
|
|
68
|
+
self.assertIn("bucket=client:five_hour", marker[1])
|
|
69
|
+
# the relaunched session's watcher reads the same agent transcripts, and lets them be
|
|
70
|
+
case.wait(lambda: case.logs("claude").count("autoresume watch") == 2, "second watcher")
|
|
71
|
+
time.sleep(1)
|
|
72
|
+
self.assertEqual(case.logs("claude").count("autoresume detect"), 1)
|
|
73
|
+
self.assertEqual(len(case.records("launch")), 2)
|
|
74
|
+
self.assertFalse((case.pools["claude"] / "acct-02" / ".limited").exists(),
|
|
75
|
+
"the account the session moved to is never parked")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
unittest.main()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Folder trust must be in place before a resumed Codex can submit its prompt."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from autoresume_tmux_support import TMUX, TmuxScenario
|
|
8
|
+
from autoresume_cross_tmux_support import CrossScenario
|
|
9
|
+
|
|
10
|
+
QUOTA = {"type": "task_complete", "error": {"message": "You've hit your usage limit.",
|
|
11
|
+
"codex_error_info": "usage_limit_exceeded"}}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@unittest.skipUnless(TMUX, "real tmux is unavailable")
|
|
15
|
+
class CodexTrustTmuxTests(unittest.TestCase):
|
|
16
|
+
def test_quota_restart_trusts_only_destination_before_prompt(self):
|
|
17
|
+
case = TmuxScenario(FAKE_REQUIRE_TRUST="1")
|
|
18
|
+
self.addCleanup(case.close)
|
|
19
|
+
source = case.pool / "acct-01/config.toml"
|
|
20
|
+
target = case.pool / "acct-02/config.toml"
|
|
21
|
+
source.write_text('# Source account stays unchanged.\n')
|
|
22
|
+
target.write_text('[projects.' + json.dumps(str(case.work)) + ']\ntrust_level = "untrusted"\n')
|
|
23
|
+
case.start()
|
|
24
|
+
self.assertEqual(source.read_text(), '# Source account stays unchanged.\n')
|
|
25
|
+
self.assertIn('"untrusted"', target.read_text())
|
|
26
|
+
case.append(QUOTA)
|
|
27
|
+
case.wait(lambda: "autoresume switch" in case.log.read_text(), "trusted restart confirmed")
|
|
28
|
+
self.assertTrue(case.records("trust")[0]["trusted"])
|
|
29
|
+
self.assertEqual(len(case.records("prompt")), 1)
|
|
30
|
+
self.assertEqual(case.records("keys"), [])
|
|
31
|
+
self.assertEqual(source.read_text(), '# Source account stays unchanged.\n')
|
|
32
|
+
|
|
33
|
+
def test_quota_restart_fails_open_when_the_trust_write_is_refused(self):
|
|
34
|
+
case = TmuxScenario(FAKE_REQUIRE_TRUST="1")
|
|
35
|
+
self.addCleanup(case.close)
|
|
36
|
+
target = case.pool / "acct-02/config.toml"
|
|
37
|
+
# A bare [projects] table is a layout lib/codex_trust.py refuses to edit.
|
|
38
|
+
target.write_text('[projects]\n' + json.dumps(str(case.work)) + ' = { trust_level = "untrusted" }\n')
|
|
39
|
+
before = target.read_bytes()
|
|
40
|
+
case.start()
|
|
41
|
+
case.append(QUOTA)
|
|
42
|
+
case.wait(lambda: case.records("trust"), "relaunched TUI on its trust screen")
|
|
43
|
+
self.assertEqual(re.findall(r" autoresume trust-failed chain=\w+ acct=(\S+)$", case.log.read_text(), re.M),
|
|
44
|
+
["acct-02"])
|
|
45
|
+
self.assertEqual([row["account"] for row in case.records("launch")], ["acct-01", "acct-02"])
|
|
46
|
+
self.assertEqual([row["trusted"] for row in case.records("trust")], [False])
|
|
47
|
+
self.assertEqual(target.read_bytes(), before)
|
|
48
|
+
|
|
49
|
+
def test_cross_provider_handoff_trusts_codex_destination(self):
|
|
50
|
+
case = CrossScenario(FAKE_REQUIRE_TRUST="1")
|
|
51
|
+
self.addCleanup(case.close)
|
|
52
|
+
case.start("claude")
|
|
53
|
+
case.park("claude")
|
|
54
|
+
case.quota()
|
|
55
|
+
case.wait(lambda: "autoresume switch" in case.logs("claude"), "trusted Codex handoff confirmed")
|
|
56
|
+
self.assertTrue(case.records("trust")[0]["trusted"])
|
|
57
|
+
self.assertEqual(case.records("prompt")[0]["provider"], "codex")
|
|
58
|
+
self.assertEqual(case.records("keys"), [])
|
|
59
|
+
self.assertFalse((case.pools["codex"] / "acct-02/config.toml").exists())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
unittest.main()
|
|
@@ -79,14 +79,17 @@ class ClaudeClientErrorTests(unittest.TestCase):
|
|
|
79
79
|
del without_sidechain["isSidechain"]
|
|
80
80
|
self.assertEqual(errors.newest_error([json.dumps(without_sidechain)], CLAIM), ("org-blocked", LATER))
|
|
81
81
|
|
|
82
|
-
def
|
|
82
|
+
def test_verified_access_supersedes_every_error_recorded_before_it(self):
|
|
83
|
+
# A verify that reached the account proves the credential it runs on authenticates
|
|
84
|
+
# for an organization that admits Claude Code: an older authentication failure is
|
|
85
|
+
# as stale as an older org refusal, and must not re-park what that PASS lifted.
|
|
83
86
|
at = errors.iso_epoch(LATER)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
for code, slug in errors.ERRORS.items():
|
|
88
|
+
with self.subTest(code=code):
|
|
89
|
+
record = json.dumps(api_record(code))
|
|
90
|
+
self.assertIsNone(errors.newest_error([record], CLAIM, at))
|
|
91
|
+
self.assertIsNone(errors.newest_error([record], CLAIM, at + 100))
|
|
92
|
+
self.assertEqual(errors.newest_error([record], CLAIM, at - 0.001), (slug, LATER))
|
|
90
93
|
|
|
91
94
|
def test_latest_in_class_wins_and_auth_cannot_downgrade_org_refusal(self):
|
|
92
95
|
latest = "2026-09-30T13:30:02.000+03:00"
|
|
@@ -74,6 +74,7 @@ class ClaudeOrganizationBlockIntegrationTests(unittest.TestCase):
|
|
|
74
74
|
CLAUDE_MULTIACC_KEYCHAIN="0", CLAUDE_MULTIACC_PATH_PROBE="0", CLAUDE_MULTIACC_MCP="0",
|
|
75
75
|
CLAUDE_MULTIACC_AUTORESUME="0", CLAUDE_MULTIACC_AUTO_RESET="0", CLAUDE_MULTIACC_HEADROOM_BAND="0",
|
|
76
76
|
CLAUDE_MULTIACC_CLIENT_SCAN_TTL="0", CLAUDE_MULTIACC_NO_SYNC="1", CLAUDE_MULTIACC_NO_DISTRIBUTE="1",
|
|
77
|
+
CLAUDE_MULTIACC_MCP_OAUTH="0", # the detached MCP OAuth sync must not outlive a test
|
|
77
78
|
CLAUDE_MULTIACC_USAGE_URL=f"file://{self.home}/missing-usage.json",
|
|
78
79
|
CLAUDE_MULTIACC_TOKEN_URL=f"file://{self.home}/missing-token.json",
|
|
79
80
|
CLAUDE_MULTIACC_TOKEN_IDENTITY_URL=f"file://{self.home}/missing-identity.json",
|
|
@@ -28,8 +28,11 @@ class ClaudePoolPrepareTests(unittest.TestCase):
|
|
|
28
28
|
}))
|
|
29
29
|
|
|
30
30
|
def run_prepare(self, verb):
|
|
31
|
+
# Run from inside a pooled session, the environment names the REAL pool root; the
|
|
32
|
+
# repair must only ever see this test's --home.
|
|
33
|
+
env = {k: v for k, v in os.environ.items() if k != "CLAUDE_ACCOUNTS_ROOT"}
|
|
31
34
|
return subprocess.run([sys.executable, str(SCRIPT), verb, "--home", str(self.home)],
|
|
32
|
-
capture_output=True, text=True)
|
|
35
|
+
capture_output=True, text=True, env=env)
|
|
33
36
|
|
|
34
37
|
def test_fresh_pool_starts_shared_and_bypassed(self):
|
|
35
38
|
self.assertEqual(self.run_prepare("check").returncode, 1)
|
|
@@ -163,6 +163,9 @@ class ClaudePoolSandbox:
|
|
|
163
163
|
env.update({
|
|
164
164
|
"CLAUDE_ACCOUNTS_DIR": str(self.pool), "CLAUDE_MULTIACC_NO_SYNC": "1",
|
|
165
165
|
"CLAUDE_MULTIACC_NO_DISTRIBUTE": "1", "CLAUDE_MULTIACC_MIN_FETCH": "0",
|
|
166
|
+
# No detached child may outlive the pass and write into the pool as tearDown
|
|
167
|
+
# removes it: the MCP OAuth sync (lib/mcp_oauth.py) the pass spawns, like the push.
|
|
168
|
+
"CLAUDE_MULTIACC_MCP_OAUTH": "0",
|
|
166
169
|
"CLAUDE_MULTIACC_KEYCHAIN": "0", "CLAUDE_MULTIACC_PATH_PROBE": "0",
|
|
167
170
|
"CLAUDE_MULTIACC_AUTO_RESET": "1", "CLAUDE_MULTIACC_CLI_VERSION": "2.1.280",
|
|
168
171
|
"PYTHONDONTWRITEBYTECODE": "1",
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""A credentials document holding no Claude login is no login (lib/keychain.py,
|
|
3
|
+
lib/audit.py, lib/report.py, lib/credential.py).
|
|
4
|
+
|
|
5
|
+
Claude Code 2.1.303 keeps its MCP servers' OAuth state (``mcpOAuth``,
|
|
6
|
+
``mcpOAuthClientConfig``) in the same document as the login, and from a session that
|
|
7
|
+
cannot open the Keychain (ssh/tmux) it writes a ``.credentials.json`` holding ONLY that.
|
|
8
|
+
Counted as the login, such accounts were picked with no token exported and every run said
|
|
9
|
+
"Not logged in" (2026-10-19). Runs everywhere: a fake ``security`` on PATH serves items
|
|
10
|
+
from a temp dir, and CLAUDE_MULTIACC_KEYCHAIN=1 forces the lookup on off-macOS.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import calendar
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import time
|
|
23
|
+
import unittest
|
|
24
|
+
|
|
25
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
26
|
+
sys.path.insert(0, str(REPO / 'lib'))
|
|
27
|
+
|
|
28
|
+
FAKE_SECURITY = (REPO / 'tests' / 'fake_security.sh').read_text()
|
|
29
|
+
KEY = 'higgsfield|0123456789abcdef'
|
|
30
|
+
ENTRY = {'serverName': 'higgsfield', 'serverUrl': 'https://mcp.higgsfield.ai/mcp',
|
|
31
|
+
'accessToken': 'mcp-access-fixture', 'refreshToken': 'mcp-refresh-fixture',
|
|
32
|
+
'expiresAt': 9999999999999, 'scope': 'openid', 'issuer': 'https://clerk.higgsfield.ai',
|
|
33
|
+
'clientId': 'https://claude.ai/oauth/claude-code-client-metadata',
|
|
34
|
+
'redirectUri': 'http://localhost:1/callback',
|
|
35
|
+
'discoveryState': {'authorizationServerUrl': 'https://clerk.higgsfield.ai',
|
|
36
|
+
'oauthMetadataFound': True}}
|
|
37
|
+
MCP_ONLY = {'mcpOAuth': {KEY: ENTRY}}
|
|
38
|
+
LIVE = {'claudeAiOauth': {'accessToken': 'sk-ant-oat01-live', 'refreshToken': 'r',
|
|
39
|
+
'expiresAt': 9999999999999, 'refreshTokenExpiresAt': 9999999999999}}
|
|
40
|
+
DEAD = {'claudeAiOauth': {'accessToken': 'sk-ant-oat01-dead', 'refreshToken': 'r',
|
|
41
|
+
'expiresAt': 1000, 'refreshTokenExpiresAt': 1000}}
|
|
42
|
+
# An expired login with no refresh token, beside an MCP entry that is alive and has one:
|
|
43
|
+
# the shape whose greedy scrape read the entry's numbers as the login's.
|
|
44
|
+
DEAD_BESIDE_MCP = {'claudeAiOauth': {'accessToken': 'sk-ant-oat01-old', 'expiresAt': 1000},
|
|
45
|
+
'mcpOAuth': {KEY: ENTRY}}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Fixture(unittest.TestCase):
|
|
49
|
+
"""A fake keychain, a pool with one account dir, and the modules reloaded."""
|
|
50
|
+
|
|
51
|
+
def setUp(self):
|
|
52
|
+
self.work = tempfile.TemporaryDirectory()
|
|
53
|
+
work = Path(self.work.name)
|
|
54
|
+
fakebin = work / 'bin'
|
|
55
|
+
fakebin.mkdir()
|
|
56
|
+
tool = fakebin / 'security'
|
|
57
|
+
tool.write_text(FAKE_SECURITY)
|
|
58
|
+
tool.chmod(0o755)
|
|
59
|
+
self.kcdir = work / 'keychain'
|
|
60
|
+
self.saved = {k: os.environ.get(k) for k in
|
|
61
|
+
('PATH', 'CLAUDE_MULTIACC_KEYCHAIN', 'FAKE_KEYCHAIN_DIR',
|
|
62
|
+
'FAKE_KEYCHAIN_LOCKED', 'FAKE_KEYCHAIN_MDAT', 'FAKE_SECURITY_LOG',
|
|
63
|
+
'USER')}
|
|
64
|
+
os.environ['PATH'] = f"{fakebin}:{os.environ['PATH']}"
|
|
65
|
+
os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '1'
|
|
66
|
+
os.environ['FAKE_KEYCHAIN_DIR'] = str(self.kcdir)
|
|
67
|
+
for key in ('FAKE_KEYCHAIN_LOCKED', 'FAKE_KEYCHAIN_MDAT', 'FAKE_SECURITY_LOG'):
|
|
68
|
+
os.environ.pop(key, None)
|
|
69
|
+
os.environ['USER'] = 'gas'
|
|
70
|
+
self.calllog = work / 'security.log'
|
|
71
|
+
import importlib
|
|
72
|
+
import keychain
|
|
73
|
+
self.keychain = importlib.reload(keychain)
|
|
74
|
+
self.root = work / 'accounts'
|
|
75
|
+
self.acct = self.root / 'acct-01'
|
|
76
|
+
self.acct.mkdir(parents=True)
|
|
77
|
+
self.d = str(self.acct)
|
|
78
|
+
self.cpath = self.acct / '.credentials.json'
|
|
79
|
+
|
|
80
|
+
def tearDown(self):
|
|
81
|
+
for key, value in self.saved.items():
|
|
82
|
+
if value is None:
|
|
83
|
+
os.environ.pop(key, None)
|
|
84
|
+
else:
|
|
85
|
+
os.environ[key] = value
|
|
86
|
+
self.work.cleanup()
|
|
87
|
+
|
|
88
|
+
def put(self, doc, account='gas', mdat='20261019000000', d=None):
|
|
89
|
+
"""An item under <account>, written the way a client whose FA() said so writes it."""
|
|
90
|
+
text = doc if isinstance(doc, str) else json.dumps(doc)
|
|
91
|
+
subprocess.run(['security', 'add-generic-password', '-U', '-a', account, '-s',
|
|
92
|
+
self.keychain.service_name(d or self.d), '-X',
|
|
93
|
+
text.encode('utf-8').hex()],
|
|
94
|
+
check=True, env=dict(os.environ, FAKE_KEYCHAIN_MDAT=mdat))
|
|
95
|
+
|
|
96
|
+
def items(self):
|
|
97
|
+
svc = self.kcdir / self.keychain.service_name(self.d)
|
|
98
|
+
return sorted(p.name for p in svc.iterdir()) if svc.is_dir() else []
|
|
99
|
+
|
|
100
|
+
def secret(self, account='gas'):
|
|
101
|
+
svc = self.kcdir / self.keychain.service_name(self.d)
|
|
102
|
+
return json.loads((svc / account / 'secret').read_text())
|
|
103
|
+
|
|
104
|
+
def calls(self, fn, *args):
|
|
105
|
+
self.calllog.write_text('')
|
|
106
|
+
os.environ['FAKE_SECURITY_LOG'] = str(self.calllog)
|
|
107
|
+
try:
|
|
108
|
+
result = fn(*args)
|
|
109
|
+
finally:
|
|
110
|
+
os.environ.pop('FAKE_SECURITY_LOG', None)
|
|
111
|
+
return result, self.calllog.read_text().splitlines()
|
|
112
|
+
|
|
113
|
+
def write_file(self, doc, mtime=None):
|
|
114
|
+
self.cpath.write_text(doc if isinstance(doc, str) else json.dumps(doc))
|
|
115
|
+
if mtime is not None:
|
|
116
|
+
os.utime(self.cpath, (mtime, mtime))
|
|
117
|
+
|
|
118
|
+
def manifest(self, home='mac'):
|
|
119
|
+
(self.root / 'accounts.json').write_text(json.dumps({
|
|
120
|
+
'version': 1, 'server': 'root@203.0.113.1', 'threshold': 90,
|
|
121
|
+
'accounts': [{'id': 'acct-01', 'email': 'mcp@test', 'home': home,
|
|
122
|
+
'added_at': '2026-10-19T00:00:00Z'}]}))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class KeychainNoLoginTests(Fixture):
|
|
126
|
+
def test_an_mcp_only_item_is_nologin_never_present(self):
|
|
127
|
+
self.put(MCP_ONLY)
|
|
128
|
+
res, log = self.calls(self.keychain.probe, self.d)
|
|
129
|
+
self.assertEqual((res['state'], res['account'], res['doc']), ('nologin', 'gas', MCP_ONLY))
|
|
130
|
+
self.assertEqual(log, ['find-generic-password a=gas w=1']) # still ONE call
|
|
131
|
+
# with a null login beside it, too; a secret that is no JSON object stays corrupt
|
|
132
|
+
self.put({'claudeAiOauth': None, 'mcpOAuth': {KEY: ENTRY}})
|
|
133
|
+
self.assertEqual(self.keychain.probe(self.d)['state'], 'nologin')
|
|
134
|
+
self.put('[1, 2]')
|
|
135
|
+
self.assertEqual(self.keychain.probe(self.d)['state'], 'corrupt')
|
|
136
|
+
self.put(dict(LIVE, **MCP_ONLY))
|
|
137
|
+
self.assertEqual(self.keychain.probe(self.d)['state'], 'present')
|
|
138
|
+
|
|
139
|
+
def test_legacy_looks_past_an_mcp_only_canonical_item_for_a_stranded_login(self):
|
|
140
|
+
# A USER-less sign-in filed the login under "unknown"; a later client, USER set and
|
|
141
|
+
# logged in by a token, saved MCP state under the canonical name.
|
|
142
|
+
self.put(LIVE, 'unknown', '20261001000000')
|
|
143
|
+
self.put(MCP_ONLY, 'gas', '20261019000000')
|
|
144
|
+
self.assertEqual(self.keychain.probe(self.d)['state'], 'nologin')
|
|
145
|
+
res = self.keychain.probe(self.d, legacy=True)
|
|
146
|
+
self.assertEqual((res['state'], res['doc'], res['canonical']), ('present', LIVE, 'gas'))
|
|
147
|
+
self.assertNotEqual(res['account'], res['canonical'])
|
|
148
|
+
# the no -a first match is the OLDEST item: when that is the MCP-only canonical one,
|
|
149
|
+
# the canonical verdict stands
|
|
150
|
+
other = self.d + '-x'
|
|
151
|
+
self.put(MCP_ONLY, 'gas', '20261001000000', d=other)
|
|
152
|
+
self.put(LIVE, 'unknown', '20261019000000', d=other)
|
|
153
|
+
res = self.keychain.probe(other, legacy=True)
|
|
154
|
+
self.assertEqual((res['state'], res['account'], res['doc']), ('nologin', 'gas', MCP_ONLY))
|
|
155
|
+
|
|
156
|
+
def test_write_lands_into_the_item_keeping_its_mcp_state(self):
|
|
157
|
+
config = {KEY: {'clientId': 'cid'}}
|
|
158
|
+
self.put({'mcpOAuth': {KEY: ENTRY}, 'mcpOAuthClientConfig': config})
|
|
159
|
+
self.assertTrue(self.keychain.write(self.d, LIVE))
|
|
160
|
+
self.assertEqual(self.secret(), {'mcpOAuth': {KEY: ENTRY}, 'mcpOAuthClientConfig': config,
|
|
161
|
+
'claudeAiOauth': LIVE['claudeAiOauth']})
|
|
162
|
+
rotated = {'claudeAiOauth': dict(LIVE['claudeAiOauth'], accessToken='sk-ant-oat01-rot')}
|
|
163
|
+
self.assertTrue(self.keychain.write(self.d, rotated))
|
|
164
|
+
doc = self.secret()
|
|
165
|
+
self.assertEqual(doc['claudeAiOauth']['accessToken'], 'sk-ant-oat01-rot')
|
|
166
|
+
self.assertEqual((doc['mcpOAuth'], doc['mcpOAuthClientConfig']), ({KEY: ENTRY}, config))
|
|
167
|
+
self.assertEqual(self.keychain.probe(self.d)['state'], 'present')
|
|
168
|
+
|
|
169
|
+
def test_write_never_lands_blind_on_an_item_it_cannot_read(self):
|
|
170
|
+
self.put(MCP_ONLY)
|
|
171
|
+
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
|
|
172
|
+
self.assertFalse(self.keychain.write(self.d, LIVE))
|
|
173
|
+
os.environ.pop('FAKE_KEYCHAIN_LOCKED')
|
|
174
|
+
self.assertEqual(self.secret(), MCP_ONLY)
|
|
175
|
+
|
|
176
|
+
def test_write_never_sweeps_a_sibling_carrying_mcp_state(self):
|
|
177
|
+
self.put(DEAD, 'olduser', '20260901000000') # the oldest: a spent login alone
|
|
178
|
+
self.put(dict(DEAD, **MCP_ONLY), 'unknown', '20261001000000') # spent login + MCP
|
|
179
|
+
self.put(MCP_ONLY, 'claude-code-user', '20261002000000') # MCP state alone
|
|
180
|
+
self.assertTrue(self.keychain.write(self.d, LIVE))
|
|
181
|
+
self.assertEqual(self.items(), ['claude-code-user', 'gas', 'unknown'])
|
|
182
|
+
self.assertEqual(self.secret('claude-code-user'), MCP_ONLY)
|
|
183
|
+
self.assertEqual(self.secret('unknown'), dict(DEAD, **MCP_ONLY))
|
|
184
|
+
|
|
185
|
+
def test_prune_keeps_siblings_carrying_mcp_state(self):
|
|
186
|
+
self.put(DEAD, 'olduser', '20260901000000') # the oldest: the no -a match
|
|
187
|
+
self.put(LIVE, 'gas', '20261019000000')
|
|
188
|
+
self.put(MCP_ONLY, 'unknown', '20261001000000')
|
|
189
|
+
(ok, line), log = self.calls(self.keychain.prune, self.d)
|
|
190
|
+
self.assertTrue(ok, line)
|
|
191
|
+
self.assertEqual(line, 'pruned: kept gas and MCP state under unknown, removed olduser')
|
|
192
|
+
self.assertEqual(self.items(), ['gas', 'unknown'])
|
|
193
|
+
self.assertFalse([c for c in log if c.startswith('add-generic-password')], log)
|
|
194
|
+
self.assertEqual(self.keychain.prune(self.d),
|
|
195
|
+
(True, 'ok: the login under gas and MCP state under unknown'))
|
|
196
|
+
|
|
197
|
+
def test_prune_skips_an_mcp_only_canonical_item(self):
|
|
198
|
+
self.put(MCP_ONLY, 'gas', '20261019000000')
|
|
199
|
+
self.put(LIVE, 'unknown', '20261001000000')
|
|
200
|
+
self.assertEqual(self.keychain.prune(self.d),
|
|
201
|
+
(True, 'skip: the item under gas holds no login (MCP state only)'))
|
|
202
|
+
self.assertEqual(self.items(), ['gas', 'unknown'])
|
|
203
|
+
|
|
204
|
+
def test_cli_probe_names_nologin_and_keeps_tokens_off_stdout(self):
|
|
205
|
+
self.put(MCP_ONLY)
|
|
206
|
+
out = subprocess.run([sys.executable, str(REPO / 'lib' / 'keychain.py'), 'probe', self.d],
|
|
207
|
+
capture_output=True, text=True, check=True).stdout
|
|
208
|
+
self.assertNotIn('mcp-', out)
|
|
209
|
+
doc = json.loads(out)
|
|
210
|
+
self.assertEqual((doc['state'], doc['has_refresh_token'], doc['expires_at']),
|
|
211
|
+
('nologin', False, None))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class AuditNoLoginTests(Fixture):
|
|
215
|
+
def setUp(self):
|
|
216
|
+
super().setUp()
|
|
217
|
+
import importlib
|
|
218
|
+
import audit
|
|
219
|
+
self.audit = importlib.reload(audit)
|
|
220
|
+
|
|
221
|
+
def test_an_mcp_only_file_is_no_login(self):
|
|
222
|
+
self.write_file(MCP_ONLY)
|
|
223
|
+
self.assertFalse(self.audit.file_has_login(str(self.cpath)))
|
|
224
|
+
login = self.audit.oauth_login(self.d, time.time())
|
|
225
|
+
self.assertEqual((login['store'], login['state'], login['doc']), (None, None, None))
|
|
226
|
+
self.assertIn('claudeAiOauth', login['reason'])
|
|
227
|
+
for doc in ({'claudeAiOauth': None, 'mcpOAuth': {}}, {'claudeAiOauth': 'x'}, {}, ''):
|
|
228
|
+
self.write_file(doc)
|
|
229
|
+
self.assertFalse(self.audit.file_has_login(str(self.cpath)), doc)
|
|
230
|
+
|
|
231
|
+
def test_the_keychain_decides_beside_an_mcp_only_file(self):
|
|
232
|
+
self.write_file(MCP_ONLY)
|
|
233
|
+
self.put(LIVE)
|
|
234
|
+
login = self.audit.oauth_login(self.d, time.time())
|
|
235
|
+
self.assertEqual((login['store'], login['state']), ('keychain', 'ok'))
|
|
236
|
+
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
|
|
237
|
+
self.assertEqual(self.audit.oauth_login(self.d, time.time())['state'], 'locked')
|
|
238
|
+
os.environ.pop('FAKE_KEYCHAIN_LOCKED')
|
|
239
|
+
self.put(MCP_ONLY) # MCP state in both places: no login anywhere
|
|
240
|
+
login = self.audit.oauth_login(self.d, time.time())
|
|
241
|
+
self.assertEqual((login['store'], login['state']), (None, None))
|
|
242
|
+
|
|
243
|
+
def test_expiry_is_read_from_the_login_object_alone(self):
|
|
244
|
+
now = time.time()
|
|
245
|
+
self.write_file(DEAD_BESIDE_MCP)
|
|
246
|
+
self.assertTrue(self.audit.file_has_login(str(self.cpath)))
|
|
247
|
+
self.assertEqual(self.audit.creds_state(str(self.cpath), now)[0], 'expired')
|
|
248
|
+
# truncated mid-write, in either key order: still the login's own numbers
|
|
249
|
+
text = json.dumps(DEAD_BESIDE_MCP, separators=(',', ':'))
|
|
250
|
+
self.write_file(text[:-30])
|
|
251
|
+
self.assertTrue(self.audit.file_has_login(str(self.cpath)))
|
|
252
|
+
self.assertEqual(self.audit.creds_state(str(self.cpath), now)[0], 'expired')
|
|
253
|
+
mcp_first = json.dumps({'mcpOAuth': {KEY: ENTRY},
|
|
254
|
+
'claudeAiOauth': DEAD_BESIDE_MCP['claudeAiOauth']},
|
|
255
|
+
separators=(',', ':'))
|
|
256
|
+
self.write_file(mcp_first[:-3])
|
|
257
|
+
self.assertEqual(self.audit.creds_state(str(self.cpath), now)[0], 'expired')
|
|
258
|
+
# a truncated live login stays in the pool (lenient, as the shim is)...
|
|
259
|
+
live = json.dumps(dict(LIVE, **MCP_ONLY), separators=(',', ':'))
|
|
260
|
+
self.write_file(live[:-20])
|
|
261
|
+
self.assertEqual(self.audit.creds_state(str(self.cpath), now)[0], 'ok')
|
|
262
|
+
# ...and a truncated document that never opens a login is no login at all
|
|
263
|
+
self.write_file(json.dumps(MCP_ONLY)[:-10])
|
|
264
|
+
self.assertFalse(self.audit.file_has_login(str(self.cpath)))
|
|
265
|
+
|
|
266
|
+
def test_audit_account_reads_mcp_state_as_no_login(self):
|
|
267
|
+
acct = {'id': 'acct-01', 'email': 'mcp@test', 'home': 'mac'}
|
|
268
|
+
self.write_file(MCP_ONLY)
|
|
269
|
+
row = self.audit.audit_account(str(self.root), acct, machine='mac')
|
|
270
|
+
self.assertEqual((row['state'], row['store']), ('missing', None))
|
|
271
|
+
self.assertIn('no Claude login', row['reason'])
|
|
272
|
+
(self.acct / 'server.token').write_text('sk-ant-oat01-' + 'T' * 60)
|
|
273
|
+
row = self.audit.audit_account(str(self.root), acct, machine='mac')
|
|
274
|
+
self.assertEqual((row['state'], row['store']), ('ok', None))
|
|
275
|
+
(self.acct / 'server.token').unlink()
|
|
276
|
+
self.write_file(DEAD_BESIDE_MCP)
|
|
277
|
+
row = self.audit.audit_account(str(self.root), acct, machine='mac')
|
|
278
|
+
self.assertEqual((row['state'], row['store']), ('expired', 'file'))
|
|
279
|
+
|
|
280
|
+
def test_a_park_heals_on_a_newer_login_never_on_newer_mcp_state(self):
|
|
281
|
+
marker = self.acct / '.expired'
|
|
282
|
+
marked = calendar.timegm((2026, 10, 1, 0, 0, 0, 0, 0, 0))
|
|
283
|
+
marker.write_text(f'{marked}\nreason=auth-error marked_at=t detail=test\n')
|
|
284
|
+
os.utime(marker, (marked, marked))
|
|
285
|
+
self.write_file(MCP_ONLY, mtime=marked + 3600)
|
|
286
|
+
self.assertTrue(self.audit.expired_marked(self.d))
|
|
287
|
+
self.write_file(dict(LIVE, **MCP_ONLY), mtime=marked + 7200)
|
|
288
|
+
self.assertFalse(self.audit.expired_marked(self.d))
|
|
289
|
+
# the Keychain: an item this session reads is judged by what it holds
|
|
290
|
+
self.cpath.unlink()
|
|
291
|
+
self.put(MCP_ONLY, mdat='20261002000000')
|
|
292
|
+
self.assertTrue(self.audit.expired_marked(self.d))
|
|
293
|
+
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1' # cannot read it: its stamp decides
|
|
294
|
+
self.assertFalse(self.audit.expired_marked(self.d))
|
|
295
|
+
os.environ.pop('FAKE_KEYCHAIN_LOCKED')
|
|
296
|
+
self.put(dict(LIVE, **MCP_ONLY), mdat='20261003000000')
|
|
297
|
+
self.assertFalse(self.audit.expired_marked(self.d))
|
|
298
|
+
# an MCP-only file beside it hides nothing: the Keychain login still heals it
|
|
299
|
+
self.write_file(MCP_ONLY, mtime=marked + 60)
|
|
300
|
+
self.assertFalse(self.audit.expired_marked(self.d))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class ReportAndExportTests(Fixture):
|
|
304
|
+
def test_report_never_calls_mcp_state_an_oauth_login(self):
|
|
305
|
+
import importlib
|
|
306
|
+
import report
|
|
307
|
+
report = importlib.reload(report)
|
|
308
|
+
self.write_file(MCP_ONLY)
|
|
309
|
+
cclass, detail = report._claude_credentials(self.d)
|
|
310
|
+
self.assertEqual((cclass, detail['oauth'], detail['oauth_store'], detail['keychain']),
|
|
311
|
+
('none', False, None, None))
|
|
312
|
+
(self.acct / 'server.token').write_text('sk-ant-oat01-' + 'T' * 60)
|
|
313
|
+
cclass, detail = report._claude_credentials(self.d)
|
|
314
|
+
self.assertEqual((cclass, detail['oauth'], detail['oauth_store']), ('portable', False, None))
|
|
315
|
+
(self.acct / 'server.token').unlink()
|
|
316
|
+
self.cpath.unlink()
|
|
317
|
+
self.put(MCP_ONLY)
|
|
318
|
+
cclass, detail = report._claude_credentials(self.d)
|
|
319
|
+
self.assertEqual((cclass, detail['oauth'], detail['keychain']), ('none', False, None))
|
|
320
|
+
self.write_file(dict(LIVE, **MCP_ONLY))
|
|
321
|
+
cclass, detail = report._claude_credentials(self.d)
|
|
322
|
+
self.assertEqual((cclass, detail['oauth'], detail['oauth_store'], detail['oauth_alive']),
|
|
323
|
+
('machine-local', True, 'file', True))
|
|
324
|
+
|
|
325
|
+
def test_export_finds_no_credential_material_in_mcp_state(self):
|
|
326
|
+
self.manifest()
|
|
327
|
+
self.write_file(MCP_ONLY)
|
|
328
|
+
cli = [sys.executable, str(REPO / 'lib' / 'credential.py'), 'export', str(self.root),
|
|
329
|
+
'claude', 'acct-01']
|
|
330
|
+
res = subprocess.run(cli, capture_output=True, text=True)
|
|
331
|
+
self.assertEqual(res.returncode, 4, res.stderr)
|
|
332
|
+
self.assertIn('no credential material', res.stderr)
|
|
333
|
+
self.write_file(dict(LIVE, **MCP_ONLY))
|
|
334
|
+
res = subprocess.run(cli, capture_output=True, text=True)
|
|
335
|
+
self.assertEqual(res.returncode, 3, res.stderr)
|
|
336
|
+
self.assertIn('.credentials.json', res.stderr)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
if __name__ == '__main__':
|
|
340
|
+
unittest.main()
|