Skip to content

fix(ai): make model discovery auth-consistent - #67

Draft
adityathebe wants to merge 2 commits into
mainfrom
fix/ai-auth-consistent-model-cache
Draft

fix(ai): make model discovery auth-consistent#67
adityathebe wants to merge 2 commits into
mainfrom
fix/ai-auth-consistent-model-cache

Conversation

@adityathebe

@adityathebe adityathebe commented Aug 11, 2026

Copy link
Copy Markdown
Member

Fixes #60.

Model discovery could report authentication from one credential while fetching and caching models with another. This change carries one immutable credential snapshot through the entire operation.

Model caches are now scoped by backend, endpoint, and a machine-keyed credential HMAC. Writes use secure permissions, atomic replacement, and per-entry locking. The adapter cache also invalidates immediately when credentials or local runtime identity change.

Tests cover credential substitution, rotation, concurrent providers, cache isolation, and permissions.

Summary by CodeRabbit

  • New Features
    • Added support for DeepSeek model discovery.
    • Added validated custom endpoints for supported providers.
    • Model caches are now isolated by credentials and endpoint.
  • Bug Fixes
    • Improved cache invalidation when credentials, endpoints, or local login details change.
    • Prevented credential leakage through redirects and sanitized transport errors.
    • Added safer handling when secure cache protection is unavailable.
  • Documentation
    • Updated whoami documentation with model-cache locations, isolation, cache controls, and HTTP logging details.

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

Gavel summary

Source Pass Fail Skip Duration

Totals: 0 passed · 0 failed · 0 skipped · -

View full results

@adityathebe
adityathebe marked this pull request as draft August 11, 2026 17:03
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ff835e2f-9bb4-4377-9f13-a317837f1ea4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

The PR introduces immutable authentication snapshots, endpoint-aware model discovery, immediate adapter-cache invalidation, and machine-local HMAC-based model caches with per-fingerprint storage and filesystem locking.

Changes

Credential-Scoped Model Availability

Layer / File(s) Summary
Authentication snapshot propagation
pkg/ai/adapters.go, pkg/ai/adapter_models.go, pkg/ai/adapters_test.go
AuthProbe captures credentials, endpoints, identities, and runtime status once. Adapter and model resolution use the captured snapshot.
Adapter cache invalidation
pkg/ai/adapters_cache.go, pkg/ai/adapters_cache_test.go, pkg/ai/availability_test.go, pkg/ai/catalog_disabled_ginkgo_test.go, pkg/ai/live_catalog_test.go
Adapter cache entries include authentication fingerprints, return deep copies, and retry when host state changes.
Endpoint-aware model discovery
pkg/ai/models_remote.go, pkg/ai/models_remote_test.go
Model listing supports provider-specific endpoints, DeepSeek, validated custom URLs, disabled redirects, and sanitized transport errors.
Persistent catalog cache
pkg/ai/catalog_resolve.go, pkg/ai/catalog_resolve_test.go, pkg/ai/model_cache.go, README.md
Model catalogs use credential- and endpoint-scoped HMAC fingerprints, locked per-entry files, atomic writes, and restrictive permissions. Documentation describes the new cache behavior.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ResolveModels
  participant ModelCache
  participant ProviderEndpoint
  Client->>ResolveModels: request model resolution
  ResolveModels->>ModelCache: read credential- and endpoint-scoped entry
  alt cache miss
    ResolveModels->>ProviderEndpoint: fetch models with token and endpoint
    ProviderEndpoint-->>ResolveModels: return model list
    ResolveModels->>ModelCache: atomically write locked entry
  end
  ResolveModels-->>Client: return resolved models
Loading

Possibly related PRs

  • flanksource/captain#13: Modifies provider model-listing and authentication handling in pkg/ai/models_remote.go.

Suggested reviewers: moshloop

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.04% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive The changes address [#60] snapshot consistency, cache isolation, invalidation, concurrency, HMAC protection, permissions, and credential redaction; build and lint results are not provided. Run and confirm make lint and make build, then provide their results.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main authentication-consistency change for AI model discovery.
Out of Scope Changes check ✅ Passed The code and documentation changes support the linked issue objectives and contain no unrelated scope.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ai-auth-consistent-model-cache
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix/ai-auth-consistent-model-cache

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.

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

Gavel summary

Source Pass Fail Skip Duration
github.com/flanksource/captain/pkg/cli 531 1 0 34.9s
ai 87 0 0 37ms
aichat 113 0 2 5.7s
anthropicmock 1 0 0 1ms
api 80 0 0 35ms
attachments 5 0 0 2ms
bash 4 0 0 184.189µs
callertools 10 0 0 72ms
captain 6 0 0 1ms
claude 22 0 0 12ms
claudeagent 13 0 0 233ms
cli 209 0 0 4.4s
cmux 4 0 0 207.785µs
collections 12 0 0 358.092µs
credentials 5 0 0 95ms
database 15 0 0 2.1s
genkit 25 0 0 53ms
gitagent 66 0 0 11.4s
github.com/flanksource/captain/migrations 11 0 0 2.8s
github.com/flanksource/captain/pkg/ai 250 0 0 1.6s
github.com/flanksource/captain/pkg/ai/agent 23 0 0 -
github.com/flanksource/captain/pkg/ai/agent/commit 52 0 0 7.5s
github.com/flanksource/captain/pkg/ai/agent/setup 16 0 0 140ms
github.com/flanksource/captain/pkg/ai/agent/verify 21 0 0 360ms
github.com/flanksource/captain/pkg/ai/agent/worktree 6 0 0 -
github.com/flanksource/captain/pkg/ai/assistanttags 15 0 0 -
github.com/flanksource/captain/pkg/ai/fixture 42 0 0 150ms
github.com/flanksource/captain/pkg/ai/fixture/kubeproxy 2 0 0 120ms
github.com/flanksource/captain/pkg/ai/fixture/mcpproxy 6 0 0 -
github.com/flanksource/captain/pkg/ai/history 54 0 0 -
github.com/flanksource/captain/pkg/ai/internal/gen-model-registry 12 0 0 -
github.com/flanksource/captain/pkg/ai/middleware 25 0 0 -
github.com/flanksource/captain/pkg/ai/pricing 9 0 0 -
github.com/flanksource/captain/pkg/ai/prompt 16 0 0 10ms
github.com/flanksource/captain/pkg/ai/provider 144 0 0 10ms
github.com/flanksource/captain/pkg/ai/provider/claudeagent 44 0 0 1.2s
github.com/flanksource/captain/pkg/ai/provider/cmux 119 0 0 980ms
github.com/flanksource/captain/pkg/ai/provider/genkit 36 0 0 -
github.com/flanksource/captain/pkg/ai/provider/jsonrpc 6 0 0 50ms
github.com/flanksource/captain/pkg/aichat 5 0 0 130ms
github.com/flanksource/captain/pkg/aimock 50 0 7 290ms
github.com/flanksource/captain/pkg/aimock/anthropicmock 14 0 0 10ms
github.com/flanksource/captain/pkg/aimock/openaimock 18 0 0 30ms
github.com/flanksource/captain/pkg/api 136 0 0 220ms
github.com/flanksource/captain/pkg/api/registry 96 0 0 -
github.com/flanksource/captain/pkg/bash 348 0 0 10ms
github.com/flanksource/captain/pkg/captainconfig 25 0 0 -
github.com/flanksource/captain/pkg/claude 138 0 0 10ms
github.com/flanksource/captain/pkg/claude/tools 18 0 0 -
github.com/flanksource/captain/pkg/cmux 1 0 0 -
github.com/flanksource/captain/pkg/codexconfig 10 0 0 120ms
github.com/flanksource/captain/pkg/container 72 0 1 -
github.com/flanksource/captain/pkg/database 99 0 0 8.6s
github.com/flanksource/captain/pkg/dod 11 0 0 1m0s
github.com/flanksource/captain/pkg/gitagent 31 0 0 1.1s
github.com/flanksource/captain/pkg/gitagent/proxy 12 0 0 -
github.com/flanksource/captain/pkg/monitor 54 0 0 1.0s
github.com/flanksource/captain/pkg/sandbox 1 0 0 -
github.com/flanksource/captain/pkg/sandbox/adapter 26 0 0 -
github.com/flanksource/captain/pkg/sandbox/presets 13 0 0 -
github.com/flanksource/captain/pkg/session 70 0 0 10ms
history 69 0 0 8ms
migrations 6 0 0 2.7s
openaimock 2 0 0 124.689µs
provider 10 0 0 15ms
registry 38 0 0 1ms
session 28 0 0 15ms
tools 38 0 0 1ms

Totals: 3556 passed · 1 failed · 10 skipped · 2m28s

Failing tests

github.com/flanksource/captain/pkg/cli — TestFullCycleWithAManualAgent

gitagent_e2e_test.go:465: git [rev-parse --abbrev-ref @{u}]:
        fatal: no such branch: 'master'

View full results

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (6)
pkg/ai/adapters_cache_test.go (1)

270-277: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Hoist t.TempDir() out of the probe closure to isolate the recapture-error path.

The closure calls t.TempDir() on every capture, so each capture also gets a different Home. The fingerprint then differs regardless of ProbeError. The test still passes, but it no longer proves that the ProbeError branch is the cause.

Create the directory once outside the closure.

♻️ Proposed change
 	wantErr := errors.New("credential vault became unreadable")
+	home := t.TempDir()
 	captures := 0
 	adapterAuthProbe = func() AuthProbe {
 		captures++
-		probe := fakeProbe(nil, nil, nil, t.TempDir())
+		probe := fakeProbe(nil, nil, nil, home)
 		if captures > 1 {
 			probe.ProbeError = wantErr
 		}
 		return probe
 	}
🤖 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 `@pkg/ai/adapters_cache_test.go` around lines 270 - 277, Hoist the t.TempDir()
call out of the adapterAuthProbe closure and store its result for reuse on every
fakeProbe creation. Keep the closure’s capture counting and ProbeError
assignment unchanged so repeated captures share the same Home and isolate the
recapture-error behavior.
pkg/ai/adapters.go (1)

355-372: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Confirm the fingerprint stays stable across processes and never leaks tokens.

frozenCredentialState carries the raw Token into json.Marshal before hashing. The digest is safe, but the intermediate buffer holds plaintext credentials. That buffer is not zeroed and could reach a heap dump.

stateFingerprint is also compared only in-process today. If a future change persists it, the digest becomes a credential oracle without a machine-local key, unlike the HMAC scheme used by the model cache layer of this stack.

Consider hashing an HMAC of the token, keyed with the same machine key the catalog cache uses, instead of the raw token.

🤖 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 `@pkg/ai/adapters.go` around lines 355 - 372, Update the fingerprint
construction around frozenCredentialState and stateFingerprint so raw credential
tokens are never placed in the marshaled state. Replace each Token value with an
HMAC-derived representation using the same machine key and established
catalog-cache mechanism, preserving deterministic output across processes and
existing non-secret credential metadata. Hash only the sanitized state and
retain the current SHA-256 fingerprint format.
README.md (1)

499-499: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider documenting the legacy cache file.

The text accurately describes the new per-entry cache. modelCacheRoot in pkg/ai/model_cache.go stops using the legacy ~/.config/captain/models.json and only tightens its permissions to 0600. Upgrading users keep a stale file that is never read. One sentence stating that the legacy file is unused and can be deleted would prevent confusion.

🤖 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 `@README.md` at line 499, Update the model-cache documentation near the
description of `~/.config/captain/models/` to state that the legacy
`~/.config/captain/models.json` file is no longer read and may be safely
deleted.
pkg/ai/catalog_resolve_test.go (1)

300-302: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Avoid shadowing the credentials package, and reuse the temp home path.

Line 300 declares a local variable named credentials. It shadows the imported credentials package for the rest of this function. The test compiles today because it does not use the package after line 300. A later edit that needs credentials.SourceVault here would fail to compile.

Lines 321 and 326 read os.Getenv("HOME") twice. Capture the temp directory in a variable instead. This also removes the os-readfile-getenv-path-go static analysis hint at line 325, which is a false positive because HOME is set by t.Setenv to a test-owned directory.

♻️ Proposed refactor
-	t.Setenv("HOME", t.TempDir())
+	home := t.TempDir()
+	t.Setenv("HOME", home)
 	prev := liveModelFetcher
@@
-	credentials := NewCredentialSnapshot(map[Backend]api.ResolvedAPIKey{
+	snapshot := NewCredentialSnapshot(map[Backend]api.ResolvedAPIKey{
 		BackendOpenAI: {Token: "endpoint-token"},
 	})
@@
-			Backend: BackendOpenAI, UseTokens: true, Credentials: credentials, APIURL: apiURL,
+			Backend: BackendOpenAI, UseTokens: true, Credentials: snapshot, APIURL: apiURL,
@@
-	entries, err := os.ReadDir(filepath.Join(os.Getenv("HOME"), ".config", "captain", "models"))
+	modelsDir := filepath.Join(home, ".config", "captain", "models")
+	entries, err := os.ReadDir(modelsDir)
 	if err != nil {
 		t.Fatalf("ReadDir model cache: %v", err)
 	}
 	for _, entry := range entries {
-		data, err := os.ReadFile(filepath.Join(os.Getenv("HOME"), ".config", "captain", "models", entry.Name()))
+		data, err := os.ReadFile(filepath.Join(modelsDir, entry.Name()))

Also applies to: 321-326

🤖 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 `@pkg/ai/catalog_resolve_test.go` around lines 300 - 302, Rename the local
credentials snapshot variable in the test to avoid shadowing the imported
credentials package, and capture the temporary HOME value once after t.Setenv.
Reuse that variable for the path construction and file read around the affected
test setup.

Source: Linters/SAST tools

pkg/ai/catalog_resolve.go (2)

83-86: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Return nil rows on the error path for consistency.

Line 85 filters rows before checking err. The cacheable path at lines 99-102 returns nil when resolveFresh fails. Correct callers check err first, so behavior does not change, but the two paths differ.

♻️ Proposed refactor
 	if !cacheable {
 		rows, err := resolveFresh(ctx, opts, credentials)
-		return filterResolved(rows, opts.Filter), err
+		if err != nil {
+			return nil, err
+		}
+		return filterResolved(rows, opts.Filter), nil
 	}
🤖 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 `@pkg/ai/catalog_resolve.go` around lines 83 - 86, Update the non-cacheable
branch of the resolver around resolveFresh and filterResolved to check err
before filtering; return nil rows with the error on failure, matching the
cacheable path, and only call filterResolved for successful results.

298-306: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hoist the UseTokens check out of the loop.

!opts.UseTokens does not change across iterations. Move the check before the loop so the intent is clear at the point of the decision.

♻️ Proposed refactor
 	var hmacKey []byte
-	for _, backend := range selectedAPIBackends(opts.Backend) {
-		if !opts.UseTokens {
-			break
-		}
-		resolved := credentials.APIKey(backend)
+	backends := []Backend(nil)
+	if opts.UseTokens {
+		backends = selectedAPIBackends(opts.Backend)
+	}
+	for _, backend := range backends {
+		resolved := credentials.APIKey(backend)
 		if strings.TrimSpace(resolved.Token) == "" {
 			continue
 		}
🤖 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 `@pkg/ai/catalog_resolve.go` around lines 298 - 306, Move the opts.UseTokens
guard out of the selectedAPIBackends loop in the surrounding token-resolution
logic, checking it before iteration and skipping the entire loop when disabled.
Remove the per-iteration break while preserving the existing backend filtering
and token resolution behavior.
🤖 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 `@pkg/ai/adapters_cache.go`:
- Around line 39-45: Optimize CachedAdapters around freezeAuthProbe so cache
hits do not perform full credential-file hashing while holding adapterCacheMu.
Add a short-lived memoization or equivalent cheap mtime/size pre-check for the
probe, while preserving probe error handling and invalidating or recomputing the
full fingerprint when relevant adapter state changes.
- Around line 46-69: Update the CachedAdapters retry flow around adapterProbe so
that when both attempts fail to reach a stable fingerprint, it returns the
freshest adapters result instead of the “adapter probe did not settle” error,
and does not publish that result to adapterCache. Define and export a sentinel
error for this unstable-probe condition, and return it alongside the adapters so
callers can match it with errors.Is while still receiving valid adapter data.

In `@pkg/ai/availability_test.go`:
- Around line 60-66: Both Ginkgo specs must isolate adapterAuthProbe instead of
allowing OSAuthProbe to access host credentials. In pkg/ai/availability_test.go
lines 60-66, save and restore adapterAuthProbe in DeferCleanup and set it to
return fakeProbe(nil, nil, nil, hermeticHome) alongside the cache reset; apply
the same substitution in pkg/ai/catalog_disabled_ginkgo_test.go lines 113-119
before assigning adapterProbe.

In `@pkg/ai/catalog_resolve.go`:
- Around line 88-94: Update lockModelCache to accept context.Context and acquire
the file lock with non-blocking retries, checking ctx.Done() between attempts
and returning ctx.Err() after closing the lock file when cancelled or expired.
Preserve existing handling for successful acquisition and unexpected flock
errors, and update the ResolveModels call to pass ctx so contention falls back
through its existing uncached resolve path.

---

Nitpick comments:
In `@pkg/ai/adapters_cache_test.go`:
- Around line 270-277: Hoist the t.TempDir() call out of the adapterAuthProbe
closure and store its result for reuse on every fakeProbe creation. Keep the
closure’s capture counting and ProbeError assignment unchanged so repeated
captures share the same Home and isolate the recapture-error behavior.

In `@pkg/ai/adapters.go`:
- Around line 355-372: Update the fingerprint construction around
frozenCredentialState and stateFingerprint so raw credential tokens are never
placed in the marshaled state. Replace each Token value with an HMAC-derived
representation using the same machine key and established catalog-cache
mechanism, preserving deterministic output across processes and existing
non-secret credential metadata. Hash only the sanitized state and retain the
current SHA-256 fingerprint format.

In `@pkg/ai/catalog_resolve_test.go`:
- Around line 300-302: Rename the local credentials snapshot variable in the
test to avoid shadowing the imported credentials package, and capture the
temporary HOME value once after t.Setenv. Reuse that variable for the path
construction and file read around the affected test setup.

In `@pkg/ai/catalog_resolve.go`:
- Around line 83-86: Update the non-cacheable branch of the resolver around
resolveFresh and filterResolved to check err before filtering; return nil rows
with the error on failure, matching the cacheable path, and only call
filterResolved for successful results.
- Around line 298-306: Move the opts.UseTokens guard out of the
selectedAPIBackends loop in the surrounding token-resolution logic, checking it
before iteration and skipping the entire loop when disabled. Remove the
per-iteration break while preserving the existing backend filtering and token
resolution behavior.

In `@README.md`:
- Line 499: Update the model-cache documentation near the description of
`~/.config/captain/models/` to state that the legacy
`~/.config/captain/models.json` file is no longer read and may be safely
deleted.
🪄 Autofix

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f1fafb75-3bbb-492a-8cb6-9b193c8acf3c

📥 Commits

Reviewing files that changed from the base of the PR and between ff7eed3 and 7521168.

📒 Files selected for processing (14)
  • README.md
  • pkg/ai/adapter_models.go
  • pkg/ai/adapters.go
  • pkg/ai/adapters_cache.go
  • pkg/ai/adapters_cache_test.go
  • pkg/ai/adapters_test.go
  • pkg/ai/availability_test.go
  • pkg/ai/catalog_disabled_ginkgo_test.go
  • pkg/ai/catalog_resolve.go
  • pkg/ai/catalog_resolve_test.go
  • pkg/ai/live_catalog_test.go
  • pkg/ai/model_cache.go
  • pkg/ai/models_remote.go
  • pkg/ai/models_remote_test.go

Comment thread pkg/ai/adapters_cache.go Outdated
Comment thread pkg/ai/adapters_cache.go Outdated
Comment thread pkg/ai/availability_test.go
Comment thread pkg/ai/catalog_resolve.go Outdated
Adapter cache hits rehashed OAuth files while holding the global cache lock, and repeated credential rewrites discarded otherwise usable probe results. Validate hits with cheap file metadata, return unsettled snapshots uncached through a sentinel, and isolate host auth in catalog tests.

Make model-cache lock acquisition context-aware so a contended entry cannot outlive the caller deadline.
@adityathebe
adityathebe force-pushed the fix/ai-auth-consistent-model-cache branch from c99eb3c to 03b31a9 Compare August 12, 2026 04:54
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.

Make model listing and availability caches auth-consistent

1 participant