feat: two-shot mp login --start/--finish/--resume for headless environments#158
feat: two-shot mp login --start/--finish/--resume for headless environments#158jaredmixpanel wants to merge 8 commits into
mp login --start/--finish/--resume for headless environments#158Conversation
…nments Adds a non-TTY login path for Claude Cowork, CI, devcontainers, and browserless SSH where the loopback OAuth callback can't reach the user's host browser. PKCE verifier persists in ~/.mp/oauth/inflight.json (mode 0o600, 10-min TTL) across the --start/--finish boundary; the user opens the authorize URL on their host, copies the redirect URL from the "site can't be reached" address bar, and pastes it back. The new CLI paths emit JSON envelopes on stdout so slash commands and scripted wrappers can drive the flow: - state: ok on success, with project_pick metadata (method enum, primary-org survivor count, funnel counts) so the slash command can render the auto-pick result conversationally. - state: error code: NEEDS_REGION_SWITCH (exit 6, new ExitCode.NEEDS_SELECTION) when /me returns zero region-compatible projects, with the cross-region project list in details so the user can retry with --region eu/in. Auto-pick in _resolve_project (auto_pick=True only): region filter, drop demos, drop unintegrated, pick from highest-survivor-count org with lowest-id tiebreak, with fallbacks through unintegrated then demos. The legacy auto_pick=False path is preserved unchanged for same-machine `mp login`. Other fixes shipped together: - Bump default OAuthFlow httpx timeout to (30s read, 10s connect) so cold SOCKS5h handshakes inside Cowork's ALL_PROXY succeed first call. - Fix paste-reader event-queue race in flow.py via threading.Event: errors propagate in ms instead of waiting 310s for callback timeout. - Skip paste reader entirely when stdin is non-TTY (Cowork pipes). - httpx[socks] dep so socksio is pulled in by default. - Stderr heartbeat in non-TTY status_spinner so users see activity during 67s+ /me calls on busy accounts. - Preserve placeholder dir when tokens.json exists so users can `mp login --resume <PATH>` after a publish failure. Slash command (auth.md) gains a Cowork branch driving the flow via AskUserQuestion. quickstart-claude-cowork.md split into Path A (two-shot inside Cowork) and Path B (bridge from laptop). 49 new tests, all 6415 tests pass. Manual smoke against live mixpanel.com/oauth/ verified. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When `mp login --finish` succeeds at token exchange but fails at publish (bad --name, project not visible, name collision, /me parse error, etc.), the OAuth code has been consumed and re-running --finish won't work — the IdP one-time-uses authorization codes. The user must `mp login --resume <PATH>` instead, but the path was not surfaced anywhere structured. Adds LoginFinishPublishError wrapping the underlying publish failure with placeholder_dir + original code/message/details. login_unified_finish and login_unified_resume catch publish failures, clear the (now-useless) inflight, and re-raise the wrapped exception when tokens.json still exists. The CLI's --finish/--resume handlers catch it and emit a state: error code: LOGIN_FINISH_PUBLISH_FAILED envelope including placeholder_dir, original cause, and a resume_hint command for the slash command to render. NeedsRegionSwitchError is special-cased separately: cross-region is fundamental, not transient. The placeholder is rmtreed on this path to avoid leaving an orphan .tmp-* dir that can never be resumed (the user must run `mp login --start --region eu` instead). 3 new tests cover the wrap on invalid --name, the wrap on --project not visible, and the cross-region cleanup. Addresses Codex review [P2] on commit 4658d8f. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This comment was marked as resolved.
This comment was marked as resolved.
There was a problem hiding this comment.
Pull request overview
Adds a headless-friendly, two-shot OAuth login workflow to the mp CLI and supporting library layers, enabling authentication in environments where the loopback callback can’t reach the user’s browser (e.g., Claude Cowork/CI/devcontainers). This is integrated with a new auto-pick project selection algorithm, structured JSON envelopes for programmatic consumers, and several reliability fixes around OAuth timeouts and non-TTY progress signaling.
Changes:
- Introduces
mp login --start/--finish/--resumewith persisted inflight PKCE state and placeholder-based recovery, emitting machine-parseable JSON envelopes. - Refactors project resolution to return a structured
ProjectPickResultand adds headless auto-pick + cross-region handling viaNeedsRegionSwitchError(exit code 6). - Improves OAuth robustness (httpx SOCKS support + timeout bump; paste-reader race fix) and CLI UX in non-TTY environments (stderr heartbeat).
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
uv.lock |
Locks httpx with socks extra and adds socksio dependency metadata. |
pyproject.toml |
Switches dependency to httpx[socks]>=0.27 for SOCKS proxy support. |
src/mixpanel_headless/_internal/auth/client_registration.py |
Adds DOMAIN_FOR_REGION mapping used by headless project region filtering. |
src/mixpanel_headless/_internal/auth/flow.py |
Increases default httpx timeout and fixes paste-reader/callback race using threading.Event. |
src/mixpanel_headless/_internal/auth/inflight.py |
New module for inflight session persistence + placeholder helpers (--start/--finish/--resume). |
src/mixpanel_headless/accounts.py |
Implements two-shot orchestration APIs, placeholder publish tail, structured ProjectPickResult, and headless auto-pick resolution. |
src/mixpanel_headless/exceptions.py |
Adds NeedsRegionSwitchError and LoginFinishPublishError to support structured headless flows. |
src/mixpanel_headless/cli/utils.py |
Adds ExitCode.NEEDS_SELECTION=6, cross-region error mapping, and non-TTY status heartbeat behavior. |
src/mixpanel_headless/cli/commands/login.py |
Adds CLI flags and JSON envelope emission for --start/--finish/--resume, plus argument-mutex validation. |
tests/unit/test_inflight.py |
New unit tests for inflight/placeholder lifecycle and error handling. |
tests/unit/test_resolve_project_autopick.py |
New unit tests covering the auto-pick cascade, fallbacks, and cross-region raise behavior. |
tests/unit/test_flow_fixes.py |
Regression tests for httpx timeout bump and paste-reader race fix. |
tests/unit/cli/test_login_two_shot.py |
New CLI tests validating two-shot flows, envelopes, exit codes, and publish-failure recovery. |
tests/unit/cli/test_login_cli.py |
Updates legacy tests to match the new “preserve placeholder when tokens exist” contract. |
tests/unit/test_auth_flow.py |
Adjusts test stdin fixture semantics (isatty) to align with paste-reader gating. |
tests/unit/test_loc_budget.py |
Updates auth subsystem file/LOC caps for the added two-shot implementation. |
mixpanel-plugin/docs/quickstart-claude-cowork.md |
Splits Cowork setup into two paths (two-shot login vs bridge file), with troubleshooting guidance. |
mixpanel-plugin/commands/auth.md |
Documents same-machine vs headless routing and the JSON envelope-driven two-shot flow. |
This comment was marked as resolved.
This comment was marked as resolved.
|
You have a whole team reviewing this lolol |
Real fixes: - inflight: read_placeholder_meta raises on corrupt/non-dict (only returns None for absent meta) so an EU/IN placeholder with bad meta no longer defaults to "us" and gets cleaned up via NeedsRegionSwitchError, which destroyed recoverable tokens. load_inflight rejects schema_version newer than INFLIGHT_SCHEMA_VERSION with OAUTH_INFLIGHT_SCHEMA_TOO_NEW. - accounts: --resume now requires the placeholder to live under accounts_root() (path-traversal guard). Bad meta region raises loud ConfigError instead of falling back to "us". Region default to "us" preserved for legacy placeholders without meta.json. - accounts: rename _PublishResult -> public LoginFinishResult, export via __all__, update CLI annotation. - accounts: PickMethod gains "sole_survivor_filtered". The filtered short-circuit in _auto_pick_from_filtered now uses it so the slash command can render "the only non-demo, integrated project" rather than misleading "your only active project". The line 2684 short- circuit (region_n == 1) keeps "sole_survivor". - accounts: docstrings on _is_demo / _is_integrated. - flow: OAuthFlow gets http_client / storage public properties + __enter__ / __exit__ / close. login_unified_start and login_unified_finish now use `with OAuthFlow(...)` so the default httpx.Client is closed after DCR. Drops the SLF001 noqa. - flow: secrets.compare_digest for CSRF state comparison. - login.py: _emit_json swaps default=str for an explicit encoder that accepts only datetime / Path. A future refactor that drops a token, SecretStr, or other surprise into the envelope now fails loudly. - auth.md: render the new sole_survivor_filtered method. - test_loc_budget: docstring -> repo-relative spec path. Tests: - read_placeholder_meta: corrupt + non-dict raise; missing still returns None. - load_inflight: schema_version > current raises OAUTH_INFLIGHT_SCHEMA_TOO_NEW. - _resolve_project: sole_survivor_filtered when demo gets filtered out. - --resume: path outside accounts_root() rejected; corrupt meta does not delete the placeholder. - OAuthFlow: http_client / storage properties, context-manager close, idempotent close. - test_login_two_shot: EU URL hostname check via urlparse (CodeQL). Skipped (with reasoning in the plan file): - CodeQL clear-text-logging at inflight.py:234 / accounts.py:1913 / login.py:301 are false positives — only path + OSError logged, no secrets in envelopes. Recommend dismissing in the GitHub Security UI. - "done_event makes first error win" is a misread; the wait pulls from result_q first, so the first SUCCESS wins. - _parse_pasted_redirect double-`?` is theoretical only; real OAuth providers don't emit that shape. - Splitting accounts.py (~3000 lines) is out of scope. just check passes (lint + format + mypy --strict + 6428 tests, 91.88% coverage, build). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks for the reviews. Pushed Real fixes landed in
|
|
Thanks for the clear summary. The approach looks well-considered — |
Picks up #156 (dependabot config + uv exclude-newer cooldown) and #157 (PyPI Trusted Publishers + drop devcontainer). uv.lock conflict: resolved by taking origin/main's resolution and re-running uv lock against the merged pyproject.toml. Result keeps both sides' intent — httpx[socks] from this branch (Cowork SOCKS proxy support) plus the hypothesis[cli]>=6.151.13 bump from main. socksio v1.0.0 added as the transitive dep behind the [socks] extra. just check passes: 6428 tests, lint, format, mypy --strict, build. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Patch bump for the two-shot mp login work and review-feedback fixes landing in PR #158. Library version (`__version__` in `src/mixpanel_headless/__init__.py`, sourced by `tool.hatch.version`) and plugin version (`mixpanel-plugin/.claude-plugin/plugin.json`) both 0.1.0 -> 0.1.1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
uv.lock conflict on `[options].exclude-newer` — took main's newer 2026-05-07 timestamp and re-ran `uv lock`, which re-added socksio v1.0.0 behind httpx[socks] cleanly. No package version drift. Other files merged automatically: .github/workflows/codex-review.yml, src/mixpanel_headless/_internal/io_utils.py, tests/unit/test_io_utils.py. just check passes: lint, format, mypy --strict, 6428 tests, 91.88% coverage, build (mixpanel_headless-0.1.1.tar.gz + .whl). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review ✅ Approved 1 resolved / 1 findingsImplements two-shot ✅ 1 resolved✅ Edge Case:
|
| Auto-apply | Compact |
|
|
Was this helpful? React with 👍 / 👎 | Gitar
Summary
mp login --start/--finish/--resumeflow for headless environments (Claude Cowork, CI runners, devcontainers, browserless SSH) where the loopback OAuth callback can't reach the user's host browser. PKCE verifier persists in~/.mp/oauth/inflight.json(mode 0o600, 10-min TTL) across the boundary; user opens authorize URL on host, copies the redirect URL from the address bar, pastes back._resolve_projectfor the headless path: region filter → drop demos → drop unintegrated → primary-org-lowest-id, with fallback through unintegrated then demos. Legacyauto_pick=Falsepath preserved unchanged for same-machinemp login.LoginFinishPublishErrorsurfaces the placeholder path on post-exchange publish failures so users (and slash commands) know tomp login --resume <PATH>instead of re-running--finish(which would fail at the IdP — codes are one-time-use).httpx[socks]dep so SOCKS5h handshakes work first call. DefaultOAuthFlowtimeout bumped to(30s read, 10s connect). Paste-reader event-queue race inflow.pyfixed viathreading.Event(errors propagate in ms instead of waiting 310s for callback timeout).status_spinnerso users see activity during slow/mecalls (67s+ on busy accounts).auth.md) gains a Cowork branch driving the flow viaAskUserQuestion.quickstart-claude-cowork.mdsplit into Path A (two-shot inside Cowork) and Path B (bridge from laptop).ExitCode.NEEDS_SELECTION = 6for the cross-region case. JSON envelopes on stdout for the new paths so slash commands and scripted wrappers can drive the dance.Test plan
test_inflight.py,test_resolve_project_autopick.py,test_login_two_shot.py,test_flow_fixes.py, plus regression patches to existing files)just checkpasses (lint + format + typecheck + tests + docstring coverage 99%+ + build)mixpanel.com/oauth/:--startemits expected JSON, inflight file at mode 0o600,--region euproduceseu.mixpanel.comURL,--finishwithout--startraisesOAUTH_INFLIGHT_MISSING(exit 2), flag mutex rejected at exit 3[P1](file present in patch) resolved by initial commit;[P2](placeholder discovery on post-exchange failure) addressed byLoginFinishPublishError+ 3 new tests🤖 Generated with Claude Code