Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ wheels/
.env.local
AGENTS.local
/tests_local
/tasks/
uv.toml
.idea/*
.superpowers/
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ GitHub Releases page; `0.8.0` is the new starting line.
- Discover each provider's models from a cached, typed, provider-neutral models.dev catalog with curated fallbacks, so xAI, GitHub Copilot, and Snowflake pick up new models automatically; the catalog is now portable across platforms (no longer Unix-only) and distinguishes fresh, cached, stale, disabled, and unavailable results.
- Persist provider login and logout atomically so a failed save never leaves orphaned credentials or a half-applied configuration; a re-login whose save fails now restores the previous credential instead of deleting it, and provider persistence failures are logged.
- Fix queued follow-up input showing a bordered ghost; pressing Enter during an active turn now shows one intentional queued row.
- Harden provider OAuth credential handling: scope token refresh to the active/selected provider, serialize persistence under a lock with atomic config replacement, roll back replaced credentials when a save fails, fail closed on credential migration, and validate OAuth token and implicit-state responses.
- Fix an intermittent doubled/"ghost" copy of the running-prompt block (agent tree, spinner, and tip) on long streaming turns: after a scrollback handoff the suppressed live body now waits for the terminal's re-requested absolute cursor position to settle before it re-expands, so it repaints against a correct cursor model instead of the mis-anchored frame left behind by `run_in_terminal`.

## 0.60.0 (2026-07-18)

Expand Down
2 changes: 2 additions & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ GitHub Releases page; `0.8.0` is the new starting line.
- Discover each provider's models from a cached, typed, provider-neutral models.dev catalog with curated fallbacks, so xAI, GitHub Copilot, and Snowflake pick up new models automatically; the catalog is now portable across platforms (no longer Unix-only) and distinguishes fresh, cached, stale, disabled, and unavailable results.
- Persist provider login and logout atomically so a failed save never leaves orphaned credentials or a half-applied configuration; a re-login whose save fails now restores the previous credential instead of deleting it, and provider persistence failures are logged.
- Fix queued follow-up input showing a bordered ghost; pressing Enter during an active turn now shows one intentional queued row.
- Harden provider OAuth credential handling: scope token refresh to the active/selected provider, serialize persistence under a lock with atomic config replacement, roll back replaced credentials when a save fails, fail closed on credential migration, and validate OAuth token and implicit-state responses.
- Fix an intermittent doubled/"ghost" copy of the running-prompt block (agent tree, spinner, and tip) on long streaming turns: after a scrollback handoff the suppressed live body now waits for the terminal's re-requested absolute cursor position to settle before it re-expands, so it repaints against a correct cursor model instead of the mis-anchored frame left behind by `run_in_terminal`.

## 0.60.0 (2026-07-18)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,21 @@ Resolve every validated review defect on PR #215 without broad auth or configura
5. Log curated-model fallback both when catalog status is non-authoritative and when an authoritative catalog yields no usable provider models.
6. Preserve the existing OpenCode Go unavailable-catalog regression test and resolve its stale review thread with evidence.

Out of scope: provider protocol redesigns, replacing DigitalOcean's provider-required implicit grant, Snowflake Cortex chat transforms, global config merge semantics unrelated to these auth transactions, and new dependencies.
Out of scope: provider protocol redesigns, replacing DigitalOcean's provider-required implicit grant, Snowflake Cortex chat transforms, global config merge semantics outside these auth transactions, and new dependencies.

## Design

### Credential filenames

Keep the legacy filename for ordinary one-segment OAuth keys so existing credentials remain readable. Encode the complete relative key for multi-segment keys into a deterministic, filesystem-safe filename, and apply the same mapping to lock files. This makes the mapping injective without changing released flat-key paths.
Keep the legacy filename for canonical lowercase `oauth/<safe-segment>` keys so existing credentials remain readable. Encode every other complete key as lowercase hex beneath a dedicated `credentials/v2/` directory, and apply the same mapping to lock files. This keeps identities injective on case-insensitive filesystems without changing released flat-key paths.

Keyring migration reads and validates the credential, writes the file copy, confirms keyring deletion, and only then changes the config reference to file storage. A missing credential leaves the keyring reference unchanged. Backend read/delete errors are safe typed failures; failed cleanup rolls back the file copy and reports a combined failure if rollback also fails.

### Persistence transaction

Expose async persistence helpers to provider login/logout callers. Each helper offloads one complete synchronous transaction to a worker thread. The transaction acquires a bounded, fail-closed inter-process lock derived from the default config path before it snapshots credentials/config, mutates state, writes, or rolls back.
Expose async persistence helpers to provider login/logout callers. Each helper offloads one complete synchronous transaction to a worker thread. The transaction acquires a bounded, fail-closed inter-process lock derived from the default config path before it snapshots credentials/config, mutates state, writes, or rolls back. Login, logout, replacement, migration, and refresh also share sorted per-credential locks beneath the config lock.

Cancellation uses an atomic phase handshake: cancellation before mutation leaves no effects; cancellation after mutation waits for the owned worker transaction to settle before re-raising. Lock contention is a typed failure and never falls back to an unlocked write.

Config writes use a temporary file in the target directory, flush and fsync it, apply private permissions, and atomically replace the destination. A failed write therefore leaves the previous config file intact.

Expand All @@ -38,6 +42,20 @@ Logout writes the config removal first, then deletes credentials. If deletion fa

The config-scoped lock covers snapshot through rollback, so a failed concurrent login cannot restore a token observed before another successful transaction.

The transaction reloads the authoritative default config after acquiring the lock, applies the requested provider mutation to that fresh object, persists it, and only then synchronizes the caller's in-memory config. This prevents two serialized sessions with stale `Config` objects from silently replacing each other's provider changes.

An account-scoped provider replacement supplies its provider key to the login transaction, which resolves the previous OAuth reference from the authoritative config while holding the lock. Equal credential keys skip cleanup even if storage metadata differs. After the new token and config commit, the transaction removes a genuinely replaced credential; rollback ordering preserves at least one loadable config/credential pair before surfacing an explicit persistence error.

Background model discovery works on a deep copy, records the provider identity used for each request, and applies collected results through the same authoritative config transaction. Results are skipped if the target provider was logged out or replaced while discovery was in flight; unrelated concurrent provider changes are preserved. Snowflake logout likewise resolves the current account credential from the authoritative locked provider entry rather than trusting a stale caller.

### Active-provider refresh

Runtime refreshes derive the OAuth reference from the active model's provider and never iterate unrelated configured providers. Provider-catalog refresh and title generation pass their already-selected provider reference explicitly. Untargeted compatibility callers retain the existing aggregate behavior only when they provide neither a runtime nor a reference.

### Token response validation

`OAuthToken.from_response()` is the shared trust boundary for provider token responses. It requires a non-empty string access token, accepts an omitted lifetime as unknown, rejects supplied boolean, negative, non-finite, or otherwise malformed lifetimes, and rejects non-string refresh tokens. Provider iterators translate those typed errors into safe error events before any persistence or success event.

### OAuth callback validation

For implicit callbacks, parse `state` and compare it with the expected value before interpreting `error`. RFC 6749 requires the original state on both successful and error responses. A wrong-state error is rejected as `OAuthStateMismatch`, not accepted as a user denial.
Expand All @@ -46,6 +64,12 @@ For implicit callbacks, parse `state` and compare it with the expected value bef

DigitalOcean continues to persist a valid OAuth token when router discovery is empty or unavailable, matching the approved PR scope. Its terminal success text distinguishes “router configured” from “credentials saved with no routers configured.”

Router discovery treats only an actually empty router list as authoritative empty data. An all-invalid non-empty list is malformed; a mixed list is partial and keeps valid router names while emitting an explicit degraded-status event.

The implicit loopback callback rejects malformed or negative body lengths and returns `413 Payload Too Large` before reading any body above 64 KiB. Empty and whitespace-only access tokens fail before catalog access, persistence, or success output.

Snowflake supplies its ten-minute default lifetime only when `expires_in` is absent or `None`; explicit falsy values retain the shared token validator's normal semantics.

Copilot, xAI, and Snowflake log when they use curated models because the catalog is non-authoritative or because authoritative conversion is empty. Logs include status/source only, never credentials.

## Tests
Expand All @@ -61,6 +85,19 @@ Use red-green TDD for each behavior:
- DigitalOcean degraded success does not name an unrelated model;
- empty authoritative catalogs emit fallback logs;
- unavailable OpenCode Go catalog coverage remains green.
- inactive configured OAuth providers cannot abort refresh of the active runtime provider;
- independently loaded configs preserve both serialized provider updates;
- Snowflake account replacement removes the prior account credential transactionally;
- malformed shared token responses fail before persistence or success;
- malformed and partial DigitalOcean router payloads remain distinguishable;
- oversized implicit callback bodies fail before allocation or blocking reads.
- lock contention and cancellation cannot produce late or unlocked persistence;
- credential and lock paths remain injective across prefixed, nested, case-variant, and unsafe keys;
- keyring read/delete/migration/rollback failures leave config and credentials truthful;
- stale model discovery cannot resurrect a logged-out or replaced provider;
- Snowflake logout deletes the authoritative account credential even from a stale caller;
- blank implicit callback tokens fail before DigitalOcean persistence;
- Snowflake expiry defaulting preserves explicit falsy values for shared validation.

After focused tests, run `make check-pythinker-code`, `make test-pythinker-code`, and `git diff --check`.

Expand Down
22 changes: 20 additions & 2 deletions src/pythinker_code/auth/digitalocean.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class RouterDiscovery(str, Enum):
"""Outcome of a DigitalOcean inference-router discovery call."""

OK = "ok" # routers were discovered
PARTIAL = "partial" # valid routers were discovered alongside malformed entries
EMPTY = "empty" # the account has no routers (valid, but empty)
UNAUTHORIZED = "unauthorized" # the token could not list routers
UNAVAILABLE = "unavailable" # timeout / outage / non-2xx response
Expand Down Expand Up @@ -92,15 +93,22 @@ def _parse_router_names(payload: object) -> RouterCatalog:
raw = cast(dict[str, Any], payload).get("model_routers")
if not isinstance(raw, list):
return RouterCatalog(RouterDiscovery.MALFORMED, ())
if not raw:
return RouterCatalog(RouterDiscovery.EMPTY, ())

names: list[str] = []
has_malformed_entry = False
for item in cast(list[Any], raw):
if isinstance(item, dict):
name = cast(dict[str, Any], item).get("name")
if isinstance(name, str) and name:
if isinstance(name, str) and name.strip():
names.append(name)
continue
has_malformed_entry = True
if not names:
return RouterCatalog(RouterDiscovery.EMPTY, ())
return RouterCatalog(RouterDiscovery.MALFORMED, ())
if has_malformed_entry:
return RouterCatalog(RouterDiscovery.PARTIAL, tuple(names))
return RouterCatalog(RouterDiscovery.OK, tuple(names))


Expand Down Expand Up @@ -141,8 +149,18 @@ async def _fetch_router_catalog(access_token: str) -> RouterCatalog:
def _router_status_message(status: RouterDiscovery) -> str | None:
if status is RouterDiscovery.OK:
return None
if status is RouterDiscovery.PARTIAL:
return (
"DigitalOcean returned some malformed inference-router entries; sign-in saved "
"with valid routers configured and malformed entries ignored."
)
if status is RouterDiscovery.EMPTY:
return "DigitalOcean returned no inference routers; sign-in saved with no models."
if status is RouterDiscovery.MALFORMED:
return (
"DigitalOcean returned entirely malformed inference-router data; sign-in saved "
"with no models configured."
)
if status is RouterDiscovery.UNAUTHORIZED:
return (
"DigitalOcean did not authorize inference-router discovery; sign-in saved. "
Expand Down
Loading
Loading