fix: bounded IPv4-first connect, observable refresh, honest scope model - #15
Merged
Conversation
Resolves docs/HANDOVER-runtime-and-scope.md (Parts A & B), minus the explicitly-deferred Issue 4 (two-phase agent sign-in). Issue 1/2 — dual-stack connect hang. Every request went through a bare urllib.request.urlopen, which on a host with a blackholed IPv6 route tried the dead address with the full operation timeout and hung — no device code, reads never return, and the silent refresh hung too (the likely cause of "re-auth every session"). runtime._http now connects via a bounded, IPv4-first path (happy-eyeballs-lite over socket/http.client): each address tried with a short connect timeout (MSGRAPH_CONNECT_TIMEOUT, default 5s) so a dead address fails fast to a reachable one. MSGRAPH_FORCE_IPV4=1 restricts to IPv4. Stdlib only; the 30s read timeout and the single _http seam are unchanged. A successful silent refresh now prints a stderr note so renewal is observable. Issue 3 — scope honesty. AAD consent is sticky/cumulative, so a read-mode sign-in on a write-consented account returns a write-capable token. auth-login now warns on stderr when granted scopes are a write-capable superset of the requested mode (runtime._extra_write_scopes). Part B — ADR-0001 records the decision to keep one app registration and frame --mode as consent-shaping + guardrail (rejecting per-tier apps). auth-login SKILL.md and README reconciled: the unqualified "structurally cannot write" claim is replaced with the true, qualified guarantee. Tests: connect ordering + bounded fallback via a fake socket (NOT mocking the _http seam — that seam was the blind spot that hid the hang), a post-expiry refresh that issues no devicecode, and the scope-diff helper. 85 passed; ruff clean. Spec: specs/008-runtime-and-scope (gitignored, local SDD). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
neilgfoster
force-pushed
the
008-runtime-and-scope
branch
from
June 23, 2026 21:27
bd14c78 to
222b503
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves
docs/HANDOVER-runtime-and-scope.md(Parts A & B), minus the explicitly-deferred Issue 4 (two-phase agent sign-in — LOW severity, partly an environment constraint; can be filed separately if still needed after the connect-path fix).Issue 1/2 — dual-stack connect hang (HIGH)
Every request flowed through a bare
urllib.request.urlopen. On a host where DNS returns both A and AAAA records but the IPv6 route is blackholed, Python tried the dead IPv6 address with the full operation timeout and hung indefinitely — the device code never printed, reads never returned, and the silent refresh hung too (the likely root cause of the "re-auth every session" pain).runtime._httpnow connects through a bounded, IPv4-first path (happy-eyeballs-lite over stdlibsocket/http.client): addresses are resolved ourselves, ordered IPv4-first, and each is tried with a short connect timeout (MSGRAPH_CONNECT_TIMEOUT, default 5s) so a dead address fails fast to a reachable one.MSGRAPH_FORCE_IPV4=1restricts to IPv4. The 30s read timeout and the single_httpseam are unchanged. A successful silent refresh now printsmsgraph: renewed access token silentlyto stderr so renewal is observable.Issue 3 — scope honesty (MEDIUM)
Microsoft (AAD) consent is sticky/cumulative, so a
--mode readsign-in on an account that previously consented to a write tier returns a write-capable token.auth-loginnow warns on stderr when the granted scopes are a write-capable superset of the requested mode (runtime._extra_write_scopes), so the token's true capability is never hidden.Part B — honest scope model (ADR)
docs/adr/0001-scope-isolation-one-app-vs-per-tier.mdrecords the decision to keep one app registration and frame--modeas consent-shaping ergonomics + a_require_scopesguardrail (rejecting per-tier app registrations as overkill for a personal-mailbox tool, kept on record if strict isolation ever becomes load-bearing). Theauth-loginskill doc and README are reconciled: the unqualified "structurally cannot write" claim is replaced with the true, qualified guarantee — structural read-only holds only before any write mode has ever been consented.Tests
_httpseam (that seam was the blind spot that hid the hang).ruffclean; stdlib-only guard green.Developed via the repo's local SDD flow (
specs/008-runtime-and-scope, gitignored). Removesdocs/HANDOVER-runtime-and-scope.md.🤖 Generated with Claude Code