Skip to content

feat(auth): recover owner-held expired tokens - #166

Open
possibilities wants to merge 5 commits into
realiti4:mainfrom
possibilities:feat/recover-expired-token
Open

feat(auth): recover owner-held expired tokens#166
possibilities wants to merge 5 commits into
realiti4:mainfrom
possibilities:feat/recover-expired-token

Conversation

@possibilities

@possibilities possibilities commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add explicit cswap recover <num|email> --json for ordinary owner-held expired OAuth access tokens
  • ask Claude Code in the exact live owner profile to perform its native synchronized refresh with one bounded, no-tools, non-persistent Haiku request
  • re-read the same owner and require matching identity plus a non-expired OAuth credential before reporting recovery
  • keep browser login, dead refresh-token lineages, API/setup tokens, profile drift, and ambiguous ownership human-only

Closes #165.

Safety boundary

list, status, watch, auto, and the TUI remain read-oriented and never invoke recovery. The command does not directly rotate an owner-held refresh token, read a slot backup as owner truth, bootstrap a profile, or fetch/write usage outside the existing collector claim protocol.

Recovery is single-flight per slot and checks owner/profile/credential identity immediately before the request and again afterward. A manual /login after the final preflight is the residual external race; the post-run identity check detects drift. macOS Keychain failures fail closed instead of falling through to a potentially stale plaintext seed.

The canary runs with safe mode, no tools, no session persistence, one low-effort Haiku turn, sealed auth/provider environment, neutral cwd, ignored stdio, a hard deadline, and process-tree cleanup. It may be billable, so recovery is explicit and documented.

JSON contract

{"schemaVersion":1,"operation":"recover","accountNumber":2,"recoveryStatus":"recovered"}

recoveryStatus is one of recovered, not_needed, retry_later, or human_required. The payload contains no email, credential, path, PID, provider output, or exception text. recovered proves credential recovery only; callers continue to use normal list --json output as usage authority.

Validation

  • uv run pytest tests/test_recovery.py tests/test_cli.py tests/test_session.py -q275 passed
  • broad deterministic suite excluding the six known local macOS move/swap baseline failures — 1561 passed, 3 skipped, 6 deselected
  • full suite — 1561 passed, 3 skipped, 6 baseline failures
  • python -m py_compile — passed
  • git diff --check — passed

No real account, credential, or Claude request is used by the tests.

Follow-up

Also fixes #187: matching idle session profiles remain authoritative after access-token expiry, and stale backup invalid_grant can no longer falsely quarantine their newer lineage.

@possibilities

possibilities commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Live validation on Claude Code 2.1.218 exposed one missing process-launch input in the sealed canary environment: omitting USER and LOGNAME makes Claude's macOS Keychain account lookup report Not logged in · Please run /login even though the exact session-profile credential is readable and ordinarily refreshable. Commit a0f66a4 preserves those two non-secret identity variables while continuing to scrub auth/provider/proxy overrides. Focused suite remains 275/275, the integrated stack is 703/703, and the live expired credential then returned recovered and produced a fresh normal usage observation.

@possibilities
possibilities force-pushed the feat/recover-expired-token branch 2 times, most recently from 3477446 to f7f13f3 Compare July 25, 2026 16:23
@possibilities

Copy link
Copy Markdown
Contributor Author

Windows CI exposed one remaining portability defect in the test added by f7f13f3: test_exact_argv_default_env_and_neutral_cwd still unconditionally asserted the POSIX-only USER/LOGNAME canary environment. Production code already selects the Windows allowlist correctly. fb03f5a makes the assertion platform-specific (Windows verifies those variables are absent and uses _WINDOWS_ENV_ALLOWLIST; POSIX retains the Keychain assertions). Focused recovery/CLI/session suite passes locally: 278 passed. Published with an exact lease against f7f13f3; CI is rerunning.

@possibilities

Copy link
Copy Markdown
Contributor Author

Rebased onto current main, past #196. CI green on all three jobs.

Three notes from the rebase:

  1. The quarantine loop in _collect_usage needed a real merge, not a resolution. Never consume a stale refresh token; never quarantine on one that was #196's fingerprint-bound _entry_token_dead and strike-healing branch are kept intact; this PR's contribution now sits in a _dead_lineage_sentinel helper called from the dead branch. The two are complementary: Never consume a stale refresh token; never quarantine on one that was #196 decides whether a stored lineage is dead, this decides what quarantine that earns — an idle slot whose session profile still holds a matching, expired-but-refreshable credential surfaces as recoverable rather than demanding a browser re-login.

  2. scan_live_sessions's rename caught a live bug here — thank you for the deliberate rename and the docstring explaining it. recovery.py was exactly the call site described: bool(live_sessions_for(...)) would have read the new tuple as unconditionally truthy. It now uses profile_is_quiescent, which is also the correct semantics for this path: an unreadable session record means recovery declines to launch its canary rather than racing a live profile.

  3. README.md follows main's current structure (the Theme section is gone upstream).

Happy to split (1) out for separate review if you'd prefer to look at the interaction with #196 on its own.

- add an explicit JSON-only recovery command using Claude's native refresh path
- verify owner identity and credential state without rotating stored backups
- bound canary execution, process-tree cleanup, and PII-free outcomes

Refs realiti4#165

Job-Id: 825aae3c-3d9c-49f9-b09f-030810e265f8
Keeper-Commit-Id: keeper-commit-work:4ccd082e-55ef-4a48-8101-72fcdd8d08c8
Keep USER and LOGNAME in the sealed canary environment so Claude Code can resolve the session profile's macOS Keychain credential instead of appearing logged out.

Job-Id: 825aae3c-3d9c-49f9-b09f-030810e265f8
Keeper-Commit-Id: keeper-commit-work:89db0502-ac8e-4034-9739-d021ed9ef927
Keep matching session profiles authoritative while idle, prevent stale backup invalid_grant from falsely quarantining their lineage, and allow the bounded native canary to clear backup-derived quarantine after freshness proof.
Assert Windows process-group creation separately and gate POSIX-only signal tests so the recovery contract runs cleanly on every CI host.
@possibilities
possibilities force-pushed the feat/recover-expired-token branch from 1b2c078 to b23a3a3 Compare August 13, 2026 16:24
@possibilities

Copy link
Copy Markdown
Contributor Author

Hi! Friendly bump on this one — I've just rebased onto current main (resolving against the profile-scoping changes from #237) and all three checks are green. If you have time to take a look I'd really appreciate it. Happy to make any changes you'd like.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Explicit recovery for owner-held expired OAuth access tokens bug: idle session expiry can falsely quarantine account backup

1 participant