detached d9fd43a

fix(subagents): use atomics for test workspace capture flags (#216)

* fix(subagents): use atomics for test workspace capture flags

Capture/Cleanup run on the resident child goroutine while tests
read the flags after polling manager state, racing under -race
(macos CI failure in TestResidentManagerCapturesFailedWorktreePatch).
Test-only change, no production code touched.

* feat(ollama): support OLLAMA_API_KEY with automatic cloud endpoint (#215)

* feat(ollama): support OLLAMA_API_KEY with automatic cloud endpoint

Read OLLAMA_API_KEY in credential resolution, default to
https://ollama.com/v1 when a real key exists and no endpoint is
given, keep localhost default without a key, and expose ollama
in /login with a cloud model-list probe.

* fix(ollama): validate cloud keys via auth-gated probe, drop duplicate env case
commit
d9fd43a957014a6d0714f5eaef8854da712d6307
author
bnema · · 2026-09-19 12:46 UTC
committer
GitHub · · 2026-09-19 12:46 UTC
parent
7e95a47
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
9 files · +263 -15
M README.md +18 -0
@@ -792,6 +792,24 @@ zut --provider ollama --model qwen3.5:4b
792792
793793 That's it. No API key needed for local models. zut defaults to `http://localhost:11434`.
794794
795+### Ollama Cloud with OLLAMA_API_KEY
796+
797+Set `OLLAMA_API_KEY` (from [ollama.com/settings/keys](https://ollama.com/settings/keys)) and zut uses Ollama Cloud automatically — no `--base-url` needed:
798+
799+```bash
800+export OLLAMA_API_KEY=...
801+zut --provider ollama --model gemma4:31b
802+```
803+
804+With a real key and no explicit endpoint, zut defaults to `https://ollama.com/v1`. Precedence:
805+
806+1. `--base-url` always wins — pass `--base-url http://localhost:11434` to force local while the key is set.
807+2. A `baseUrl` in `models.json` (model- or provider-level) wins when no `--base-url` is given.
808+3. Otherwise a real key (`--api-key`, `OLLAMA_API_KEY`, or `/login`) selects the cloud endpoint.
809+4. With no key, zut stays local with no auth.
810+
811+You can also save the key with `/login` → api key → Ollama (stored in `auth.json`). `--api-key` overrides env and stored credentials.
812+
795813 For a remote ollama instance or one behind auth:
796814
797815 ```bash
@@ -792,6 +792,24 @@ zut --provider ollama --model qwen3.5:4b
792 792
793 That's it. No API key needed for local models. zut defaults to `http://localhost:11434`.793 That's it. No API key needed for local models. zut defaults to `http://localhost:11434`.
794 794
795+### Ollama Cloud with OLLAMA_API_KEY
796+
797+Set `OLLAMA_API_KEY` (from [ollama.com/settings/keys](https://ollama.com/settings/keys)) and zut uses Ollama Cloud automatically — no `--base-url` needed:
798+
799+```bash
800+export OLLAMA_API_KEY=...
801+zut --provider ollama --model gemma4:31b
802+```
803+
804+With a real key and no explicit endpoint, zut defaults to `https://ollama.com/v1`. Precedence:
805+
806+1. `--base-url` always wins — pass `--base-url http://localhost:11434` to force local while the key is set.
807+2. A `baseUrl` in `models.json` (model- or provider-level) wins when no `--base-url` is given.
808+3. Otherwise a real key (`--api-key`, `OLLAMA_API_KEY`, or `/login`) selects the cloud endpoint.
809+4. With no key, zut stays local with no auth.
810+
811+You can also save the key with `/login` → api key → Ollama (stored in `auth.json`). `--api-key` overrides env and stored credentials.
812+
795 For a remote ollama instance or one behind auth:813 For a remote ollama instance or one behind auth:
796 814
797 ```bash815 ```bash
M docs/providers.md +9 -0
@@ -104,6 +104,7 @@ show instructions and should be configured with environment variables.
104104 | Kimi | `KIMI_API_KEY` or `MOONSHOT_API_KEY` | `kimi` |
105105 | Google Gemini | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `google` |
106106 | DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` |
107+| Ollama Cloud | `OLLAMA_API_KEY` | `ollama` |
107108 | Moonshot AI | `MOONSHOT_API_KEY` | `moonshotai` |
108109 | Moonshot AI China | `MOONSHOT_API_KEY` | `moonshotai-cn` |
109110 | Groq | `GROQ_API_KEY` | `groq` |
@@ -136,6 +137,14 @@ export OPENROUTER_API_KEY=...
136137 zut --provider openrouter
137138 ```
138139
140+### Ollama local vs Cloud
141+
142+The `ollama` provider serves both local and Cloud models over the OpenAI-compatible `/v1` API. Secrets are sent only as `Authorization: Bearer` headers, never in URLs or logs.
143+
144+- No key: `http://localhost:11434`, no auth (local models).
145+- Real key (`--api-key`, `OLLAMA_API_KEY`, or `/login` → Ollama) with no explicit endpoint: `https://ollama.com/v1` (Cloud models).
146+- `--base-url` always wins; a `baseUrl` in `models.json` (model- or provider-level) wins over the automatic default.
147+
139148 ### OpenCode Go model catalog
140149
141150 OpenCode Go is synchronized instead of using a hand-maintained model list. When
@@ -104,6 +104,7 @@ show instructions and should be configured with environment variables.
104 | Kimi | `KIMI_API_KEY` or `MOONSHOT_API_KEY` | `kimi` |104 | Kimi | `KIMI_API_KEY` or `MOONSHOT_API_KEY` | `kimi` |
105 | Google Gemini | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `google` |105 | Google Gemini | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `google` |
106 | DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` |106 | DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` |
107+| Ollama Cloud | `OLLAMA_API_KEY` | `ollama` |
107 | Moonshot AI | `MOONSHOT_API_KEY` | `moonshotai` |108 | Moonshot AI | `MOONSHOT_API_KEY` | `moonshotai` |
108 | Moonshot AI China | `MOONSHOT_API_KEY` | `moonshotai-cn` |109 | Moonshot AI China | `MOONSHOT_API_KEY` | `moonshotai-cn` |
109 | Groq | `GROQ_API_KEY` | `groq` |110 | Groq | `GROQ_API_KEY` | `groq` |
@@ -136,6 +137,14 @@ export OPENROUTER_API_KEY=...
136 zut --provider openrouter137 zut --provider openrouter
137 ```138 ```
138 139
140+### Ollama local vs Cloud
141+
142+The `ollama` provider serves both local and Cloud models over the OpenAI-compatible `/v1` API. Secrets are sent only as `Authorization: Bearer` headers, never in URLs or logs.
143+
144+- No key: `http://localhost:11434`, no auth (local models).
145+- Real key (`--api-key`, `OLLAMA_API_KEY`, or `/login` → Ollama) with no explicit endpoint: `https://ollama.com/v1` (Cloud models).
146+- `--base-url` always wins; a `baseUrl` in `models.json` (model- or provider-level) wins over the automatic default.
147+
139 ### OpenCode Go model catalog148 ### OpenCode Go model catalog
140 149
141 OpenCode Go is synchronized instead of using a hand-maintained model list. When150 OpenCode Go is synchronized instead of using a hand-maintained model list. When
M packages/agent/build.go +22 -3
@@ -593,10 +593,21 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
593593 method string
594594 accountID string
595595 credErr error
596+ // hasRealOllamaCred tracks whether ResolveCredentialFull returned a
597+ // genuine ollama credential (--api-key, OLLAMA_API_KEY, auth.json).
598+ // It drives the cloud-vs-local endpoint default below; the dummy
599+ // "ollama" credential never triggers the cloud default.
600+ hasRealOllamaCred bool
596601 )
597602 if provName == "ollama" {
598- cred = firstNonEmpty(args.APIKey, "ollama")
599- method = "apikey"
603+ cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
604+ if credErr != nil {
605+ // Local ollama needs no key: fall back to the dummy
606+ // credential instead of failing resolution.
607+ cred, method, accountID, credErr = "ollama", "apikey", "", nil
608+ } else {
609+ hasRealOllamaCred = true
610+ }
600611 } else {
601612 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
602613 }
@@ -817,7 +828,15 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
817828 args.BaseURL = resolvedModel.BaseURL
818829 }
819830 if args.BaseURL == "" && provName == "ollama" {
820- args.BaseURL = "http://localhost:11434"
831+ // A real ollama credential with no explicit endpoint means
832+ // Ollama Cloud; without one, stay on the local server.
833+ // Explicit --base-url and model-level baseUrl (applied above)
834+ // both win over this default.
835+ if hasRealOllamaCred {
836+ args.BaseURL = "https://ollama.com/v1"
837+ } else {
838+ args.BaseURL = "http://localhost:11434"
839+ }
821840 }
822841 if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {
823842 managementURL, _, configErr := ResolveLlamaCPPConfig()
@@ -593,10 +593,21 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
593 method string593 method string
594 accountID string594 accountID string
595 credErr error595 credErr error
596+ // hasRealOllamaCred tracks whether ResolveCredentialFull returned a
597+ // genuine ollama credential (--api-key, OLLAMA_API_KEY, auth.json).
598+ // It drives the cloud-vs-local endpoint default below; the dummy
599+ // "ollama" credential never triggers the cloud default.
600+ hasRealOllamaCred bool
596 )601 )
597 if provName == "ollama" {602 if provName == "ollama" {
598- cred = firstNonEmpty(args.APIKey, "ollama")603+ cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
599- method = "apikey"604+ if credErr != nil {
605+ // Local ollama needs no key: fall back to the dummy
606+ // credential instead of failing resolution.
607+ cred, method, accountID, credErr = "ollama", "apikey", "", nil
608+ } else {
609+ hasRealOllamaCred = true
610+ }
600 } else {611 } else {
601 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)612 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
602 }613 }
@@ -817,7 +828,15 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
817 args.BaseURL = resolvedModel.BaseURL828 args.BaseURL = resolvedModel.BaseURL
818 }829 }
819 if args.BaseURL == "" && provName == "ollama" {830 if args.BaseURL == "" && provName == "ollama" {
820- args.BaseURL = "http://localhost:11434"831+ // A real ollama credential with no explicit endpoint means
832+ // Ollama Cloud; without one, stay on the local server.
833+ // Explicit --base-url and model-level baseUrl (applied above)
834+ // both win over this default.
835+ if hasRealOllamaCred {
836+ args.BaseURL = "https://ollama.com/v1"
837+ } else {
838+ args.BaseURL = "http://localhost:11434"
839+ }
821 }840 }
822 if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {841 if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {
823 managementURL, _, configErr := ResolveLlamaCPPConfig()842 managementURL, _, configErr := ResolveLlamaCPPConfig()
M packages/agent/config.go +3 -0
@@ -543,6 +543,9 @@ func resolveCredentialFull(ctx context.Context, provider, explicit string, comma
543543 if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" {
544544 return v, "apikey", "", nil
545545 }
546+ // ollama needs no explicit case: the generic
547+ // normalizeCustomProviderEnvVar fallback below already maps
548+ // provider "ollama" to OLLAMA_API_KEY before auth.json.
546549 case "llama.cpp":
547550 baseURL, apiKey, resolveErr := resolveLlamaCPPConfig(ctx, commandMode)
548551 if resolveErr != nil {
@@ -543,6 +543,9 @@ func resolveCredentialFull(ctx context.Context, provider, explicit string, comma
543 if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" {543 if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" {
544 return v, "apikey", "", nil544 return v, "apikey", "", nil
545 }545 }
546+ // ollama needs no explicit case: the generic
547+ // normalizeCustomProviderEnvVar fallback below already maps
548+ // provider "ollama" to OLLAMA_API_KEY before auth.json.
546 case "llama.cpp":549 case "llama.cpp":
547 baseURL, apiKey, resolveErr := resolveLlamaCPPConfig(ctx, commandMode)550 baseURL, apiKey, resolveErr := resolveLlamaCPPConfig(ctx, commandMode)
548 if resolveErr != nil {551 if resolveErr != nil {
A packages/agent/ollama_test.go +161 -0
@@ -0,0 +1,161 @@
1+package agent
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/provider"
9+ providerauth "github.com/bnema/zut/packages/provider/auth"
10+)
11+
12+func TestResolveCredentialOllamaEnv(t *testing.T) {
13+ t.Setenv("ZUT_HOME", t.TempDir())
14+ t.Setenv("OLLAMA_API_KEY", "ollama-cloud-key")
15+
16+ cred, method, _, err := ResolveCredentialFull("ollama", "")
17+ if err != nil {
18+ t.Fatalf("ResolveCredentialFull failed: %v", err)
19+ }
20+ if cred != "ollama-cloud-key" {
21+ t.Errorf("cred = %q, want %q", cred, "ollama-cloud-key")
22+ }
23+ if method != "apikey" {
24+ t.Errorf("method = %q, want apikey", method)
25+ }
26+ if !CredentialAvailable("ollama") {
27+ t.Error("CredentialAvailable(ollama) = false, want true with OLLAMA_API_KEY set")
28+ }
29+}
30+
31+func TestResolveCredentialOllamaExplicitWinsOverEnv(t *testing.T) {
32+ t.Setenv("ZUT_HOME", t.TempDir())
33+ t.Setenv("OLLAMA_API_KEY", "env-key")
34+
35+ cred, _, _, err := ResolveCredentialFull("ollama", "explicit-key")
36+ if err != nil {
37+ t.Fatalf("ResolveCredentialFull failed: %v", err)
38+ }
39+ if cred != "explicit-key" {
40+ t.Errorf("cred = %q, want explicit-key", cred)
41+ }
42+}
43+
44+func TestResolveOllamaCloudDefaultWithKey(t *testing.T) {
45+ t.Setenv("ZUT_HOME", t.TempDir())
46+ t.Setenv("OLLAMA_API_KEY", "ollama-cloud-key")
47+
48+ resolved, err := Resolve(Args{Provider: "ollama", Model: "gemma4:31b"}, false)
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ if resolved.Credential != "ollama-cloud-key" {
53+ t.Errorf("credential = %q, want real key", resolved.Credential)
54+ }
55+ if resolved.BaseURL != "https://ollama.com/v1" {
56+ t.Errorf("baseURL = %q, want https://ollama.com/v1", resolved.BaseURL)
57+ }
58+}
59+
60+func TestResolveOllamaLocalDefaultWithoutKey(t *testing.T) {
61+ t.Setenv("ZUT_HOME", t.TempDir())
62+ t.Setenv("OLLAMA_API_KEY", "")
63+
64+ resolved, err := Resolve(Args{Provider: "ollama", Model: "qwen3.5:4b"}, false)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if resolved.Credential != "ollama" {
69+ t.Errorf("credential = %q, want dummy ollama", resolved.Credential)
70+ }
71+ if resolved.BaseURL != "http://localhost:11434" {
72+ t.Errorf("baseURL = %q, want http://localhost:11434", resolved.BaseURL)
73+ }
74+}
75+
76+func TestResolveOllamaExplicitBaseURLWinsOverCloud(t *testing.T) {
77+ t.Setenv("ZUT_HOME", t.TempDir())
78+ t.Setenv("OLLAMA_API_KEY", "ollama-cloud-key")
79+
80+ resolved, err := Resolve(Args{Provider: "ollama", Model: "qwen3.5:4b", BaseURL: "http://localhost:11434"}, false)
81+ if err != nil {
82+ t.Fatal(err)
83+ }
84+ if resolved.BaseURL != "http://localhost:11434" {
85+ t.Errorf("baseURL = %q, want explicit local URL", resolved.BaseURL)
86+ }
87+ if resolved.Credential != "ollama-cloud-key" {
88+ t.Errorf("credential = %q, want real key (local server ignores it)", resolved.Credential)
89+ }
90+}
91+
92+func TestResolveOllamaExplicitKeyTriggersCloud(t *testing.T) {
93+ t.Setenv("ZUT_HOME", t.TempDir())
94+ t.Setenv("OLLAMA_API_KEY", "")
95+
96+ resolved, err := Resolve(Args{Provider: "ollama", Model: "gemma4:31b", APIKey: "explicit-key"}, false)
97+ if err != nil {
98+ t.Fatal(err)
99+ }
100+ if resolved.Credential != "explicit-key" {
101+ t.Errorf("credential = %q, want explicit-key", resolved.Credential)
102+ }
103+ if resolved.BaseURL != "https://ollama.com/v1" {
104+ t.Errorf("baseURL = %q, want https://ollama.com/v1", resolved.BaseURL)
105+ }
106+}
107+
108+func TestResolveOllamaModelBaseURLWinsOverCloud(t *testing.T) {
109+ t.Setenv("ZUT_HOME", t.TempDir())
110+ t.Setenv("OLLAMA_API_KEY", "ollama-cloud-key")
111+ provider.SetUserModels([]provider.Model{{
112+ Provider: "ollama", ID: "custom-local", DisplayName: "custom-local",
113+ ContextWindow: 32768, MaxOutput: 8192,
114+ BaseURL: "http://mylocal:11434", Source: "user",
115+ }})
116+ t.Cleanup(func() { provider.SetUserModels(nil) })
117+
118+ resolved, err := Resolve(Args{Provider: "ollama", Model: "custom-local"}, false)
119+ if err != nil {
120+ t.Fatal(err)
121+ }
122+ if resolved.BaseURL != "http://mylocal:11434" {
123+ t.Errorf("baseURL = %q, want model-level URL", resolved.BaseURL)
124+ }
125+ if resolved.Credential != "ollama-cloud-key" {
126+ t.Errorf("credential = %q, want real key", resolved.Credential)
127+ }
128+}
129+
130+func TestResolveOllamaStoredCredTriggersCloud(t *testing.T) {
131+ t.Setenv("ZUT_HOME", t.TempDir())
132+ t.Setenv("OLLAMA_API_KEY", "")
133+ encoded, err := json.Marshal(providerauth.Credentials{AdditionalAPIKeyCreds: map[string]providerauth.ProviderCreds{
134+ "ollama": {APIKey: "stored-key"},
135+ }})
136+ if err != nil {
137+ t.Fatal(err)
138+ }
139+ if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil {
140+ t.Fatal(err)
141+ }
142+
143+ cred, _, _, err := ResolveCredentialFull("ollama", "")
144+ if err != nil {
145+ t.Fatalf("ResolveCredentialFull failed: %v", err)
146+ }
147+ if cred != "stored-key" {
148+ t.Errorf("cred = %q, want stored-key", cred)
149+ }
150+
151+ resolved, err := Resolve(Args{Provider: "ollama", Model: "gemma4:31b"}, false)
152+ if err != nil {
153+ t.Fatal(err)
154+ }
155+ if resolved.Credential != "stored-key" {
156+ t.Errorf("credential = %q, want stored-key", resolved.Credential)
157+ }
158+ if resolved.BaseURL != "https://ollama.com/v1" {
159+ t.Errorf("baseURL = %q, want https://ollama.com/v1", resolved.BaseURL)
160+ }
161+}
@@ -0,0 +1,161 @@
1+package agent
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/provider"
9+ providerauth "github.com/bnema/zut/packages/provider/auth"
10+)
11+
12+func TestResolveCredentialOllamaEnv(t *testing.T) {
13+ t.Setenv("ZUT_HOME", t.TempDir())
14+ t.Setenv("OLLAMA_API_KEY", "ollama-cloud-key")
15+
16+ cred, method, _, err := ResolveCredentialFull("ollama", "")
17+ if err != nil {
18+ t.Fatalf("ResolveCredentialFull failed: %v", err)
19+ }
20+ if cred != "ollama-cloud-key" {
21+ t.Errorf("cred = %q, want %q", cred, "ollama-cloud-key")
22+ }
23+ if method != "apikey" {
24+ t.Errorf("method = %q, want apikey", method)
25+ }
26+ if !CredentialAvailable("ollama") {
27+ t.Error("CredentialAvailable(ollama) = false, want true with OLLAMA_API_KEY set")
28+ }
29+}
30+
31+func TestResolveCredentialOllamaExplicitWinsOverEnv(t *testing.T) {
32+ t.Setenv("ZUT_HOME", t.TempDir())
33+ t.Setenv("OLLAMA_API_KEY", "env-key")
34+
35+ cred, _, _, err := ResolveCredentialFull("ollama", "explicit-key")
36+ if err != nil {
37+ t.Fatalf("ResolveCredentialFull failed: %v", err)
38+ }
39+ if cred != "explicit-key" {
40+ t.Errorf("cred = %q, want explicit-key", cred)
41+ }
42+}
43+
44+func TestResolveOllamaCloudDefaultWithKey(t *testing.T) {
45+ t.Setenv("ZUT_HOME", t.TempDir())
46+ t.Setenv("OLLAMA_API_KEY", "ollama-cloud-key")
47+
48+ resolved, err := Resolve(Args{Provider: "ollama", Model: "gemma4:31b"}, false)
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ if resolved.Credential != "ollama-cloud-key" {
53+ t.Errorf("credential = %q, want real key", resolved.Credential)
54+ }
55+ if resolved.BaseURL != "https://ollama.com/v1" {
56+ t.Errorf("baseURL = %q, want https://ollama.com/v1", resolved.BaseURL)
57+ }
58+}
59+
60+func TestResolveOllamaLocalDefaultWithoutKey(t *testing.T) {
61+ t.Setenv("ZUT_HOME", t.TempDir())
62+ t.Setenv("OLLAMA_API_KEY", "")
63+
64+ resolved, err := Resolve(Args{Provider: "ollama", Model: "qwen3.5:4b"}, false)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if resolved.Credential != "ollama" {
69+ t.Errorf("credential = %q, want dummy ollama", resolved.Credential)
70+ }
71+ if resolved.BaseURL != "http://localhost:11434" {
72+ t.Errorf("baseURL = %q, want http://localhost:11434", resolved.BaseURL)
73+ }
74+}
75+
76+func TestResolveOllamaExplicitBaseURLWinsOverCloud(t *testing.T) {
77+ t.Setenv("ZUT_HOME", t.TempDir())
78+ t.Setenv("OLLAMA_API_KEY", "ollama-cloud-key")
79+
80+ resolved, err := Resolve(Args{Provider: "ollama", Model: "qwen3.5:4b", BaseURL: "http://localhost:11434"}, false)
81+ if err != nil {
82+ t.Fatal(err)
83+ }
84+ if resolved.BaseURL != "http://localhost:11434" {
85+ t.Errorf("baseURL = %q, want explicit local URL", resolved.BaseURL)
86+ }
87+ if resolved.Credential != "ollama-cloud-key" {
88+ t.Errorf("credential = %q, want real key (local server ignores it)", resolved.Credential)
89+ }
90+}
91+
92+func TestResolveOllamaExplicitKeyTriggersCloud(t *testing.T) {
93+ t.Setenv("ZUT_HOME", t.TempDir())
94+ t.Setenv("OLLAMA_API_KEY", "")
95+
96+ resolved, err := Resolve(Args{Provider: "ollama", Model: "gemma4:31b", APIKey: "explicit-key"}, false)
97+ if err != nil {
98+ t.Fatal(err)
99+ }
100+ if resolved.Credential != "explicit-key" {
101+ t.Errorf("credential = %q, want explicit-key", resolved.Credential)
102+ }
103+ if resolved.BaseURL != "https://ollama.com/v1" {
104+ t.Errorf("baseURL = %q, want https://ollama.com/v1", resolved.BaseURL)
105+ }
106+}
107+
108+func TestResolveOllamaModelBaseURLWinsOverCloud(t *testing.T) {
109+ t.Setenv("ZUT_HOME", t.TempDir())
110+ t.Setenv("OLLAMA_API_KEY", "ollama-cloud-key")
111+ provider.SetUserModels([]provider.Model{{
112+ Provider: "ollama", ID: "custom-local", DisplayName: "custom-local",
113+ ContextWindow: 32768, MaxOutput: 8192,
114+ BaseURL: "http://mylocal:11434", Source: "user",
115+ }})
116+ t.Cleanup(func() { provider.SetUserModels(nil) })
117+
118+ resolved, err := Resolve(Args{Provider: "ollama", Model: "custom-local"}, false)
119+ if err != nil {
120+ t.Fatal(err)
121+ }
122+ if resolved.BaseURL != "http://mylocal:11434" {
123+ t.Errorf("baseURL = %q, want model-level URL", resolved.BaseURL)
124+ }
125+ if resolved.Credential != "ollama-cloud-key" {
126+ t.Errorf("credential = %q, want real key", resolved.Credential)
127+ }
128+}
129+
130+func TestResolveOllamaStoredCredTriggersCloud(t *testing.T) {
131+ t.Setenv("ZUT_HOME", t.TempDir())
132+ t.Setenv("OLLAMA_API_KEY", "")
133+ encoded, err := json.Marshal(providerauth.Credentials{AdditionalAPIKeyCreds: map[string]providerauth.ProviderCreds{
134+ "ollama": {APIKey: "stored-key"},
135+ }})
136+ if err != nil {
137+ t.Fatal(err)
138+ }
139+ if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil {
140+ t.Fatal(err)
141+ }
142+
143+ cred, _, _, err := ResolveCredentialFull("ollama", "")
144+ if err != nil {
145+ t.Fatalf("ResolveCredentialFull failed: %v", err)
146+ }
147+ if cred != "stored-key" {
148+ t.Errorf("cred = %q, want stored-key", cred)
149+ }
150+
151+ resolved, err := Resolve(Args{Provider: "ollama", Model: "gemma4:31b"}, false)
152+ if err != nil {
153+ t.Fatal(err)
154+ }
155+ if resolved.Credential != "stored-key" {
156+ t.Errorf("credential = %q, want stored-key", resolved.Credential)
157+ }
158+ if resolved.BaseURL != "https://ollama.com/v1" {
159+ t.Errorf("baseURL = %q, want https://ollama.com/v1", resolved.BaseURL)
160+ }
161+}
M packages/agent/subagents/resident_manager_test.go +20 -11
@@ -10,6 +10,7 @@ import (
1010 "os/exec"
1111 "path/filepath"
1212 "strings"
13+ "sync/atomic"
1314 "testing"
1415 "time"
1516
@@ -517,7 +518,7 @@ func TestResidentManagerCapturesSuccessfulWorktreeBeforeCleanup(t *testing.T) {
517518 }
518519 time.Sleep(time.Millisecond)
519520 }
520- if workspace.cleaned {
521+ if workspace.Cleaned() {
521522 t.Fatal("idle child worktree was removed before explicit close")
522523 }
523524 result, err := manager.Result("capture-child")
@@ -534,7 +535,7 @@ func TestResidentManagerCapturesSuccessfulWorktreeBeforeCleanup(t *testing.T) {
534535 if err := manager.Stop(context.Background(), "capture-child"); err != nil {
535536 t.Fatal(err)
536537 }
537- if !workspace.cleaned {
538+ if !workspace.Cleaned() {
538539 t.Fatal("successful worktree was not cleaned on stop")
539540 }
540541 }
@@ -560,7 +561,7 @@ func TestResidentManagerCapturesFailedWorktreePatch(t *testing.T) {
560561 }
561562 // A non-canceled failure still captures partial work, but the turn
562563 // stays failed: capture never converts failure to success.
563- if !workspace.captured {
564+ if !workspace.Captured() {
564565 t.Fatal("failed worktree was not captured")
565566 }
566567 snapshot := manager.Snapshot()[0]
@@ -597,8 +598,8 @@ func TestResidentManagerRetainsFailedWorktree(t *testing.T) {
597598 }
598599 // Non-canceled failures capture partial work for recovery, but the
599600 // worktree is retained (never cleaned) until an explicit stop or close.
600- if !workspace.captured || workspace.cleaned {
601- t.Fatalf("failed worktree captured=%t cleaned=%t", workspace.captured, workspace.cleaned)
601+ if !workspace.Captured() || workspace.Cleaned() {
602+ t.Fatalf("failed worktree captured=%t cleaned=%t", workspace.Captured(), workspace.Cleaned())
602603 }
603604 }
604605
@@ -646,21 +647,29 @@ func TestResidentManagerCapturesRealWorktreePatch(t *testing.T) {
646647 }
647648 }
648649
650+// testResidentWorkspace flags are atomic because Capture/Cleanup run on
651+// the resident child's goroutine while tests read them after polling
652+// manager state (no happens-before edge the race detector can see).
649653 type testResidentWorkspace struct {
650- dir string
651- mode WorkspaceMode
652- capture WorkspaceCapture
653- captured, cleaned bool
654+ dir string
655+ mode WorkspaceMode
656+ capture WorkspaceCapture
657+
658+ captured atomic.Bool
659+ cleaned atomic.Bool
654660 }
655661
662+func (w *testResidentWorkspace) Captured() bool { return w.captured.Load() }
663+func (w *testResidentWorkspace) Cleaned() bool { return w.cleaned.Load() }
664+
656665 func (w *testResidentWorkspace) Dir() string { return w.dir }
657666 func (w *testResidentWorkspace) RepositoryRoot() string { return "/repo" }
658667 func (w *testResidentWorkspace) Mode() WorkspaceMode { return w.mode }
659668 func (w *testResidentWorkspace) Capture(context.Context) (WorkspaceCapture, error) {
660- w.captured = true
669+ w.captured.Store(true)
661670 return w.capture, nil
662671 }
663-func (w *testResidentWorkspace) Cleanup(context.Context) error { w.cleaned = true; return nil }
672+func (w *testResidentWorkspace) Cleanup(context.Context) error { w.cleaned.Store(true); return nil }
664673
665674 func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing.T) {
666675 root := t.TempDir()
@@ -10,6 +10,7 @@ import (
10 "os/exec"10 "os/exec"
11 "path/filepath"11 "path/filepath"
12 "strings"12 "strings"
13+ "sync/atomic"
13 "testing"14 "testing"
14 "time"15 "time"
15 16
@@ -517,7 +518,7 @@ func TestResidentManagerCapturesSuccessfulWorktreeBeforeCleanup(t *testing.T) {
517 }518 }
518 time.Sleep(time.Millisecond)519 time.Sleep(time.Millisecond)
519 }520 }
520- if workspace.cleaned {521+ if workspace.Cleaned() {
521 t.Fatal("idle child worktree was removed before explicit close")522 t.Fatal("idle child worktree was removed before explicit close")
522 }523 }
523 result, err := manager.Result("capture-child")524 result, err := manager.Result("capture-child")
@@ -534,7 +535,7 @@ func TestResidentManagerCapturesSuccessfulWorktreeBeforeCleanup(t *testing.T) {
534 if err := manager.Stop(context.Background(), "capture-child"); err != nil {535 if err := manager.Stop(context.Background(), "capture-child"); err != nil {
535 t.Fatal(err)536 t.Fatal(err)
536 }537 }
537- if !workspace.cleaned {538+ if !workspace.Cleaned() {
538 t.Fatal("successful worktree was not cleaned on stop")539 t.Fatal("successful worktree was not cleaned on stop")
539 }540 }
540 }541 }
@@ -560,7 +561,7 @@ func TestResidentManagerCapturesFailedWorktreePatch(t *testing.T) {
560 }561 }
561 // A non-canceled failure still captures partial work, but the turn562 // A non-canceled failure still captures partial work, but the turn
562 // stays failed: capture never converts failure to success.563 // stays failed: capture never converts failure to success.
563- if !workspace.captured {564+ if !workspace.Captured() {
564 t.Fatal("failed worktree was not captured")565 t.Fatal("failed worktree was not captured")
565 }566 }
566 snapshot := manager.Snapshot()[0]567 snapshot := manager.Snapshot()[0]
@@ -597,8 +598,8 @@ func TestResidentManagerRetainsFailedWorktree(t *testing.T) {
597 }598 }
598 // Non-canceled failures capture partial work for recovery, but the599 // Non-canceled failures capture partial work for recovery, but the
599 // worktree is retained (never cleaned) until an explicit stop or close.600 // worktree is retained (never cleaned) until an explicit stop or close.
600- if !workspace.captured || workspace.cleaned {601+ if !workspace.Captured() || workspace.Cleaned() {
601- t.Fatalf("failed worktree captured=%t cleaned=%t", workspace.captured, workspace.cleaned)602+ t.Fatalf("failed worktree captured=%t cleaned=%t", workspace.Captured(), workspace.Cleaned())
602 }603 }
603 }604 }
604 605
@@ -646,21 +647,29 @@ func TestResidentManagerCapturesRealWorktreePatch(t *testing.T) {
646 }647 }
647 }648 }
648 649
650+// testResidentWorkspace flags are atomic because Capture/Cleanup run on
651+// the resident child's goroutine while tests read them after polling
652+// manager state (no happens-before edge the race detector can see).
649 type testResidentWorkspace struct {653 type testResidentWorkspace struct {
650- dir string654+ dir string
651- mode WorkspaceMode655+ mode WorkspaceMode
652- capture WorkspaceCapture656+ capture WorkspaceCapture
653- captured, cleaned bool657+
658+ captured atomic.Bool
659+ cleaned atomic.Bool
654 }660 }
655 661
662+func (w *testResidentWorkspace) Captured() bool { return w.captured.Load() }
663+func (w *testResidentWorkspace) Cleaned() bool { return w.cleaned.Load() }
664+
656 func (w *testResidentWorkspace) Dir() string { return w.dir }665 func (w *testResidentWorkspace) Dir() string { return w.dir }
657 func (w *testResidentWorkspace) RepositoryRoot() string { return "/repo" }666 func (w *testResidentWorkspace) RepositoryRoot() string { return "/repo" }
658 func (w *testResidentWorkspace) Mode() WorkspaceMode { return w.mode }667 func (w *testResidentWorkspace) Mode() WorkspaceMode { return w.mode }
659 func (w *testResidentWorkspace) Capture(context.Context) (WorkspaceCapture, error) {668 func (w *testResidentWorkspace) Capture(context.Context) (WorkspaceCapture, error) {
660- w.captured = true669+ w.captured.Store(true)
661 return w.capture, nil670 return w.capture, nil
662 }671 }
663-func (w *testResidentWorkspace) Cleanup(context.Context) error { w.cleaned = true; return nil }672+func (w *testResidentWorkspace) Cleanup(context.Context) error { w.cleaned.Store(true); return nil }
664 673
665 func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing.T) {674 func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing.T) {
666 root := t.TempDir()675 root := t.TempDir()
M packages/provider/auth/auth_test.go +13 -0
@@ -11,6 +11,19 @@ import (
1111 "time"
1212 )
1313
14+func TestAPIKeyProvidersIncludesOllama(t *testing.T) {
15+ found := false
16+ for _, p := range APIKeyProviders() {
17+ if p == "ollama" {
18+ found = true
19+ break
20+ }
21+ }
22+ if !found {
23+ t.Fatalf("APIKeyProviders() = %v, want ollama listed", APIKeyProviders())
24+ }
25+}
26+
1427 func TestProbeAPIKeyAcceptsExtraProviderWithoutProbe(t *testing.T) {
1528 SetExtraAPIKeyProviders([]string{"my-company"})
1629 t.Cleanup(func() { SetExtraAPIKeyProviders(nil) })
@@ -11,6 +11,19 @@ import (
11 "time"11 "time"
12 )12 )
13 13
14+func TestAPIKeyProvidersIncludesOllama(t *testing.T) {
15+ found := false
16+ for _, p := range APIKeyProviders() {
17+ if p == "ollama" {
18+ found = true
19+ break
20+ }
21+ }
22+ if !found {
23+ t.Fatalf("APIKeyProviders() = %v, want ollama listed", APIKeyProviders())
24+ }
25+}
26+
14 func TestProbeAPIKeyAcceptsExtraProviderWithoutProbe(t *testing.T) {27 func TestProbeAPIKeyAcceptsExtraProviderWithoutProbe(t *testing.T) {
15 SetExtraAPIKeyProviders([]string{"my-company"})28 SetExtraAPIKeyProviders([]string{"my-company"})
16 t.Cleanup(func() { SetExtraAPIKeyProviders(nil) })29 t.Cleanup(func() { SetExtraAPIKeyProviders(nil) })
M packages/provider/auth/probe.go +16 -0
@@ -45,6 +45,17 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error {
4545 return err
4646 }
4747 req.Header.Set("authorization", "Bearer "+key)
48+ case "ollama":
49+ // GET /v1/models is public on ollama.com (200 without a key),
50+ // so it cannot validate one. POST /v1/chat/completions is
51+ // auth-gated: 401/403 means the key was rejected. The empty
52+ // body is enough — auth runs before request validation.
53+ req, err = http.NewRequestWithContext(ctx, "POST", "https://ollama.com/v1/chat/completions", strings.NewReader("{}"))
54+ if err != nil {
55+ return err
56+ }
57+ req.Header.Set("authorization", "Bearer "+key)
58+ req.Header.Set("content-type", "application/json")
4859 case "google":
4960 // Google Generative Language: list models with the API key.
5061 // Accepts the key via x-goog-api-key header (preferred over
@@ -212,6 +223,11 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error {
212223 if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
213224 return fmt.Errorf("%s rejected the key (http %d)", provider, resp.StatusCode)
214225 }
226+ if provider == "ollama" && resp.StatusCode == http.StatusBadRequest {
227+ // Chat-completions probe with an empty body: auth passed,
228+ // request validation failed. Key is valid.
229+ return nil
230+ }
215231 if resp.StatusCode >= 400 {
216232 return fmt.Errorf("%s http %d", provider, resp.StatusCode)
217233 }
@@ -45,6 +45,17 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error {
45 return err45 return err
46 }46 }
47 req.Header.Set("authorization", "Bearer "+key)47 req.Header.Set("authorization", "Bearer "+key)
48+ case "ollama":
49+ // GET /v1/models is public on ollama.com (200 without a key),
50+ // so it cannot validate one. POST /v1/chat/completions is
51+ // auth-gated: 401/403 means the key was rejected. The empty
52+ // body is enough — auth runs before request validation.
53+ req, err = http.NewRequestWithContext(ctx, "POST", "https://ollama.com/v1/chat/completions", strings.NewReader("{}"))
54+ if err != nil {
55+ return err
56+ }
57+ req.Header.Set("authorization", "Bearer "+key)
58+ req.Header.Set("content-type", "application/json")
48 case "google":59 case "google":
49 // Google Generative Language: list models with the API key.60 // Google Generative Language: list models with the API key.
50 // Accepts the key via x-goog-api-key header (preferred over61 // Accepts the key via x-goog-api-key header (preferred over
@@ -212,6 +223,11 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error {
212 if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {223 if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
213 return fmt.Errorf("%s rejected the key (http %d)", provider, resp.StatusCode)224 return fmt.Errorf("%s rejected the key (http %d)", provider, resp.StatusCode)
214 }225 }
226+ if provider == "ollama" && resp.StatusCode == http.StatusBadRequest {
227+ // Chat-completions probe with an empty body: auth passed,
228+ // request validation failed. Key is valid.
229+ return nil
230+ }
215 if resp.StatusCode >= 400 {231 if resp.StatusCode >= 400 {
216 return fmt.Errorf("%s http %d", provider, resp.StatusCode)232 return fmt.Errorf("%s http %d", provider, resp.StatusCode)
217 }233 }
M packages/provider/auth/server.go +1 -1
@@ -136,7 +136,7 @@ func apiKeyProviderMessage() string {
136136 // APIKeyProviders is the ordered list shown by /login -> api key.
137137 func APIKeyProviders() []string {
138138 return []string{
139- "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp",
139+ "anthropic", "openai", "kimi", "deepseek", "google", "ollama", "llama.cpp",
140140 "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together",
141141 "huggingface", "openrouter", "mistral", "zai",
142142 "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
@@ -136,7 +136,7 @@ func apiKeyProviderMessage() string {
136 // APIKeyProviders is the ordered list shown by /login -> api key.136 // APIKeyProviders is the ordered list shown by /login -> api key.
137 func APIKeyProviders() []string {137 func APIKeyProviders() []string {
138 return []string{138 return []string{
139- "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp",139+ "anthropic", "openai", "kimi", "deepseek", "google", "ollama", "llama.cpp",
140 "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together",140 "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together",
141 "huggingface", "openrouter", "mistral", "zai",141 "huggingface", "openrouter", "mistral", "zai",
142 "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",142 "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close