Skip to content

feat: disconnect any provider, purging cached secret + identity (task 004)#5

Merged
ogrodev merged 2 commits into
mainfrom
task/004-disconnect-provider
Jun 2, 2026
Merged

feat: disconnect any provider, purging cached secret + identity (task 004)#5
ogrodev merged 2 commits into
mainfrom
task/004-disconnect-provider

Conversation

@ogrodev
Copy link
Copy Markdown
Owner

@ogrodev ogrodev commented Jun 2, 2026

TL;DR

Implements task 004 — disconnect a provider without restart. Adds a disconnect
action for every connected source. Disconnecting purges every secret MLT cached for
that source under its own keychain service
, forgets the cached account identity,
and clears consent — so the tile disappears and refresh stops immediately, with no
restart, and the source can be reconnected afterwards.

Security invariant preserved end-to-end: only copies we wrote are deleted; the
vendor's own credential store (e.g. Claude Code's keychain item) is only ever read,
never written or deleted (ADR 0012 / 0016).

Closes task 004. All four acceptance criteria ticked in
docs/tasks/004-disconnect-provider.md.

⚠️ Reviewer note — one adjacent behaviour change

Beyond the disconnect feature, this branch also changes the Claude OAuth refresher
(crates/core/src/providers/claude.rs): when Claude Code's own token is fresh it is now
returned as-is, without reading our keychain cache, so the OS no longer prompts for
keychain access on every refresh. The cache (and a network refresh) are consulted only
when the vendor token is missing or stale. It's adjacent to task 004 (it surfaces while
exercising connect/reconnect), but it is a real behaviour change — please review it on its
own merits, not as "cleanup". Covered by a new test that fails loudly if the cache is read.

What changed, by layer (dependency order)

Layer Files Change
Core port crates/core/src/ports.rs IdentityStore gains clear_identity (idempotent).
Core domain crates/core/src/sources.rs SourceDescriptor.oauth_cache_key + cached_secret_keys() — the exact set of keys MLT itself cached for a source.
Core provider crates/core/src/providers/claude.rs Refresher fast-path (see reviewer note above).
Adapter crates/adapters/src/identity.rs FileIdentityStore::clear_identity — write-through delete with rollback, mirroring set_identity.
Tauri wiring src-tauri/src/lib.rs remove_api_keydisconnect_source (now works for any source kind); shared disconnect() core; set_source_enabled disable path purges instead of de-consenting.
UI src/lib/usage.ts, src/routes/+page.svelte removeApiKeydisconnectSource; button label "Remove" → "Disconnect".
Docs (tracking) docs/PRD.md, docs/tasks/004-disconnect-provider.md Mark task 004 done (also required by the pre-commit task-gate).
Tooling (unrelated-ish) .omp/extensions/mlt-tasks/index.ts /mlt:start-task now inlines the task spec and hands it straight to the agent. Pure local dev tooling — no app behaviour.

Where to start reading

  1. crates/core/src/sources.rscached_secret_keys() defines what disconnect purges.
  2. src-tauri/src/lib.rsdisconnect() is the shared core; note secrets are purged
    before consent is cleared
    , so a keychain failure leaves the source still connected
    rather than half-removed.
  3. crates/core/src/providers/claude.rs — the refresher fast-path (reviewer note above).

Risk & invariants

  • Never deletes the vendor's store. cached_secret_keys() returns only api_key.<id>
    and/or our oauth.<provider> copy — never the vendor's own item. Asserted by
    catalog_declares_each_source_self_cached_secret and
    cached_secret_keys_lists_only_what_mlt_caches_itself.
  • Idempotent & scoped. Disconnecting twice, or a source with nothing cached, is a
    no-op success; another source's secret is left untouched
    (disconnect_is_idempotent_and_scoped_to_this_sources_own_keys).
  • Ordering. Secret purge precedes consent clear → no half-disconnected state on
    keychain error.

Test coverage (all new/affected, green locally)

  • mlt-core: cached_secret_keys_lists_only_what_mlt_caches_itself,
    catalog_declares_each_source_self_cached_secret,
    refresher_skips_the_cache_when_the_vendor_token_is_fresh.
  • mlt-adapters: clear_forgets_an_identity_and_persists_the_removal,
    clear_is_idempotent_for_an_absent_source.
  • mlt (tauri): disconnect_purges_an_api_key_sources_secret_and_consent,
    disconnect_purges_a_reused_logins_cached_oauth_copy_and_identity,
    disconnect_is_idempotent_and_scoped_to_this_sources_own_keys.
  • Gates: make lint (clippy -D warnings), make purity, and make test (pre-push) all pass.

Manual QA

Per the task doc: disconnect a provider, then confirm via Keychain Access that our
cached item (com.bigshotpictures.mlt) is gone and the vendor's own item is untouched,
the tile disappears without a restart, and the provider reconnects cleanly.

References


Summary by cubic

Add a disconnect action for any provider that purges our cached secret(s), clears consent, and forgets identity so the tile disappears immediately without a restart. Meets task 004 and never touches vendor credential stores.

  • New Features

    • Disconnect any provider via disconnect_source or by disabling consent: purge our keychain entries (api_key.* and any oauth.* we cached), clear consent, then call IdentityStore::clear_identity. Takes effect instantly; providers can be reconnected. UI: "Remove" → "Disconnect"; removeApiKeydisconnectSource.
    • Safety: only our com.bigshotpictures.mlt items are deleted; operation is idempotent and scoped to the selected source. Ordering hardened to purge → clear consent → forget identity to avoid an enabled-but-keyless state.
  • Refactors

    • Claude OAuth refresher: when Claude Code’s token is fresh, use it without reading our cache; consult cache/refresh only if missing or stale (avoids macOS keychain prompts).
    • Core: added IdentityStore::clear_identity; SourceDescriptor.oauth_cache_key and cached_secret_keys() declare exactly what to purge. remove_api_key generalized to disconnect_source, and set_source_enabled’s disable path routes through the shared disconnect flow.

Written for commit 4d657bb. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Disconnect capability now available for all sources—clears cached credentials and removes provider tiles immediately; sources remain fully reconnectable without restarting.
    • Task specifications are now automatically inlined in agent prompts when tasks are selected.
  • Documentation

    • Updated documentation to reflect completion of the disconnect feature.

… 004)

Adds a disconnect action for every connected source. Disconnecting purges
every secret MLT cached for that source under its OWN keychain service (a
user-entered API key and/or a refreshed-OAuth copy), forgets the cached
account identity, and clears consent — so the tile disappears and refresh
stops immediately, without a restart, and the source can be reconnected
afterwards. The vendor's own credential store is only ever read, never
written or deleted (ADR 0012/0016).

- core: SourceDescriptor gains oauth_cache_key + cached_secret_keys() (the
  exact set of self-cached keys to purge); IdentityStore gains clear_identity
  (write-through delete with rollback, mirroring set_identity).
- tauri: rename remove_api_key -> disconnect_source, generalised to any source
  kind; set_source_enabled's disable path now routes through the shared
  disconnect() core (purges secret + identity, then clears consent) instead of
  merely de-consenting.
- claude (adjacent behaviour change): the OAuth refresher now returns a fresh
  vendor token as-is WITHOUT reading our keychain cache, avoiding a macOS
  keychain prompt on every refresh; the cache is consulted only when the
  vendor token is missing or stale.
- ui: "Remove" -> "Disconnect"; removeApiKey -> disconnectSource.
- docs: mark task 004 done (PRD §4 + task doc).

Closes task 004.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 2, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements account disconnect functionality across both OAuth and API-key sources, unifying the removal flow under a single command that purges cached secrets and clears identity state. It also enhances the task extension to inline specification documents and hand tasks directly to the agent.

Changes

Unified Account Disconnect Flow

Layer / File(s) Summary
Identity clearing contract and implementation
crates/core/src/ports.rs, crates/adapters/src/identity.rs, crates/core/src/providers/claude.rs, crates/core/src/sources.rs
IdentityStore trait gains clear_identity method; FileIdentityStore implements removal with write-through persistence and idempotence; test fakes support clearing.
Source descriptor caching metadata
crates/core/src/sources.rs
SourceDescriptor adds oauth_cache_key field and cached_secret_keys() method to enumerate keychain entries per source; catalog configures keys for Claude OAuth and OpenRouter API-key; tests verify enumeration across credential types.
Claude refresher cache optimization
crates/core/src/providers/claude.rs
ClaudeOAuthRefresher::load() fast-returns fresh bootstrap tokens without touching the secret cache; otherwise refreshes and persists; test asserts cache bypass for fresh vendor tokens.
Unified backend disconnect implementation
src-tauri/src/lib.rs
New disconnect(...) helper centralizes secret purging and identity clearing; disconnect_source command replaces remove_api_key for both credential kinds; set_source_enabled rejects API-key toggling and routes disable through disconnect; tests cover API-key, OAuth, and idempotence using in-memory MemIdentity store.
Frontend disconnect API and UI integration
src/lib/usage.ts, src/routes/+page.svelte
New disconnectSource(id) invokes backend disconnect_source command; replaces removeApiKey; page imports new function, adds disconnectKeySource() handler, and changes API-key button from "Remove" to "Disconnect".
Documentation status updates
docs/PRD.md, docs/tasks/004-disconnect-provider.md
PRD checklist marks disconnect done; task doc updates header and acceptance criteria to reflect completion of tile removal, secret purging, and reconnectability.

Task Spec Inlining and Handoff

Layer / File(s) Summary
Task spec inlining and agent handoff
.omp/extensions/mlt-tasks/index.ts
Adds readTaskSpec to load task documents and startPrompt to build prompts with inlined spec or read-first instruction; updates startTask to send prompt via pi.sendUserMessage with immediate or queued delivery based on idle state; read errors are non-fatal.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • ogrodev/MLT#2: Introduces the per-source consent model that this PR modifies with the unified disconnect flow through set_source_enabled and the new disconnect_source command.
  • ogrodev/MLT#4: Establishes the identity caching and API-key connection infrastructure that this PR extends by adding clear_identity and implementing secret purging through the new unified disconnect path.

Poem

A rabbit hops through secret stores,
Clearing keys and closing doors—
Identity and cache alike dissolve,
While tasks now in agent hands resolve. 🐰✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title directly matches the primary change: implementing a disconnect action for providers that purges cached secrets and identity, as detailed across all modified files and confirmed by the PR objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task/004-disconnect-provider

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
.omp/extensions/mlt-tasks/index.ts (1)

67-68: 💤 Low value

Inlined spec can break the surrounding code fence.

The spec is wrapped in a 4-backtick ````markdown fence. A task doc that itself contains a fence of 4+ backticks would terminate the wrapper early and corrupt the prompt structure handed to the agent. Three-backtick fences are safe, but consider computing a fence longer than any run of backticks present in spec to be robust.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.omp/extensions/mlt-tasks/index.ts around lines 67 - 68, The current
lines.push call in .omp/extensions/mlt-tasks/index.ts inlines task spec using a
fixed 4-backtick fence which can be broken if spec contains equal-or-longer
backtick runs; update the logic around the spec rendering (where spec is used
and where lines.push is called) to compute the longest consecutive backtick
sequence in spec and generate a fence of length (maxRun + 1), then use that
fence (e.g., fence + 'markdown' and closing fence) instead of the hardcoded four
backticks so the wrapper cannot be prematurely terminated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.omp/extensions/mlt-tasks/index.ts:
- Line 185: Await or handle rejections from pi.sendUserMessage called with
startPrompt(task, branch, spec) so transport/agent errors don't become unhandled
and the UI toast reflects real delivery status; update the call at
pi.sendUserMessage(startPrompt(task, branch, spec), idle ? undefined : {
deliverAs: 'followUp' }) to either await the promise (inside the surrounding
async function) or attach .catch(...) and on success/failure update the toast
accordingly, ensuring any thrown error is propagated to the command’s existing
error handling path.

In `@src-tauri/src/lib.rs`:
- Around line 195-203: The current flow deletes cached secrets via
descriptor.cached_secret_keys()/secrets.delete(...) then returns early if
identity.clear_identity(...) or consent.set_enabled(..., false) fails, which can
leave consent true after secrets are purged; change the post-delete steps so
they are non-fatal and always attempt to disable consent: replace the chained
map_err? calls for identity.clear_identity and consent.set_enabled with explicit
error handling that (1) always calls consent.set_enabled(&descriptor.id, false)
(retry/log if it fails) even if clear_identity fails, (2) does not early-return
after a post-delete failure but collects/logs errors, and (3) guarantees the
consent flag is forced false (best-effort, with retries or compensating action)
before finishing so ApiKey sources cannot remain reported as connected after
secret deletion.

---

Nitpick comments:
In @.omp/extensions/mlt-tasks/index.ts:
- Around line 67-68: The current lines.push call in
.omp/extensions/mlt-tasks/index.ts inlines task spec using a fixed 4-backtick
fence which can be broken if spec contains equal-or-longer backtick runs; update
the logic around the spec rendering (where spec is used and where lines.push is
called) to compute the longest consecutive backtick sequence in spec and
generate a fence of length (maxRun + 1), then use that fence (e.g., fence +
'markdown' and closing fence) instead of the hardcoded four backticks so the
wrapper cannot be prematurely terminated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 13978e9e-e824-4349-8b77-58148ba6b8f2

📥 Commits

Reviewing files that changed from the base of the PR and between 5bfd790 and 9191973.

📒 Files selected for processing (10)
  • .omp/extensions/mlt-tasks/index.ts
  • crates/adapters/src/identity.rs
  • crates/core/src/ports.rs
  • crates/core/src/providers/claude.rs
  • crates/core/src/sources.rs
  • docs/PRD.md
  • docs/tasks/004-disconnect-provider.md
  • src-tauri/src/lib.rs
  • src/lib/usage.ts
  • src/routes/+page.svelte

Comment thread .omp/extensions/mlt-tasks/index.ts Outdated
Comment thread src-tauri/src/lib.rs
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 10 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src-tauri/src/lib.rs Outdated
…ommand

Addresses PR #5 review (CodeRabbit + cubic).

- disconnect() ordering (src-tauri): purge secret -> clear consent -> forget
  identity (was secret -> identity -> consent). For an `ApiKey` source
  `SourceState::active` is consent alone, so the old order could leave a source
  enabled-but-keyless — still reading as "connected" and refreshing against a
  now-missing key — if the cosmetic identity-cache clear failed after the secret
  was already purged. Consent is now cleared before the identity clear (so it
  sticks), and any error is still surfaced. The secret is still purged first, so
  a failure there aborts with nothing else changed. Regression test added:
  disconnect_clears_consent_even_when_forgetting_identity_fails.

- mlt-tasks extension: handle pi.sendUserMessage rejection instead of
  fire-and-forget. The call goes through the prompt flow, which can reject
  (e.g. model/API-key validation when idle); surface it via a toast rather than
  leaving an unhandled rejection after the success toast.

- mlt-tasks extension: compute the inlined-spec wrapper fence dynamically
  (longest backtick run + 1, min 3) so a task doc containing a >=4-backtick
  fence can no longer terminate the wrapper early.
@ogrodev
Copy link
Copy Markdown
Owner Author

ogrodev commented Jun 2, 2026

Update — addressed automated review (commit 4d657bb)

All three findings from the CodeRabbit/cubic review were verified against the code, then fixed:

  1. disconnect() ordering — the one behaviour fix. Reordered to purge secret → clear consent → forget identity. For an ApiKey source SourceState::active == enabled (presence is ignored), so the prior order could leave a source enabled-but-keyless — still "connected" and refreshing against a missing key — if the cosmetic identity-cache clear failed after the secret was already purged. Consent now clears before the identity clear (so it sticks); the secret is still purged first; any error still surfaces. New regression test disconnect_clears_consent_even_when_forgetting_identity_fails fails under the old order, passes under the new.
  2. mlt-tasks extension — handle pi.sendUserMessage rejection instead of fire-and-forget. The call routes through the prompt flow, which can reject (e.g. model/API-key validation when idle); it now surfaces via a toast rather than an unhandled rejection.
  3. mlt-tasks extension — dynamic spec fence. Computes the wrapper fence as longest backtick run + 1 (min 3) so a task doc containing a ≥4-backtick fence can't terminate the wrapper early.

Residual (accepted) tradeoff: if the consent-store write itself fails after the secret is purged, the source can briefly remain enabled-but-keyless. That's an irreducible failing-disk case; disconnect is idempotent so a retry resolves it, and no retry/log machinery was added (YAGNI).

Verification: make test → 64 pass; clippy -D warnings, biome, core-purity, gitleaks, rustfmt all green; independent pre-ship review found no critical/warning/note issues; all PR checks green.

@ogrodev ogrodev merged commit 8c5d8df into main Jun 2, 2026
5 checks passed
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.

1 participant