detached fc39bd9

Add command-backed API keys

commit
fc39bd968fa2a1a0106799f8da07b637e24f56af
author
patriceckhart · · 2026-07-24 11:32 UTC
parent
82dd8e4
signature
unsigned
refs
tag: v0.3.4
16 files · +667 -60
M README.md +22 -0
@@ -87,6 +87,28 @@ The easiest way is to just run `zot` and type `/login`. The TUI opens even witho
8787 - Linux fallback: `~/.local/state/zot`
8888 - Windows fallback: `%LOCALAPPDATA%\zot`
8989
90+### API keys from commands
91+
92+To keep an API key in a password manager instead of `auth.json`, configure an `api_key_command` for the provider:
93+
94+```json
95+{
96+ "anthropic": {
97+ "api_key_command": {
98+ "program": "op",
99+ "args": ["read", "op://Work/Anthropic/credential"],
100+ "timeout_ms": 120000
101+ }
102+ }
103+}
104+```
105+
106+For a provider added through `models.json`, put the same credential object under `additional_api_key_creds` using its provider ID. `program` is executed directly, without a shell, so each argument must be a separate `args` entry. `timeout_ms` is optional and defaults to 120 seconds.
107+
108+zot runs the command only when that provider is selected, not while checking login status or refreshing model catalogs in the background. Successful output is cached in memory for the rest of the zot process and is never written to disk. The command must print one non-empty line to stdout; zot removes trailing CR/LF characters, limits output to 64 KiB, and does not include command output in errors. Saving a normal key through `/login` replaces the command configuration, and `/logout` removes it.
109+
110+Treat `auth.json` as executable configuration: anyone who can modify it can cause zot to run a program under your user account. zot does not interpret `!` prefixes or execute command strings through a shell.
111+
90112 ### `/login` flow
91113
92114 Run `zot` and type `/login`. Pick one of two methods:
@@ -87,6 +87,28 @@ The easiest way is to just run `zot` and type `/login`. The TUI opens even witho
87 - Linux fallback: `~/.local/state/zot`87 - Linux fallback: `~/.local/state/zot`
88 - Windows fallback: `%LOCALAPPDATA%\zot`88 - Windows fallback: `%LOCALAPPDATA%\zot`
89 89
90+### API keys from commands
91+
92+To keep an API key in a password manager instead of `auth.json`, configure an `api_key_command` for the provider:
93+
94+```json
95+{
96+ "anthropic": {
97+ "api_key_command": {
98+ "program": "op",
99+ "args": ["read", "op://Work/Anthropic/credential"],
100+ "timeout_ms": 120000
101+ }
102+ }
103+}
104+```
105+
106+For a provider added through `models.json`, put the same credential object under `additional_api_key_creds` using its provider ID. `program` is executed directly, without a shell, so each argument must be a separate `args` entry. `timeout_ms` is optional and defaults to 120 seconds.
107+
108+zot runs the command only when that provider is selected, not while checking login status or refreshing model catalogs in the background. Successful output is cached in memory for the rest of the zot process and is never written to disk. The command must print one non-empty line to stdout; zot removes trailing CR/LF characters, limits output to 64 KiB, and does not include command output in errors. Saving a normal key through `/login` replaces the command configuration, and `/logout` removes it.
109+
110+Treat `auth.json` as executable configuration: anyone who can modify it can cause zot to run a program under your user account. zot does not interpret `!` prefixes or execute command strings through a shell.
111+
90 ### `/login` flow112 ### `/login` flow
91 113
92 Run `zot` and type `/login`. Pick one of two methods:114 Run `zot` and type `/login`. Pick one of two methods:
M docs/providers.md +35 -0
@@ -18,6 +18,41 @@ Some providers need more than a single pasted key. For those providers,
1818 This avoids broken browser flows in SSH, containers, and `kubectl exec`
1919 sessions.
2020
21+### Command-backed API keys
22+
23+A provider can obtain its API key from a password manager or another local program. Configure this directly in `$ZOT_HOME/auth.json`:
24+
25+```json
26+{
27+ "openai": {
28+ "api_key_command": {
29+ "program": "op",
30+ "args": ["read", "op://Work/OpenAI/credential"],
31+ "timeout_ms": 120000
32+ }
33+ }
34+}
35+```
36+
37+Custom provider credentials use the same object under `additional_api_key_creds`:
38+
39+```json
40+{
41+ "additional_api_key_creds": {
42+ "my-company": {
43+ "api_key_command": {
44+ "program": "secret-tool",
45+ "args": ["read", "my-company-api-key"]
46+ }
47+ }
48+ }
49+}
50+```
51+
52+The program is started directly rather than through a shell. The timeout defaults to 120 seconds. It must write one non-empty line to stdout, with no more than 64 KiB of output. Trailing CR/LF characters are removed. Successful results stay only in process memory and are reused until zot exits.
53+
54+Command-backed credentials count as logged in without being executed. zot materializes one only when selecting that provider; background model discovery skips it. `/login` with a normal key replaces the command, while `/logout` clears it. Because `auth.json` can cause program execution, keep it user-writable only and do not use files from untrusted sources.
55+
2156 Setup-instruction providers:
2257
2358 - Amazon Bedrock
@@ -18,6 +18,41 @@ Some providers need more than a single pasted key. For those providers,
18 This avoids broken browser flows in SSH, containers, and `kubectl exec`18 This avoids broken browser flows in SSH, containers, and `kubectl exec`
19 sessions.19 sessions.
20 20
21+### Command-backed API keys
22+
23+A provider can obtain its API key from a password manager or another local program. Configure this directly in `$ZOT_HOME/auth.json`:
24+
25+```json
26+{
27+ "openai": {
28+ "api_key_command": {
29+ "program": "op",
30+ "args": ["read", "op://Work/OpenAI/credential"],
31+ "timeout_ms": 120000
32+ }
33+ }
34+}
35+```
36+
37+Custom provider credentials use the same object under `additional_api_key_creds`:
38+
39+```json
40+{
41+ "additional_api_key_creds": {
42+ "my-company": {
43+ "api_key_command": {
44+ "program": "secret-tool",
45+ "args": ["read", "my-company-api-key"]
46+ }
47+ }
48+ }
49+}
50+```
51+
52+The program is started directly rather than through a shell. The timeout defaults to 120 seconds. It must write one non-empty line to stdout, with no more than 64 KiB of output. Trailing CR/LF characters are removed. Successful results stay only in process memory and are reused until zot exits.
53+
54+Command-backed credentials count as logged in without being executed. zot materializes one only when selecting that provider; background model discovery skips it. `/login` with a normal key replaces the command, while `/logout` clears it. Because `auth.json` can cause program execution, keep it user-writable only and do not use files from untrusted sources.
55+
21 Setup-instruction providers:56 Setup-instruction providers:
22 57
23 - Amazon Bedrock58 - Amazon Bedrock
M packages/agent/build.go +11 -9
@@ -324,19 +324,19 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
324324 // by isKnownProvider, so we only reach here for truly unknown
325325 // names.
326326 provName = "anthropic"
327- if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
327+ if CredentialAvailable("openai") {
328328 provName = "openai"
329329 }
330- if _, _, _, err := ResolveCredentialFull("openai-codex", ""); err == nil {
330+ if CredentialAvailable("openai-codex") {
331331 provName = "openai-codex"
332332 }
333- if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {
333+ if CredentialAvailable("kimi") {
334334 provName = "kimi"
335335 }
336- if _, _, _, err := ResolveCredentialFull("deepseek", ""); err == nil {
336+ if CredentialAvailable("deepseek") {
337337 provName = "deepseek"
338338 }
339- if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
339+ if CredentialAvailable("anthropic") {
340340 provName = "anthropic"
341341 }
342342 // Reset the saved config so this doesn't keep happening.
@@ -400,11 +400,13 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
400400 if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID {
401401 continue
402402 }
403- if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
404- provName = other
405- cred, method, accountID, credErr = c, m, a, err
406- break
403+ if !CredentialAvailable(other) {
404+ continue
407405 }
406+ c, m, a, err := ResolveCredentialFull(other, args.APIKey)
407+ provName = other
408+ cred, method, accountID, credErr = c, m, a, err
409+ break
408410 }
409411 }
410412
@@ -324,19 +324,19 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
324 // by isKnownProvider, so we only reach here for truly unknown324 // by isKnownProvider, so we only reach here for truly unknown
325 // names.325 // names.
326 provName = "anthropic"326 provName = "anthropic"
327- if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {327+ if CredentialAvailable("openai") {
328 provName = "openai"328 provName = "openai"
329 }329 }
330- if _, _, _, err := ResolveCredentialFull("openai-codex", ""); err == nil {330+ if CredentialAvailable("openai-codex") {
331 provName = "openai-codex"331 provName = "openai-codex"
332 }332 }
333- if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {333+ if CredentialAvailable("kimi") {
334 provName = "kimi"334 provName = "kimi"
335 }335 }
336- if _, _, _, err := ResolveCredentialFull("deepseek", ""); err == nil {336+ if CredentialAvailable("deepseek") {
337 provName = "deepseek"337 provName = "deepseek"
338 }338 }
339- if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {339+ if CredentialAvailable("anthropic") {
340 provName = "anthropic"340 provName = "anthropic"
341 }341 }
342 // Reset the saved config so this doesn't keep happening.342 // Reset the saved config so this doesn't keep happening.
@@ -400,11 +400,13 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
400 if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID {400 if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID {
401 continue401 continue
402 }402 }
403- if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {403+ if !CredentialAvailable(other) {
404- provName = other404+ continue
405- cred, method, accountID, credErr = c, m, a, err
406- break
407 }405 }
406+ c, m, a, err := ResolveCredentialFull(other, args.APIKey)
407+ provName = other
408+ cred, method, accountID, credErr = c, m, a, err
409+ break
408 }410 }
409 }411 }
410 412
M packages/agent/cli.go +2 -2
@@ -1032,14 +1032,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10321032 var out []string
10331033 seen := map[string]bool{}
10341034 for _, p := range knownProviders {
1035- if _, _, err := ResolveCredential(p, ""); err == nil && !seen[p] {
1035+ if CredentialAvailable(p) && !seen[p] {
10361036 out = append(out, p)
10371037 seen[p] = true
10381038 }
10391039 }
10401040 // Include custom providers that have credentials stored.
10411041 for p := range provider.CustomProviders() {
1042- if _, _, err := ResolveCredential(p, ""); err == nil && !seen[p] {
1042+ if CredentialAvailable(p) && !seen[p] {
10431043 out = append(out, p)
10441044 seen[p] = true
10451045 }
@@ -1032,14 +1032,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1032 var out []string1032 var out []string
1033 seen := map[string]bool{}1033 seen := map[string]bool{}
1034 for _, p := range knownProviders {1034 for _, p := range knownProviders {
1035- if _, _, err := ResolveCredential(p, ""); err == nil && !seen[p] {1035+ if CredentialAvailable(p) && !seen[p] {
1036 out = append(out, p)1036 out = append(out, p)
1037 seen[p] = true1037 seen[p] = true
1038 }1038 }
1039 }1039 }
1040 // Include custom providers that have credentials stored.1040 // Include custom providers that have credentials stored.
1041 for p := range provider.CustomProviders() {1041 for p := range provider.CustomProviders() {
1042- if _, _, err := ResolveCredential(p, ""); err == nil && !seen[p] {1042+ if CredentialAvailable(p) && !seen[p] {
1043 out = append(out, p)1043 out = append(out, p)
1044 seen[p] = true1044 seen[p] = true
1045 }1045 }
M packages/agent/config.go +103 -33
@@ -225,17 +225,30 @@ func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) }
225225 // ResolveLlamaCPPConfig resolves the router URL and optional API key. The
226226 // environment overrides the credential stored through /login.
227227 func ResolveLlamaCPPConfig() (baseURL, apiKey string, err error) {
228+ return resolveLlamaCPPConfig(context.Background(), apiKeyCommandExecute)
229+}
230+
231+func resolveLlamaCPPConfig(ctx context.Context, commandMode apiKeyCommandMode) (baseURL, apiKey string, err error) {
228232 baseURL = strings.TrimSpace(os.Getenv("LLAMA_BASE_URL"))
229233 apiKey = strings.TrimSpace(os.Getenv("LLAMA_API_KEY"))
230234 if baseURL == "" {
231- var storedKey string
232- var loadErr error
233- baseURL, storedKey, loadErr = AuthStoreFor().EndpointCredential(providerpkg.LlamaCPPProviderID)
235+ creds, loadErr := AuthStoreFor().Load()
234236 if loadErr != nil {
235237 return "", "", loadErr
236238 }
237- if apiKey == "" {
238- apiKey = storedKey
239+ if stored, ok := creds.AdditionalAPIKeyCreds[providerpkg.LlamaCPPProviderID]; ok {
240+ baseURL = stored.BaseURL
241+ if apiKey == "" {
242+ if stored.APIKeyCommand != nil && commandMode == apiKeyCommandSkip {
243+ return "", "", nil
244+ }
245+ if key, found, commandErr := resolveStoredAPIKey(ctx, providerpkg.LlamaCPPProviderID, stored, commandMode); found || commandErr != nil {
246+ if commandErr != nil {
247+ return "", "", commandErr
248+ }
249+ apiKey = key
250+ }
251+ }
239252 }
240253 }
241254 if baseURL == "" {
@@ -258,11 +271,34 @@ func ResolveCredential(provider, explicit string) (cred, method string, err erro
258271 return cred, method, err
259272 }
260273
274+// ResolveCredentialContext is ResolveCredential with caller cancellation.
275+func ResolveCredentialContext(ctx context.Context, provider, explicit string) (cred, method string, err error) {
276+ cred, method, _, err = ResolveCredentialFullContext(ctx, provider, explicit)
277+ return cred, method, err
278+}
279+
261280 // ResolveCredentialFull is like ResolveCredential but also returns a
262281 // provider-specific accountID when the credential is an OpenAI OAuth
263282 // token (the ChatGPT account id extracted from the stored id_token).
264283 // accountID is "" for API-key auth and for anthropic.
265284 func ResolveCredentialFull(provider, explicit string) (cred, method, accountID string, err error) {
285+ return ResolveCredentialFullContext(context.Background(), provider, explicit)
286+}
287+
288+// ResolveCredentialFullContext is ResolveCredentialFull with caller cancellation.
289+func ResolveCredentialFullContext(ctx context.Context, provider, explicit string) (cred, method, accountID string, err error) {
290+ return resolveCredentialFull(ctx, provider, explicit, apiKeyCommandExecute)
291+}
292+
293+type apiKeyCommandMode uint8
294+
295+const (
296+ apiKeyCommandExecute apiKeyCommandMode = iota
297+ apiKeyCommandAvailable
298+ apiKeyCommandSkip
299+)
300+
301+func resolveCredentialFull(ctx context.Context, provider, explicit string, commandMode apiKeyCommandMode) (cred, method, accountID string, err error) {
266302 if explicit != "" {
267303 return explicit, "apikey", "", nil
268304 }
@@ -313,7 +349,7 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
313349 return v, "apikey", "", nil
314350 }
315351 case "llama.cpp":
316- baseURL, apiKey, resolveErr := ResolveLlamaCPPConfig()
352+ baseURL, apiKey, resolveErr := resolveLlamaCPPConfig(ctx, commandMode)
317353 if resolveErr != nil {
318354 return "", "", "", resolveErr
319355 }
@@ -444,9 +480,12 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
444480 if err != nil {
445481 return "", "", "", err
446482 }
447- if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok {
448- if pc.APIKey != "" {
449- return pc.APIKey, "apikey", "", nil
483+ if pc, ok := storedProviderCreds(c, provider); ok {
484+ if key, found, commandErr := resolveStoredAPIKey(ctx, provider, pc, commandMode); found || commandErr != nil {
485+ if commandErr != nil {
486+ return "", "", "", commandErr
487+ }
488+ return key, "apikey", "", nil
450489 }
451490 if provider == "xai" && pc.OAuth != nil && pc.OAuth.AccessToken != "" {
452491 tok, _ := refreshIfExpired(provider, pc.OAuth)
@@ -455,26 +494,16 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
455494 }
456495 switch provider {
457496 case "anthropic":
458- if c.Anthropic.APIKey != "" {
459- return c.Anthropic.APIKey, "apikey", "", nil
460- }
461497 if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" {
462498 tok, _ := refreshIfExpired("anthropic", c.Anthropic.OAuth)
463499 return tok.AccessToken, "oauth", "", nil
464500 }
465- case "openai":
466- if c.OpenAI.APIKey != "" {
467- return c.OpenAI.APIKey, "apikey", "", nil
468- }
469501 case "openai-codex":
470502 if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" {
471503 tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)
472504 return tok.AccessToken, "oauth", tok.AccountID, nil
473505 }
474506 case "kimi":
475- if c.Kimi.APIKey != "" {
476- return c.Kimi.APIKey, "apikey", "", nil
477- }
478507 if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" {
479508 tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth)
480509 return tok.AccessToken, "oauth", "", nil
@@ -486,21 +515,7 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
486515 tok, _ = refreshIfExpired("kimi", tok)
487516 return tok.AccessToken, "oauth", "", nil
488517 }
489- case "deepseek":
490- if c.DeepSeek.APIKey != "" {
491- return c.DeepSeek.APIKey, "apikey", "", nil
492- }
493- case "google":
494- // Google is API-key only — no OAuth path. We still load
495- // auth.json so /login api-key flows work without exporting
496- // an env var.
497- if c.Google.APIKey != "" {
498- return c.Google.APIKey, "apikey", "", nil
499- }
500518 case "github-copilot":
501- if c.GithubCopilot.APIKey != "" {
502- return c.GithubCopilot.APIKey, "apikey", "", nil
503- }
504519 if c.GithubCopilot.OAuth != nil && c.GithubCopilot.OAuth.AccessToken != "" {
505520 return c.GithubCopilot.OAuth.AccessToken, "oauth", "", nil
506521 }
@@ -508,6 +523,61 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
508523 return "", "", "", fmt.Errorf("no credential for %s", provider)
509524 }
510525
526+// CredentialAvailable reports whether a provider has a configured credential
527+// without executing an api_key_command.
528+func CredentialAvailable(provider string) bool {
529+ _, _, _, err := resolveCredentialFull(context.Background(), provider, "", apiKeyCommandAvailable)
530+ return err == nil
531+}
532+
533+func resolveCredentialForBackground(ctx context.Context, provider string) (cred, method string, err error) {
534+ cred, method, _, err = resolveCredentialFull(ctx, provider, "", apiKeyCommandSkip)
535+ return cred, method, err
536+}
537+
538+func storedProviderCreds(c auth.Credentials, provider string) (auth.ProviderCreds, bool) {
539+ if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok {
540+ return pc, true
541+ }
542+ switch provider {
543+ case "anthropic":
544+ return c.Anthropic, true
545+ case "openai":
546+ return c.OpenAI, true
547+ case "kimi":
548+ return c.Kimi, true
549+ case "google":
550+ return c.Google, true
551+ case "deepseek":
552+ return c.DeepSeek, true
553+ case "github-copilot":
554+ return c.GithubCopilot, true
555+ default:
556+ return auth.ProviderCreds{}, false
557+ }
558+}
559+
560+func resolveStoredAPIKey(ctx context.Context, provider string, creds auth.ProviderCreds, mode apiKeyCommandMode) (key string, found bool, err error) {
561+ if creds.APIKey != "" {
562+ return creds.APIKey, true, nil
563+ }
564+ if creds.APIKeyCommand == nil {
565+ return "", false, nil
566+ }
567+ switch mode {
568+ case apiKeyCommandAvailable:
569+ return "<api-key-command>", true, nil
570+ case apiKeyCommandSkip:
571+ return "", false, nil
572+ default:
573+ key, err := auth.ResolveAPIKeyCommand(ctx, *creds.APIKeyCommand)
574+ if err != nil {
575+ return "", true, fmt.Errorf("resolve api key for %s: %w", provider, err)
576+ }
577+ return key, true, nil
578+ }
579+}
580+
511581 func kimiCLIFallbackDisabled() bool {
512582 _, err := os.Stat(KimiCLIFallbackDisabledPath())
513583 return err == nil
@@ -225,17 +225,30 @@ func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) }
225 // ResolveLlamaCPPConfig resolves the router URL and optional API key. The225 // ResolveLlamaCPPConfig resolves the router URL and optional API key. The
226 // environment overrides the credential stored through /login.226 // environment overrides the credential stored through /login.
227 func ResolveLlamaCPPConfig() (baseURL, apiKey string, err error) {227 func ResolveLlamaCPPConfig() (baseURL, apiKey string, err error) {
228+ return resolveLlamaCPPConfig(context.Background(), apiKeyCommandExecute)
229+}
230+
231+func resolveLlamaCPPConfig(ctx context.Context, commandMode apiKeyCommandMode) (baseURL, apiKey string, err error) {
228 baseURL = strings.TrimSpace(os.Getenv("LLAMA_BASE_URL"))232 baseURL = strings.TrimSpace(os.Getenv("LLAMA_BASE_URL"))
229 apiKey = strings.TrimSpace(os.Getenv("LLAMA_API_KEY"))233 apiKey = strings.TrimSpace(os.Getenv("LLAMA_API_KEY"))
230 if baseURL == "" {234 if baseURL == "" {
231- var storedKey string235+ creds, loadErr := AuthStoreFor().Load()
232- var loadErr error
233- baseURL, storedKey, loadErr = AuthStoreFor().EndpointCredential(providerpkg.LlamaCPPProviderID)
234 if loadErr != nil {236 if loadErr != nil {
235 return "", "", loadErr237 return "", "", loadErr
236 }238 }
237- if apiKey == "" {239+ if stored, ok := creds.AdditionalAPIKeyCreds[providerpkg.LlamaCPPProviderID]; ok {
238- apiKey = storedKey240+ baseURL = stored.BaseURL
241+ if apiKey == "" {
242+ if stored.APIKeyCommand != nil && commandMode == apiKeyCommandSkip {
243+ return "", "", nil
244+ }
245+ if key, found, commandErr := resolveStoredAPIKey(ctx, providerpkg.LlamaCPPProviderID, stored, commandMode); found || commandErr != nil {
246+ if commandErr != nil {
247+ return "", "", commandErr
248+ }
249+ apiKey = key
250+ }
251+ }
239 }252 }
240 }253 }
241 if baseURL == "" {254 if baseURL == "" {
@@ -258,11 +271,34 @@ func ResolveCredential(provider, explicit string) (cred, method string, err erro
258 return cred, method, err271 return cred, method, err
259 }272 }
260 273
274+// ResolveCredentialContext is ResolveCredential with caller cancellation.
275+func ResolveCredentialContext(ctx context.Context, provider, explicit string) (cred, method string, err error) {
276+ cred, method, _, err = ResolveCredentialFullContext(ctx, provider, explicit)
277+ return cred, method, err
278+}
279+
261 // ResolveCredentialFull is like ResolveCredential but also returns a280 // ResolveCredentialFull is like ResolveCredential but also returns a
262 // provider-specific accountID when the credential is an OpenAI OAuth281 // provider-specific accountID when the credential is an OpenAI OAuth
263 // token (the ChatGPT account id extracted from the stored id_token).282 // token (the ChatGPT account id extracted from the stored id_token).
264 // accountID is "" for API-key auth and for anthropic.283 // accountID is "" for API-key auth and for anthropic.
265 func ResolveCredentialFull(provider, explicit string) (cred, method, accountID string, err error) {284 func ResolveCredentialFull(provider, explicit string) (cred, method, accountID string, err error) {
285+ return ResolveCredentialFullContext(context.Background(), provider, explicit)
286+}
287+
288+// ResolveCredentialFullContext is ResolveCredentialFull with caller cancellation.
289+func ResolveCredentialFullContext(ctx context.Context, provider, explicit string) (cred, method, accountID string, err error) {
290+ return resolveCredentialFull(ctx, provider, explicit, apiKeyCommandExecute)
291+}
292+
293+type apiKeyCommandMode uint8
294+
295+const (
296+ apiKeyCommandExecute apiKeyCommandMode = iota
297+ apiKeyCommandAvailable
298+ apiKeyCommandSkip
299+)
300+
301+func resolveCredentialFull(ctx context.Context, provider, explicit string, commandMode apiKeyCommandMode) (cred, method, accountID string, err error) {
266 if explicit != "" {302 if explicit != "" {
267 return explicit, "apikey", "", nil303 return explicit, "apikey", "", nil
268 }304 }
@@ -313,7 +349,7 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
313 return v, "apikey", "", nil349 return v, "apikey", "", nil
314 }350 }
315 case "llama.cpp":351 case "llama.cpp":
316- baseURL, apiKey, resolveErr := ResolveLlamaCPPConfig()352+ baseURL, apiKey, resolveErr := resolveLlamaCPPConfig(ctx, commandMode)
317 if resolveErr != nil {353 if resolveErr != nil {
318 return "", "", "", resolveErr354 return "", "", "", resolveErr
319 }355 }
@@ -444,9 +480,12 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
444 if err != nil {480 if err != nil {
445 return "", "", "", err481 return "", "", "", err
446 }482 }
447- if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok {483+ if pc, ok := storedProviderCreds(c, provider); ok {
448- if pc.APIKey != "" {484+ if key, found, commandErr := resolveStoredAPIKey(ctx, provider, pc, commandMode); found || commandErr != nil {
449- return pc.APIKey, "apikey", "", nil485+ if commandErr != nil {
486+ return "", "", "", commandErr
487+ }
488+ return key, "apikey", "", nil
450 }489 }
451 if provider == "xai" && pc.OAuth != nil && pc.OAuth.AccessToken != "" {490 if provider == "xai" && pc.OAuth != nil && pc.OAuth.AccessToken != "" {
452 tok, _ := refreshIfExpired(provider, pc.OAuth)491 tok, _ := refreshIfExpired(provider, pc.OAuth)
@@ -455,26 +494,16 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
455 }494 }
456 switch provider {495 switch provider {
457 case "anthropic":496 case "anthropic":
458- if c.Anthropic.APIKey != "" {
459- return c.Anthropic.APIKey, "apikey", "", nil
460- }
461 if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" {497 if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" {
462 tok, _ := refreshIfExpired("anthropic", c.Anthropic.OAuth)498 tok, _ := refreshIfExpired("anthropic", c.Anthropic.OAuth)
463 return tok.AccessToken, "oauth", "", nil499 return tok.AccessToken, "oauth", "", nil
464 }500 }
465- case "openai":
466- if c.OpenAI.APIKey != "" {
467- return c.OpenAI.APIKey, "apikey", "", nil
468- }
469 case "openai-codex":501 case "openai-codex":
470 if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" {502 if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" {
471 tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)503 tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)
472 return tok.AccessToken, "oauth", tok.AccountID, nil504 return tok.AccessToken, "oauth", tok.AccountID, nil
473 }505 }
474 case "kimi":506 case "kimi":
475- if c.Kimi.APIKey != "" {
476- return c.Kimi.APIKey, "apikey", "", nil
477- }
478 if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" {507 if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" {
479 tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth)508 tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth)
480 return tok.AccessToken, "oauth", "", nil509 return tok.AccessToken, "oauth", "", nil
@@ -486,21 +515,7 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
486 tok, _ = refreshIfExpired("kimi", tok)515 tok, _ = refreshIfExpired("kimi", tok)
487 return tok.AccessToken, "oauth", "", nil516 return tok.AccessToken, "oauth", "", nil
488 }517 }
489- case "deepseek":
490- if c.DeepSeek.APIKey != "" {
491- return c.DeepSeek.APIKey, "apikey", "", nil
492- }
493- case "google":
494- // Google is API-key only — no OAuth path. We still load
495- // auth.json so /login api-key flows work without exporting
496- // an env var.
497- if c.Google.APIKey != "" {
498- return c.Google.APIKey, "apikey", "", nil
499- }
500 case "github-copilot":518 case "github-copilot":
501- if c.GithubCopilot.APIKey != "" {
502- return c.GithubCopilot.APIKey, "apikey", "", nil
503- }
504 if c.GithubCopilot.OAuth != nil && c.GithubCopilot.OAuth.AccessToken != "" {519 if c.GithubCopilot.OAuth != nil && c.GithubCopilot.OAuth.AccessToken != "" {
505 return c.GithubCopilot.OAuth.AccessToken, "oauth", "", nil520 return c.GithubCopilot.OAuth.AccessToken, "oauth", "", nil
506 }521 }
@@ -508,6 +523,61 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
508 return "", "", "", fmt.Errorf("no credential for %s", provider)523 return "", "", "", fmt.Errorf("no credential for %s", provider)
509 }524 }
510 525
526+// CredentialAvailable reports whether a provider has a configured credential
527+// without executing an api_key_command.
528+func CredentialAvailable(provider string) bool {
529+ _, _, _, err := resolveCredentialFull(context.Background(), provider, "", apiKeyCommandAvailable)
530+ return err == nil
531+}
532+
533+func resolveCredentialForBackground(ctx context.Context, provider string) (cred, method string, err error) {
534+ cred, method, _, err = resolveCredentialFull(ctx, provider, "", apiKeyCommandSkip)
535+ return cred, method, err
536+}
537+
538+func storedProviderCreds(c auth.Credentials, provider string) (auth.ProviderCreds, bool) {
539+ if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok {
540+ return pc, true
541+ }
542+ switch provider {
543+ case "anthropic":
544+ return c.Anthropic, true
545+ case "openai":
546+ return c.OpenAI, true
547+ case "kimi":
548+ return c.Kimi, true
549+ case "google":
550+ return c.Google, true
551+ case "deepseek":
552+ return c.DeepSeek, true
553+ case "github-copilot":
554+ return c.GithubCopilot, true
555+ default:
556+ return auth.ProviderCreds{}, false
557+ }
558+}
559+
560+func resolveStoredAPIKey(ctx context.Context, provider string, creds auth.ProviderCreds, mode apiKeyCommandMode) (key string, found bool, err error) {
561+ if creds.APIKey != "" {
562+ return creds.APIKey, true, nil
563+ }
564+ if creds.APIKeyCommand == nil {
565+ return "", false, nil
566+ }
567+ switch mode {
568+ case apiKeyCommandAvailable:
569+ return "<api-key-command>", true, nil
570+ case apiKeyCommandSkip:
571+ return "", false, nil
572+ default:
573+ key, err := auth.ResolveAPIKeyCommand(ctx, *creds.APIKeyCommand)
574+ if err != nil {
575+ return "", true, fmt.Errorf("resolve api key for %s: %w", provider, err)
576+ }
577+ return key, true, nil
578+ }
579+}
580+
511 func kimiCLIFallbackDisabled() bool {581 func kimiCLIFallbackDisabled() bool {
512 _, err := os.Stat(KimiCLIFallbackDisabledPath())582 _, err := os.Stat(KimiCLIFallbackDisabledPath())
513 return err == nil583 return err == nil
A packages/agent/config_command_test.go +75 -0
@@ -0,0 +1,75 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+
12+ "github.com/patriceckhart/zot/packages/provider/auth"
13+)
14+
15+func TestAgentAPIKeyCommandHelperProcess(t *testing.T) {
16+ if os.Getenv("ZOT_AGENT_API_KEY_COMMAND_HELPER") != "1" {
17+ return
18+ }
19+ args := os.Args
20+ for len(args) > 0 && args[0] != "--" {
21+ args = args[1:]
22+ }
23+ if len(args) != 2 {
24+ os.Exit(2)
25+ }
26+ if err := os.WriteFile(args[1], []byte("ran\n"), 0o600); err != nil {
27+ os.Exit(3)
28+ }
29+ fmt.Print("resolved-secret\n")
30+ os.Exit(0)
31+}
32+
33+func TestCommandCredentialIsLazy(t *testing.T) {
34+ home := t.TempDir()
35+ t.Setenv("ZOT_HOME", home)
36+ t.Setenv("LAZY_PROVIDER_API_KEY", "")
37+ t.Setenv("ZOT_AGENT_API_KEY_COMMAND_HELPER", "1")
38+ marker := filepath.Join(home, "command-ran")
39+ credentials := auth.Credentials{AdditionalAPIKeyCreds: map[string]auth.ProviderCreds{
40+ "lazy-provider": {
41+ APIKeyCommand: &auth.APIKeyCommand{
42+ Program: os.Args[0],
43+ Args: []string{"-test.run=^TestAgentAPIKeyCommandHelperProcess$", "--", marker},
44+ },
45+ },
46+ }}
47+ encoded, err := json.Marshal(credentials)
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil {
52+ t.Fatal(err)
53+ }
54+
55+ if !CredentialAvailable("lazy-provider") {
56+ t.Fatal("command-backed credential was not reported as available")
57+ }
58+ if _, _, err := resolveCredentialForBackground(context.Background(), "lazy-provider"); err == nil {
59+ t.Fatal("background resolution unexpectedly returned the command-backed credential")
60+ }
61+ if _, err := os.Stat(marker); !os.IsNotExist(err) {
62+ t.Fatalf("credential command ran during availability checks: %v", err)
63+ }
64+
65+ cred, method, _, err := ResolveCredentialFull("lazy-provider", "")
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ if cred != "resolved-secret" || method != "apikey" {
70+ t.Fatalf("resolved (%q, %q), want command API key", cred, method)
71+ }
72+ if contents, err := os.ReadFile(marker); err != nil || !strings.Contains(string(contents), "ran") {
73+ t.Fatalf("credential command marker = %q, %v", contents, err)
74+ }
75+}
@@ -0,0 +1,75 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+
12+ "github.com/patriceckhart/zot/packages/provider/auth"
13+)
14+
15+func TestAgentAPIKeyCommandHelperProcess(t *testing.T) {
16+ if os.Getenv("ZOT_AGENT_API_KEY_COMMAND_HELPER") != "1" {
17+ return
18+ }
19+ args := os.Args
20+ for len(args) > 0 && args[0] != "--" {
21+ args = args[1:]
22+ }
23+ if len(args) != 2 {
24+ os.Exit(2)
25+ }
26+ if err := os.WriteFile(args[1], []byte("ran\n"), 0o600); err != nil {
27+ os.Exit(3)
28+ }
29+ fmt.Print("resolved-secret\n")
30+ os.Exit(0)
31+}
32+
33+func TestCommandCredentialIsLazy(t *testing.T) {
34+ home := t.TempDir()
35+ t.Setenv("ZOT_HOME", home)
36+ t.Setenv("LAZY_PROVIDER_API_KEY", "")
37+ t.Setenv("ZOT_AGENT_API_KEY_COMMAND_HELPER", "1")
38+ marker := filepath.Join(home, "command-ran")
39+ credentials := auth.Credentials{AdditionalAPIKeyCreds: map[string]auth.ProviderCreds{
40+ "lazy-provider": {
41+ APIKeyCommand: &auth.APIKeyCommand{
42+ Program: os.Args[0],
43+ Args: []string{"-test.run=^TestAgentAPIKeyCommandHelperProcess$", "--", marker},
44+ },
45+ },
46+ }}
47+ encoded, err := json.Marshal(credentials)
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil {
52+ t.Fatal(err)
53+ }
54+
55+ if !CredentialAvailable("lazy-provider") {
56+ t.Fatal("command-backed credential was not reported as available")
57+ }
58+ if _, _, err := resolveCredentialForBackground(context.Background(), "lazy-provider"); err == nil {
59+ t.Fatal("background resolution unexpectedly returned the command-backed credential")
60+ }
61+ if _, err := os.Stat(marker); !os.IsNotExist(err) {
62+ t.Fatalf("credential command ran during availability checks: %v", err)
63+ }
64+
65+ cred, method, _, err := ResolveCredentialFull("lazy-provider", "")
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ if cred != "resolved-secret" || method != "apikey" {
70+ t.Fatalf("resolved (%q, %q), want command API key", cred, method)
71+ }
72+ if contents, err := os.ReadFile(marker); err != nil || !strings.Contains(string(contents), "ran") {
73+ t.Fatalf("credential command marker = %q, %v", contents, err)
74+ }
75+}
M packages/agent/modelsync.go +11 -7
@@ -144,7 +144,7 @@ func RefreshModelsAsync() {
144144 go func() {
145145 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
146146 defer cancel()
147- _ = RefreshLlamaCPPModels(ctx)
147+ _ = refreshLlamaCPPModels(ctx, apiKeyCommandSkip)
148148 }()
149149 }
150150
@@ -152,7 +152,11 @@ func RefreshModelsAsync() {
152152 // active catalog. Unloaded models remain in the management UI and cannot be
153153 // selected for inference until they are loaded.
154154 func RefreshLlamaCPPModels(ctx context.Context) error {
155- baseURL, apiKey, err := ResolveLlamaCPPConfig()
155+ return refreshLlamaCPPModels(ctx, apiKeyCommandExecute)
156+}
157+
158+func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) error {
159+ baseURL, apiKey, err := resolveLlamaCPPConfig(ctx, commandMode)
156160 if err != nil || baseURL == "" {
157161 return err
158162 }
@@ -179,7 +183,7 @@ func refreshModels() {
179183
180184 var all []provider.Model
181185
182- if cred, method, err := ResolveCredential("anthropic", ""); err == nil && method == "apikey" {
186+ if cred, method, err := resolveCredentialForBackground(ctx, "anthropic"); err == nil && method == "apikey" {
183187 // /v1/models on Anthropic is API-key only; OAuth tokens can
184188 // also list models via the bearer header, but we skip OAuth
185189 // here to avoid surprise rate-limit hits on subscription keys.
@@ -187,12 +191,12 @@ func refreshModels() {
187191 all = append(all, live...)
188192 }
189193 }
190- if cred, method, err := ResolveCredential("openai", ""); err == nil && method == "apikey" {
194+ if cred, method, err := resolveCredentialForBackground(ctx, "openai"); err == nil && method == "apikey" {
191195 if live, err := provider.DiscoverOpenAI(ctx, cred, ""); err == nil {
192196 all = append(all, live...)
193197 }
194198 }
195- if cred, method, err := ResolveCredential("kimi", ""); err == nil && method == "apikey" {
199+ if cred, method, err := resolveCredentialForBackground(ctx, "kimi"); err == nil && method == "apikey" {
196200 if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {
197201 for i := range live {
198202 live[i].Provider = "kimi"
@@ -201,12 +205,12 @@ func refreshModels() {
201205 all = append(all, live...)
202206 }
203207 }
204- if cred, method, err := ResolveCredential("google", ""); err == nil && method == "apikey" {
208+ if cred, method, err := resolveCredentialForBackground(ctx, "google"); err == nil && method == "apikey" {
205209 if live, err := provider.DiscoverGoogle(ctx, cred, ""); err == nil {
206210 all = append(all, live...)
207211 }
208212 }
209- if _, _, err := ResolveCredential("openrouter", ""); err == nil {
213+ if _, _, err := resolveCredentialForBackground(ctx, "openrouter"); err == nil {
210214 // /models is public; gate on a credential so the picker only
211215 // fills with OpenRouter's hundreds of routes for users who use it.
212216 if live, err := provider.DiscoverOpenRouter(ctx, ""); err == nil {
@@ -144,7 +144,7 @@ func RefreshModelsAsync() {
144 go func() {144 go func() {
145 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)145 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
146 defer cancel()146 defer cancel()
147- _ = RefreshLlamaCPPModels(ctx)147+ _ = refreshLlamaCPPModels(ctx, apiKeyCommandSkip)
148 }()148 }()
149 }149 }
150 150
@@ -152,7 +152,11 @@ func RefreshModelsAsync() {
152 // active catalog. Unloaded models remain in the management UI and cannot be152 // active catalog. Unloaded models remain in the management UI and cannot be
153 // selected for inference until they are loaded.153 // selected for inference until they are loaded.
154 func RefreshLlamaCPPModels(ctx context.Context) error {154 func RefreshLlamaCPPModels(ctx context.Context) error {
155- baseURL, apiKey, err := ResolveLlamaCPPConfig()155+ return refreshLlamaCPPModels(ctx, apiKeyCommandExecute)
156+}
157+
158+func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) error {
159+ baseURL, apiKey, err := resolveLlamaCPPConfig(ctx, commandMode)
156 if err != nil || baseURL == "" {160 if err != nil || baseURL == "" {
157 return err161 return err
158 }162 }
@@ -179,7 +183,7 @@ func refreshModels() {
179 183
180 var all []provider.Model184 var all []provider.Model
181 185
182- if cred, method, err := ResolveCredential("anthropic", ""); err == nil && method == "apikey" {186+ if cred, method, err := resolveCredentialForBackground(ctx, "anthropic"); err == nil && method == "apikey" {
183 // /v1/models on Anthropic is API-key only; OAuth tokens can187 // /v1/models on Anthropic is API-key only; OAuth tokens can
184 // also list models via the bearer header, but we skip OAuth188 // also list models via the bearer header, but we skip OAuth
185 // here to avoid surprise rate-limit hits on subscription keys.189 // here to avoid surprise rate-limit hits on subscription keys.
@@ -187,12 +191,12 @@ func refreshModels() {
187 all = append(all, live...)191 all = append(all, live...)
188 }192 }
189 }193 }
190- if cred, method, err := ResolveCredential("openai", ""); err == nil && method == "apikey" {194+ if cred, method, err := resolveCredentialForBackground(ctx, "openai"); err == nil && method == "apikey" {
191 if live, err := provider.DiscoverOpenAI(ctx, cred, ""); err == nil {195 if live, err := provider.DiscoverOpenAI(ctx, cred, ""); err == nil {
192 all = append(all, live...)196 all = append(all, live...)
193 }197 }
194 }198 }
195- if cred, method, err := ResolveCredential("kimi", ""); err == nil && method == "apikey" {199+ if cred, method, err := resolveCredentialForBackground(ctx, "kimi"); err == nil && method == "apikey" {
196 if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {200 if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {
197 for i := range live {201 for i := range live {
198 live[i].Provider = "kimi"202 live[i].Provider = "kimi"
@@ -201,12 +205,12 @@ func refreshModels() {
201 all = append(all, live...)205 all = append(all, live...)
202 }206 }
203 }207 }
204- if cred, method, err := ResolveCredential("google", ""); err == nil && method == "apikey" {208+ if cred, method, err := resolveCredentialForBackground(ctx, "google"); err == nil && method == "apikey" {
205 if live, err := provider.DiscoverGoogle(ctx, cred, ""); err == nil {209 if live, err := provider.DiscoverGoogle(ctx, cred, ""); err == nil {
206 all = append(all, live...)210 all = append(all, live...)
207 }211 }
208 }212 }
209- if _, _, err := ResolveCredential("openrouter", ""); err == nil {213+ if _, _, err := resolveCredentialForBackground(ctx, "openrouter"); err == nil {
210 // /models is public; gate on a credential so the picker only214 // /models is public; gate on a credential so the picker only
211 // fills with OpenRouter's hundreds of routes for users who use it.215 // fills with OpenRouter's hundreds of routes for users who use it.
212 if live, err := provider.DiscoverOpenRouter(ctx, ""); err == nil {216 if live, err := provider.DiscoverOpenRouter(ctx, ""); err == nil {
M packages/agent/modes/interactive.go +2 -2
@@ -4315,14 +4315,14 @@ func (i *Interactive) openLogoutDialog() {
43154315 })
43164316 }
43174317 }
4318- if creds.OpenAI.APIKey != "" {
4318+ if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
43194319 items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
43204320 }
43214321 if creds.OpenAI.OAuth != nil {
43224322 items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
43234323 }
43244324 for p, c := range creds.AdditionalAPIKeyCreds {
4325- if c.APIKey != "" || c.BaseURL != "" {
4325+ if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
43264326 items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
43274327 }
43284328 }
@@ -4315,14 +4315,14 @@ func (i *Interactive) openLogoutDialog() {
4315 })4315 })
4316 }4316 }
4317 }4317 }
4318- if creds.OpenAI.APIKey != "" {4318+ if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
4319 items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})4319 items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
4320 }4320 }
4321 if creds.OpenAI.OAuth != nil {4321 if creds.OpenAI.OAuth != nil {
4322 items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})4322 items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
4323 }4323 }
4324 for p, c := range creds.AdditionalAPIKeyCreds {4324 for p, c := range creds.AdditionalAPIKeyCreds {
4325- if c.APIKey != "" || c.BaseURL != "" {4325+ if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
4326 items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})4326 items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
4327 }4327 }
4328 }4328 }
M packages/agent/modes/login_dialog.go +1 -1
@@ -98,7 +98,7 @@ func (d *loginDialog) Open(zotHome string) {
9898 if creds, err := auth.NewStore(path).Load(); err == nil {
9999 d.status["anthropic"] = creds.Method("anthropic")
100100 d.status["openai"] = ""
101- if creds.OpenAI.APIKey != "" {
101+ if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
102102 d.status["openai"] = "apikey"
103103 }
104104 d.status["openai-codex"] = ""
@@ -98,7 +98,7 @@ func (d *loginDialog) Open(zotHome string) {
98 if creds, err := auth.NewStore(path).Load(); err == nil {98 if creds, err := auth.NewStore(path).Load(); err == nil {
99 d.status["anthropic"] = creds.Method("anthropic")99 d.status["anthropic"] = creds.Method("anthropic")
100 d.status["openai"] = ""100 d.status["openai"] = ""
101- if creds.OpenAI.APIKey != "" {101+ if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
102 d.status["openai"] = "apikey"102 d.status["openai"] = "apikey"
103 }103 }
104 d.status["openai-codex"] = ""104 d.status["openai-codex"] = ""
A packages/provider/auth/api_key_command.go +135 -0
@@ -0,0 +1,135 @@
1+package auth
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os/exec"
11+ "strings"
12+ "sync"
13+ "time"
14+)
15+
16+const (
17+ defaultAPIKeyCommandTimeout = 120 * time.Second
18+ maxAPIKeyCommandOutput = 64 << 10
19+)
20+
21+// APIKeyCommand describes a program that prints an API key to stdout.
22+// Program is executed directly, without a shell.
23+type APIKeyCommand struct {
24+ Program string `json:"program"`
25+ Args []string `json:"args,omitempty"`
26+ TimeoutMS int `json:"timeout_ms,omitempty"`
27+}
28+
29+type apiKeyCommandCall struct {
30+ done chan struct{}
31+ value string
32+ err error
33+}
34+
35+var apiKeyCommandCache = struct {
36+ sync.Mutex
37+ values map[[sha256.Size]byte]string
38+ inflight map[[sha256.Size]byte]*apiKeyCommandCall
39+}{
40+ values: make(map[[sha256.Size]byte]string),
41+ inflight: make(map[[sha256.Size]byte]*apiKeyCommandCall),
42+}
43+
44+// ResolveAPIKeyCommand executes command and returns its API key. Successful
45+// results are cached in memory for the lifetime of the process. Concurrent
46+// requests for the same command share one execution.
47+func ResolveAPIKeyCommand(ctx context.Context, command APIKeyCommand) (string, error) {
48+ if strings.TrimSpace(command.Program) == "" {
49+ return "", errors.New("api key command has an empty program")
50+ }
51+ if command.TimeoutMS < 0 {
52+ return "", errors.New("api key command timeout_ms must not be negative")
53+ }
54+
55+ encoded, err := json.Marshal(command)
56+ if err != nil {
57+ return "", errors.New("encode api key command")
58+ }
59+ cacheKey := sha256.Sum256(encoded)
60+ apiKeyCommandCache.Lock()
61+ if value, ok := apiKeyCommandCache.values[cacheKey]; ok {
62+ apiKeyCommandCache.Unlock()
63+ return value, nil
64+ }
65+ if call, ok := apiKeyCommandCache.inflight[cacheKey]; ok {
66+ apiKeyCommandCache.Unlock()
67+ select {
68+ case <-call.done:
69+ return call.value, call.err
70+ case <-ctx.Done():
71+ return "", errors.New("api key command was canceled")
72+ }
73+ }
74+ call := &apiKeyCommandCall{done: make(chan struct{})}
75+ apiKeyCommandCache.inflight[cacheKey] = call
76+ apiKeyCommandCache.Unlock()
77+
78+ value, resolveErr := runAPIKeyCommand(ctx, command)
79+ apiKeyCommandCache.Lock()
80+ delete(apiKeyCommandCache.inflight, cacheKey)
81+ if resolveErr == nil {
82+ apiKeyCommandCache.values[cacheKey] = value
83+ }
84+ call.value = value
85+ call.err = resolveErr
86+ close(call.done)
87+ apiKeyCommandCache.Unlock()
88+ return value, resolveErr
89+}
90+
91+func runAPIKeyCommand(ctx context.Context, command APIKeyCommand) (string, error) {
92+ timeout := defaultAPIKeyCommandTimeout
93+ if command.TimeoutMS > 0 {
94+ timeout = time.Duration(command.TimeoutMS) * time.Millisecond
95+ }
96+ commandCtx, cancel := context.WithTimeout(ctx, timeout)
97+ defer cancel()
98+
99+ cmd := exec.CommandContext(commandCtx, command.Program, command.Args...)
100+ configureAPIKeyCommandProcess(cmd)
101+ stdout, err := cmd.StdoutPipe()
102+ if err != nil {
103+ return "", errors.New("prepare api key command output")
104+ }
105+ if err := cmd.Start(); err != nil {
106+ return "", errors.New("start api key command")
107+ }
108+
109+ output, readErr := io.ReadAll(io.LimitReader(stdout, maxAPIKeyCommandOutput+1))
110+ if len(output) > maxAPIKeyCommandOutput {
111+ cancelAPIKeyCommandProcess(cmd)
112+ _ = cmd.Wait()
113+ return "", fmt.Errorf("api key command output exceeds %d bytes", maxAPIKeyCommandOutput)
114+ }
115+ waitErr := cmd.Wait()
116+ if commandCtx.Err() != nil {
117+ return "", errors.New("api key command timed out or was canceled")
118+ }
119+ if readErr != nil {
120+ return "", errors.New("read api key command output")
121+ }
122+ if waitErr != nil {
123+ return "", errors.New("api key command exited unsuccessfully")
124+ }
125+
126+ value := strings.TrimRight(string(output), "\r\n")
127+ if value == "" {
128+ return "", errors.New("api key command returned empty output")
129+ }
130+ if strings.ContainsAny(value, "\r\n") {
131+ return "", errors.New("api key command returned multiple lines")
132+ }
133+
134+ return value, nil
135+}
@@ -0,0 +1,135 @@
1+package auth
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os/exec"
11+ "strings"
12+ "sync"
13+ "time"
14+)
15+
16+const (
17+ defaultAPIKeyCommandTimeout = 120 * time.Second
18+ maxAPIKeyCommandOutput = 64 << 10
19+)
20+
21+// APIKeyCommand describes a program that prints an API key to stdout.
22+// Program is executed directly, without a shell.
23+type APIKeyCommand struct {
24+ Program string `json:"program"`
25+ Args []string `json:"args,omitempty"`
26+ TimeoutMS int `json:"timeout_ms,omitempty"`
27+}
28+
29+type apiKeyCommandCall struct {
30+ done chan struct{}
31+ value string
32+ err error
33+}
34+
35+var apiKeyCommandCache = struct {
36+ sync.Mutex
37+ values map[[sha256.Size]byte]string
38+ inflight map[[sha256.Size]byte]*apiKeyCommandCall
39+}{
40+ values: make(map[[sha256.Size]byte]string),
41+ inflight: make(map[[sha256.Size]byte]*apiKeyCommandCall),
42+}
43+
44+// ResolveAPIKeyCommand executes command and returns its API key. Successful
45+// results are cached in memory for the lifetime of the process. Concurrent
46+// requests for the same command share one execution.
47+func ResolveAPIKeyCommand(ctx context.Context, command APIKeyCommand) (string, error) {
48+ if strings.TrimSpace(command.Program) == "" {
49+ return "", errors.New("api key command has an empty program")
50+ }
51+ if command.TimeoutMS < 0 {
52+ return "", errors.New("api key command timeout_ms must not be negative")
53+ }
54+
55+ encoded, err := json.Marshal(command)
56+ if err != nil {
57+ return "", errors.New("encode api key command")
58+ }
59+ cacheKey := sha256.Sum256(encoded)
60+ apiKeyCommandCache.Lock()
61+ if value, ok := apiKeyCommandCache.values[cacheKey]; ok {
62+ apiKeyCommandCache.Unlock()
63+ return value, nil
64+ }
65+ if call, ok := apiKeyCommandCache.inflight[cacheKey]; ok {
66+ apiKeyCommandCache.Unlock()
67+ select {
68+ case <-call.done:
69+ return call.value, call.err
70+ case <-ctx.Done():
71+ return "", errors.New("api key command was canceled")
72+ }
73+ }
74+ call := &apiKeyCommandCall{done: make(chan struct{})}
75+ apiKeyCommandCache.inflight[cacheKey] = call
76+ apiKeyCommandCache.Unlock()
77+
78+ value, resolveErr := runAPIKeyCommand(ctx, command)
79+ apiKeyCommandCache.Lock()
80+ delete(apiKeyCommandCache.inflight, cacheKey)
81+ if resolveErr == nil {
82+ apiKeyCommandCache.values[cacheKey] = value
83+ }
84+ call.value = value
85+ call.err = resolveErr
86+ close(call.done)
87+ apiKeyCommandCache.Unlock()
88+ return value, resolveErr
89+}
90+
91+func runAPIKeyCommand(ctx context.Context, command APIKeyCommand) (string, error) {
92+ timeout := defaultAPIKeyCommandTimeout
93+ if command.TimeoutMS > 0 {
94+ timeout = time.Duration(command.TimeoutMS) * time.Millisecond
95+ }
96+ commandCtx, cancel := context.WithTimeout(ctx, timeout)
97+ defer cancel()
98+
99+ cmd := exec.CommandContext(commandCtx, command.Program, command.Args...)
100+ configureAPIKeyCommandProcess(cmd)
101+ stdout, err := cmd.StdoutPipe()
102+ if err != nil {
103+ return "", errors.New("prepare api key command output")
104+ }
105+ if err := cmd.Start(); err != nil {
106+ return "", errors.New("start api key command")
107+ }
108+
109+ output, readErr := io.ReadAll(io.LimitReader(stdout, maxAPIKeyCommandOutput+1))
110+ if len(output) > maxAPIKeyCommandOutput {
111+ cancelAPIKeyCommandProcess(cmd)
112+ _ = cmd.Wait()
113+ return "", fmt.Errorf("api key command output exceeds %d bytes", maxAPIKeyCommandOutput)
114+ }
115+ waitErr := cmd.Wait()
116+ if commandCtx.Err() != nil {
117+ return "", errors.New("api key command timed out or was canceled")
118+ }
119+ if readErr != nil {
120+ return "", errors.New("read api key command output")
121+ }
122+ if waitErr != nil {
123+ return "", errors.New("api key command exited unsuccessfully")
124+ }
125+
126+ value := strings.TrimRight(string(output), "\r\n")
127+ if value == "" {
128+ return "", errors.New("api key command returned empty output")
129+ }
130+ if strings.ContainsAny(value, "\r\n") {
131+ return "", errors.New("api key command returned multiple lines")
132+ }
133+
134+ return value, nil
135+}
A packages/provider/auth/api_key_command_test.go +140 -0
@@ -0,0 +1,140 @@
1+package auth
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "strings"
8+ "testing"
9+ "time"
10+)
11+
12+func TestAPIKeyCommandHelperProcess(t *testing.T) {
13+ if os.Getenv("ZOT_API_KEY_COMMAND_HELPER") != "1" {
14+ return
15+ }
16+ args := os.Args
17+ for len(args) > 0 && args[0] != "--" {
18+ args = args[1:]
19+ }
20+ if len(args) < 2 {
21+ os.Exit(2)
22+ }
23+ switch args[1] {
24+ case "print":
25+ fmt.Print(os.Getenv("ZOT_API_KEY_COMMAND_VALUE"))
26+ case "fail":
27+ fmt.Fprint(os.Stderr, os.Getenv("ZOT_API_KEY_COMMAND_VALUE"))
28+ os.Exit(1)
29+ case "sleep":
30+ time.Sleep(5 * time.Second)
31+ case "large":
32+ fmt.Print(strings.Repeat("x", maxAPIKeyCommandOutput+1))
33+ case "count":
34+ if len(args) < 3 {
35+ os.Exit(2)
36+ }
37+ f, err := os.OpenFile(args[2], os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
38+ if err != nil {
39+ os.Exit(3)
40+ }
41+ _, _ = f.WriteString("run\n")
42+ _ = f.Close()
43+ fmt.Print("cached-key\n")
44+ default:
45+ os.Exit(2)
46+ }
47+ os.Exit(0)
48+}
49+
50+func apiKeyHelperCommand(mode string, extra ...string) APIKeyCommand {
51+ args := []string{"-test.run=^TestAPIKeyCommandHelperProcess$", "--", mode}
52+ args = append(args, extra...)
53+ return APIKeyCommand{Program: os.Args[0], Args: args}
54+}
55+
56+func resetAPIKeyCommandCache(t *testing.T) {
57+ t.Helper()
58+ apiKeyCommandCache.Lock()
59+ oldValues := apiKeyCommandCache.values
60+ oldInflight := apiKeyCommandCache.inflight
61+ apiKeyCommandCache.values = make(map[[32]byte]string)
62+ apiKeyCommandCache.inflight = make(map[[32]byte]*apiKeyCommandCall)
63+ apiKeyCommandCache.Unlock()
64+ t.Cleanup(func() {
65+ apiKeyCommandCache.Lock()
66+ apiKeyCommandCache.values = oldValues
67+ apiKeyCommandCache.inflight = oldInflight
68+ apiKeyCommandCache.Unlock()
69+ })
70+}
71+
72+func TestResolveAPIKeyCommand(t *testing.T) {
73+ resetAPIKeyCommandCache(t)
74+ t.Setenv("ZOT_API_KEY_COMMAND_HELPER", "1")
75+ t.Setenv("ZOT_API_KEY_COMMAND_VALUE", "secret-value\r\n")
76+
77+ got, err := ResolveAPIKeyCommand(context.Background(), apiKeyHelperCommand("print"))
78+ if err != nil {
79+ t.Fatal(err)
80+ }
81+ if got != "secret-value" {
82+ t.Fatalf("ResolveAPIKeyCommand() = %q, want %q", got, "secret-value")
83+ }
84+}
85+
86+func TestResolveAPIKeyCommandCachesSuccess(t *testing.T) {
87+ resetAPIKeyCommandCache(t)
88+ t.Setenv("ZOT_API_KEY_COMMAND_HELPER", "1")
89+ countPath := t.TempDir() + string(os.PathSeparator) + "count"
90+ command := apiKeyHelperCommand("count", countPath)
91+
92+ for range 2 {
93+ got, err := ResolveAPIKeyCommand(context.Background(), command)
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ if got != "cached-key" {
98+ t.Fatalf("got %q", got)
99+ }
100+ }
101+ contents, err := os.ReadFile(countPath)
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ if got := strings.Count(string(contents), "run\n"); got != 1 {
106+ t.Fatalf("command ran %d times, want 1", got)
107+ }
108+}
109+
110+func TestResolveAPIKeyCommandFailureDoesNotExposeOutput(t *testing.T) {
111+ resetAPIKeyCommandCache(t)
112+ t.Setenv("ZOT_API_KEY_COMMAND_HELPER", "1")
113+ t.Setenv("ZOT_API_KEY_COMMAND_VALUE", "must-not-appear")
114+
115+ _, err := ResolveAPIKeyCommand(context.Background(), apiKeyHelperCommand("fail"))
116+ if err == nil {
117+ t.Fatal("expected an error")
118+ }
119+ if strings.Contains(err.Error(), "must-not-appear") {
120+ t.Fatalf("error exposed command output: %v", err)
121+ }
122+}
123+
124+func TestResolveAPIKeyCommandTimeoutAndOutputLimit(t *testing.T) {
125+ resetAPIKeyCommandCache(t)
126+ t.Setenv("ZOT_API_KEY_COMMAND_HELPER", "1")
127+
128+ t.Run("timeout", func(t *testing.T) {
129+ command := apiKeyHelperCommand("sleep")
130+ command.TimeoutMS = 20
131+ if _, err := ResolveAPIKeyCommand(context.Background(), command); err == nil || !strings.Contains(err.Error(), "timed out") {
132+ t.Fatalf("error = %v, want timeout", err)
133+ }
134+ })
135+ t.Run("output limit", func(t *testing.T) {
136+ if _, err := ResolveAPIKeyCommand(context.Background(), apiKeyHelperCommand("large")); err == nil || !strings.Contains(err.Error(), "exceeds") {
137+ t.Fatalf("error = %v, want output limit", err)
138+ }
139+ })
140+}
@@ -0,0 +1,140 @@
1+package auth
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "strings"
8+ "testing"
9+ "time"
10+)
11+
12+func TestAPIKeyCommandHelperProcess(t *testing.T) {
13+ if os.Getenv("ZOT_API_KEY_COMMAND_HELPER") != "1" {
14+ return
15+ }
16+ args := os.Args
17+ for len(args) > 0 && args[0] != "--" {
18+ args = args[1:]
19+ }
20+ if len(args) < 2 {
21+ os.Exit(2)
22+ }
23+ switch args[1] {
24+ case "print":
25+ fmt.Print(os.Getenv("ZOT_API_KEY_COMMAND_VALUE"))
26+ case "fail":
27+ fmt.Fprint(os.Stderr, os.Getenv("ZOT_API_KEY_COMMAND_VALUE"))
28+ os.Exit(1)
29+ case "sleep":
30+ time.Sleep(5 * time.Second)
31+ case "large":
32+ fmt.Print(strings.Repeat("x", maxAPIKeyCommandOutput+1))
33+ case "count":
34+ if len(args) < 3 {
35+ os.Exit(2)
36+ }
37+ f, err := os.OpenFile(args[2], os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
38+ if err != nil {
39+ os.Exit(3)
40+ }
41+ _, _ = f.WriteString("run\n")
42+ _ = f.Close()
43+ fmt.Print("cached-key\n")
44+ default:
45+ os.Exit(2)
46+ }
47+ os.Exit(0)
48+}
49+
50+func apiKeyHelperCommand(mode string, extra ...string) APIKeyCommand {
51+ args := []string{"-test.run=^TestAPIKeyCommandHelperProcess$", "--", mode}
52+ args = append(args, extra...)
53+ return APIKeyCommand{Program: os.Args[0], Args: args}
54+}
55+
56+func resetAPIKeyCommandCache(t *testing.T) {
57+ t.Helper()
58+ apiKeyCommandCache.Lock()
59+ oldValues := apiKeyCommandCache.values
60+ oldInflight := apiKeyCommandCache.inflight
61+ apiKeyCommandCache.values = make(map[[32]byte]string)
62+ apiKeyCommandCache.inflight = make(map[[32]byte]*apiKeyCommandCall)
63+ apiKeyCommandCache.Unlock()
64+ t.Cleanup(func() {
65+ apiKeyCommandCache.Lock()
66+ apiKeyCommandCache.values = oldValues
67+ apiKeyCommandCache.inflight = oldInflight
68+ apiKeyCommandCache.Unlock()
69+ })
70+}
71+
72+func TestResolveAPIKeyCommand(t *testing.T) {
73+ resetAPIKeyCommandCache(t)
74+ t.Setenv("ZOT_API_KEY_COMMAND_HELPER", "1")
75+ t.Setenv("ZOT_API_KEY_COMMAND_VALUE", "secret-value\r\n")
76+
77+ got, err := ResolveAPIKeyCommand(context.Background(), apiKeyHelperCommand("print"))
78+ if err != nil {
79+ t.Fatal(err)
80+ }
81+ if got != "secret-value" {
82+ t.Fatalf("ResolveAPIKeyCommand() = %q, want %q", got, "secret-value")
83+ }
84+}
85+
86+func TestResolveAPIKeyCommandCachesSuccess(t *testing.T) {
87+ resetAPIKeyCommandCache(t)
88+ t.Setenv("ZOT_API_KEY_COMMAND_HELPER", "1")
89+ countPath := t.TempDir() + string(os.PathSeparator) + "count"
90+ command := apiKeyHelperCommand("count", countPath)
91+
92+ for range 2 {
93+ got, err := ResolveAPIKeyCommand(context.Background(), command)
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ if got != "cached-key" {
98+ t.Fatalf("got %q", got)
99+ }
100+ }
101+ contents, err := os.ReadFile(countPath)
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ if got := strings.Count(string(contents), "run\n"); got != 1 {
106+ t.Fatalf("command ran %d times, want 1", got)
107+ }
108+}
109+
110+func TestResolveAPIKeyCommandFailureDoesNotExposeOutput(t *testing.T) {
111+ resetAPIKeyCommandCache(t)
112+ t.Setenv("ZOT_API_KEY_COMMAND_HELPER", "1")
113+ t.Setenv("ZOT_API_KEY_COMMAND_VALUE", "must-not-appear")
114+
115+ _, err := ResolveAPIKeyCommand(context.Background(), apiKeyHelperCommand("fail"))
116+ if err == nil {
117+ t.Fatal("expected an error")
118+ }
119+ if strings.Contains(err.Error(), "must-not-appear") {
120+ t.Fatalf("error exposed command output: %v", err)
121+ }
122+}
123+
124+func TestResolveAPIKeyCommandTimeoutAndOutputLimit(t *testing.T) {
125+ resetAPIKeyCommandCache(t)
126+ t.Setenv("ZOT_API_KEY_COMMAND_HELPER", "1")
127+
128+ t.Run("timeout", func(t *testing.T) {
129+ command := apiKeyHelperCommand("sleep")
130+ command.TimeoutMS = 20
131+ if _, err := ResolveAPIKeyCommand(context.Background(), command); err == nil || !strings.Contains(err.Error(), "timed out") {
132+ t.Fatalf("error = %v, want timeout", err)
133+ }
134+ })
135+ t.Run("output limit", func(t *testing.T) {
136+ if _, err := ResolveAPIKeyCommand(context.Background(), apiKeyHelperCommand("large")); err == nil || !strings.Contains(err.Error(), "exceeds") {
137+ t.Fatalf("error = %v, want output limit", err)
138+ }
139+ })
140+}
A packages/provider/auth/api_key_command_unix.go +32 -0
@@ -0,0 +1,32 @@
1+//go:build !windows
2+
3+package auth
4+
5+import (
6+ "errors"
7+ "os"
8+ "os/exec"
9+ "syscall"
10+ "time"
11+)
12+
13+func configureAPIKeyCommandProcess(cmd *exec.Cmd) {
14+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
15+ cmd.Cancel = func() error {
16+ if cmd.Process == nil {
17+ return os.ErrProcessDone
18+ }
19+ err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
20+ if errors.Is(err, syscall.ESRCH) {
21+ return os.ErrProcessDone
22+ }
23+ return err
24+ }
25+ cmd.WaitDelay = 2 * time.Second
26+}
27+
28+func cancelAPIKeyCommandProcess(cmd *exec.Cmd) {
29+ if cmd.Cancel != nil {
30+ _ = cmd.Cancel()
31+ }
32+}
@@ -0,0 +1,32 @@
1+//go:build !windows
2+
3+package auth
4+
5+import (
6+ "errors"
7+ "os"
8+ "os/exec"
9+ "syscall"
10+ "time"
11+)
12+
13+func configureAPIKeyCommandProcess(cmd *exec.Cmd) {
14+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
15+ cmd.Cancel = func() error {
16+ if cmd.Process == nil {
17+ return os.ErrProcessDone
18+ }
19+ err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
20+ if errors.Is(err, syscall.ESRCH) {
21+ return os.ErrProcessDone
22+ }
23+ return err
24+ }
25+ cmd.WaitDelay = 2 * time.Second
26+}
27+
28+func cancelAPIKeyCommandProcess(cmd *exec.Cmd) {
29+ if cmd.Cancel != nil {
30+ _ = cmd.Cancel()
31+ }
32+}
A packages/provider/auth/api_key_command_windows.go +18 -0
@@ -0,0 +1,18 @@
1+//go:build windows
2+
3+package auth
4+
5+import (
6+ "os/exec"
7+ "time"
8+)
9+
10+func configureAPIKeyCommandProcess(cmd *exec.Cmd) {
11+ cmd.WaitDelay = 2 * time.Second
12+}
13+
14+func cancelAPIKeyCommandProcess(cmd *exec.Cmd) {
15+ if cmd.Process != nil {
16+ _ = cmd.Process.Kill()
17+ }
18+}
@@ -0,0 +1,18 @@
1+//go:build windows
2+
3+package auth
4+
5+import (
6+ "os/exec"
7+ "time"
8+)
9+
10+func configureAPIKeyCommandProcess(cmd *exec.Cmd) {
11+ cmd.WaitDelay = 2 * time.Second
12+}
13+
14+func cancelAPIKeyCommandProcess(cmd *exec.Cmd) {
15+ if cmd.Process != nil {
16+ _ = cmd.Process.Kill()
17+ }
18+}
M packages/provider/auth/manager.go +7 -0
@@ -534,6 +534,13 @@ func (m *Manager) RefreshIfNeeded(ctx context.Context, provider string) (string,
534534 if p.APIKey != "" {
535535 return p.APIKey, "apikey", nil
536536 }
537+ if p.APIKeyCommand != nil {
538+ key, err := ResolveAPIKeyCommand(ctx, *p.APIKeyCommand)
539+ if err != nil {
540+ return "", "", fmt.Errorf("resolve api key for %s: %w", provider, err)
541+ }
542+ return key, "apikey", nil
543+ }
537544 if p.OAuth == nil {
538545 return "", "", fmt.Errorf("no credentials for %s", provider)
539546 }
@@ -534,6 +534,13 @@ func (m *Manager) RefreshIfNeeded(ctx context.Context, provider string) (string,
534 if p.APIKey != "" {534 if p.APIKey != "" {
535 return p.APIKey, "apikey", nil535 return p.APIKey, "apikey", nil
536 }536 }
537+ if p.APIKeyCommand != nil {
538+ key, err := ResolveAPIKeyCommand(ctx, *p.APIKeyCommand)
539+ if err != nil {
540+ return "", "", fmt.Errorf("resolve api key for %s: %w", provider, err)
541+ }
542+ return key, "apikey", nil
543+ }
537 if p.OAuth == nil {544 if p.OAuth == nil {
538 return "", "", fmt.Errorf("no credentials for %s", provider)545 return "", "", fmt.Errorf("no credentials for %s", provider)
539 }546 }
M packages/provider/auth/store.go +13 -6
@@ -29,9 +29,10 @@ type Credentials struct {
2929 // use either APIKey or OAuth; OpenAI may store both so the public API
3030 // route and ChatGPT/Codex subscription route can coexist.
3131 type ProviderCreds struct {
32- APIKey string `json:"api_key,omitempty"`
33- BaseURL string `json:"base_url,omitempty"`
34- OAuth *OAuthToken `json:"oauth,omitempty"`
32+ APIKey string `json:"api_key,omitempty"`
33+ APIKeyCommand *APIKeyCommand `json:"api_key_command,omitempty"`
34+ BaseURL string `json:"base_url,omitempty"`
35+ OAuth *OAuthToken `json:"oauth,omitempty"`
3536 }
3637
3738 // OAuthToken is an OAuth 2 token set with refresh support.
@@ -63,7 +64,7 @@ func (t *OAuthToken) Expired() bool {
6364 // Has reports whether at least one credential is present for provider.
6465 func (c *Credentials) Has(provider string) bool {
6566 p := c.get(provider)
66- return p != nil && (p.APIKey != "" || p.BaseURL != "" || p.OAuth != nil)
67+ return p != nil && (p.APIKey != "" || p.APIKeyCommand != nil || p.BaseURL != "" || p.OAuth != nil)
6768 }
6869
6970 // Method returns "apikey", "oauth", or "" for the given provider.
@@ -72,7 +73,7 @@ func (c *Credentials) Method(provider string) string {
7273 if p == nil {
7374 return ""
7475 }
75- if p.APIKey != "" || p.BaseURL != "" {
76+ if p.APIKey != "" || p.APIKeyCommand != nil || p.BaseURL != "" {
7677 return "apikey"
7778 }
7879 if p.OAuth != nil {
@@ -108,7 +109,7 @@ func (c *Credentials) setAdditional(provider string, p ProviderCreds) {
108109 if c.AdditionalAPIKeyCreds == nil {
109110 c.AdditionalAPIKeyCreds = map[string]ProviderCreds{}
110111 }
111- if p.APIKey == "" && p.BaseURL == "" && p.OAuth == nil {
112+ if p.APIKey == "" && p.APIKeyCommand == nil && p.BaseURL == "" && p.OAuth == nil {
112113 delete(c.AdditionalAPIKeyCreds, provider)
113114 if len(c.AdditionalAPIKeyCreds) == 0 {
114115 c.AdditionalAPIKeyCreds = nil
@@ -160,6 +161,7 @@ func (s *Store) SetAPIKey(provider, key string) error {
160161 }
161162 if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
162163 cur.APIKey = key
164+ cur.APIKeyCommand = nil
163165 cur.OAuth = nil
164166 c.setAdditional(provider, cur)
165167 return s.saveLocked(c)
@@ -170,6 +172,7 @@ func (s *Store) SetAPIKey(provider, key string) error {
170172 return s.saveLocked(c)
171173 }
172174 p.APIKey = key
175+ p.APIKeyCommand = nil
173176 if provider != "openai" {
174177 p.OAuth = nil
175178 }
@@ -212,6 +215,7 @@ func (s *Store) SetOAuth(provider string, tok OAuthToken) error {
212215 }
213216 if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
214217 cur.APIKey = ""
218+ cur.APIKeyCommand = nil
215219 cur.OAuth = &tok
216220 c.setAdditional(provider, cur)
217221 return s.saveLocked(c)
@@ -223,6 +227,7 @@ func (s *Store) SetOAuth(provider string, tok OAuthToken) error {
223227 }
224228 if provider != "openai" {
225229 p.APIKey = ""
230+ p.APIKeyCommand = nil
226231 }
227232 p.OAuth = &tok
228233 return s.saveLocked(c)
@@ -259,6 +264,7 @@ func (s *Store) ClearAPIKey(provider string) error {
259264 }
260265 if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
261266 cur.APIKey = ""
267+ cur.APIKeyCommand = nil
262268 c.setAdditional(provider, cur)
263269 return s.saveLocked(c)
264270 }
@@ -267,6 +273,7 @@ func (s *Store) ClearAPIKey(provider string) error {
267273 return nil
268274 }
269275 p.APIKey = ""
276+ p.APIKeyCommand = nil
270277 return s.saveLocked(c)
271278 }
272279
@@ -29,9 +29,10 @@ type Credentials struct {
29 // use either APIKey or OAuth; OpenAI may store both so the public API29 // use either APIKey or OAuth; OpenAI may store both so the public API
30 // route and ChatGPT/Codex subscription route can coexist.30 // route and ChatGPT/Codex subscription route can coexist.
31 type ProviderCreds struct {31 type ProviderCreds struct {
32- APIKey string `json:"api_key,omitempty"`32+ APIKey string `json:"api_key,omitempty"`
33- BaseURL string `json:"base_url,omitempty"`33+ APIKeyCommand *APIKeyCommand `json:"api_key_command,omitempty"`
34- OAuth *OAuthToken `json:"oauth,omitempty"`34+ BaseURL string `json:"base_url,omitempty"`
35+ OAuth *OAuthToken `json:"oauth,omitempty"`
35 }36 }
36 37
37 // OAuthToken is an OAuth 2 token set with refresh support.38 // OAuthToken is an OAuth 2 token set with refresh support.
@@ -63,7 +64,7 @@ func (t *OAuthToken) Expired() bool {
63 // Has reports whether at least one credential is present for provider.64 // Has reports whether at least one credential is present for provider.
64 func (c *Credentials) Has(provider string) bool {65 func (c *Credentials) Has(provider string) bool {
65 p := c.get(provider)66 p := c.get(provider)
66- return p != nil && (p.APIKey != "" || p.BaseURL != "" || p.OAuth != nil)67+ return p != nil && (p.APIKey != "" || p.APIKeyCommand != nil || p.BaseURL != "" || p.OAuth != nil)
67 }68 }
68 69
69 // Method returns "apikey", "oauth", or "" for the given provider.70 // Method returns "apikey", "oauth", or "" for the given provider.
@@ -72,7 +73,7 @@ func (c *Credentials) Method(provider string) string {
72 if p == nil {73 if p == nil {
73 return ""74 return ""
74 }75 }
75- if p.APIKey != "" || p.BaseURL != "" {76+ if p.APIKey != "" || p.APIKeyCommand != nil || p.BaseURL != "" {
76 return "apikey"77 return "apikey"
77 }78 }
78 if p.OAuth != nil {79 if p.OAuth != nil {
@@ -108,7 +109,7 @@ func (c *Credentials) setAdditional(provider string, p ProviderCreds) {
108 if c.AdditionalAPIKeyCreds == nil {109 if c.AdditionalAPIKeyCreds == nil {
109 c.AdditionalAPIKeyCreds = map[string]ProviderCreds{}110 c.AdditionalAPIKeyCreds = map[string]ProviderCreds{}
110 }111 }
111- if p.APIKey == "" && p.BaseURL == "" && p.OAuth == nil {112+ if p.APIKey == "" && p.APIKeyCommand == nil && p.BaseURL == "" && p.OAuth == nil {
112 delete(c.AdditionalAPIKeyCreds, provider)113 delete(c.AdditionalAPIKeyCreds, provider)
113 if len(c.AdditionalAPIKeyCreds) == 0 {114 if len(c.AdditionalAPIKeyCreds) == 0 {
114 c.AdditionalAPIKeyCreds = nil115 c.AdditionalAPIKeyCreds = nil
@@ -160,6 +161,7 @@ func (s *Store) SetAPIKey(provider, key string) error {
160 }161 }
161 if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {162 if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
162 cur.APIKey = key163 cur.APIKey = key
164+ cur.APIKeyCommand = nil
163 cur.OAuth = nil165 cur.OAuth = nil
164 c.setAdditional(provider, cur)166 c.setAdditional(provider, cur)
165 return s.saveLocked(c)167 return s.saveLocked(c)
@@ -170,6 +172,7 @@ func (s *Store) SetAPIKey(provider, key string) error {
170 return s.saveLocked(c)172 return s.saveLocked(c)
171 }173 }
172 p.APIKey = key174 p.APIKey = key
175+ p.APIKeyCommand = nil
173 if provider != "openai" {176 if provider != "openai" {
174 p.OAuth = nil177 p.OAuth = nil
175 }178 }
@@ -212,6 +215,7 @@ func (s *Store) SetOAuth(provider string, tok OAuthToken) error {
212 }215 }
213 if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {216 if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
214 cur.APIKey = ""217 cur.APIKey = ""
218+ cur.APIKeyCommand = nil
215 cur.OAuth = &tok219 cur.OAuth = &tok
216 c.setAdditional(provider, cur)220 c.setAdditional(provider, cur)
217 return s.saveLocked(c)221 return s.saveLocked(c)
@@ -223,6 +227,7 @@ func (s *Store) SetOAuth(provider string, tok OAuthToken) error {
223 }227 }
224 if provider != "openai" {228 if provider != "openai" {
225 p.APIKey = ""229 p.APIKey = ""
230+ p.APIKeyCommand = nil
226 }231 }
227 p.OAuth = &tok232 p.OAuth = &tok
228 return s.saveLocked(c)233 return s.saveLocked(c)
@@ -259,6 +264,7 @@ func (s *Store) ClearAPIKey(provider string) error {
259 }264 }
260 if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {265 if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
261 cur.APIKey = ""266 cur.APIKey = ""
267+ cur.APIKeyCommand = nil
262 c.setAdditional(provider, cur)268 c.setAdditional(provider, cur)
263 return s.saveLocked(c)269 return s.saveLocked(c)
264 }270 }
@@ -267,6 +273,7 @@ func (s *Store) ClearAPIKey(provider string) error {
267 return nil273 return nil
268 }274 }
269 p.APIKey = ""275 p.APIKey = ""
276+ p.APIKeyCommand = nil
270 return s.saveLocked(c)277 return s.saveLocked(c)
271 }278 }
272 279
A packages/provider/auth/store_command_test.go +60 -0
@@ -0,0 +1,60 @@
1+package auth
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestStoreRecognizesAndClearsAPIKeyCommand(t *testing.T) {
10+ path := filepath.Join(t.TempDir(), "auth.json")
11+ if err := os.WriteFile(path, []byte(`{
12+ "anthropic": {
13+ "api_key_command": {
14+ "program": "op",
15+ "args": ["read", "op://example/credential"],
16+ "timeout_ms": 120000
17+ }
18+ }
19+}`), 0o600); err != nil {
20+ t.Fatal(err)
21+ }
22+ store := NewStore(path)
23+ creds, err := store.Load()
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ if !creds.Has("anthropic") || creds.Method("anthropic") != "apikey" {
28+ t.Fatalf("command credential not recognized: %+v", creds.Anthropic)
29+ }
30+
31+ if err := store.ClearAPIKey("anthropic"); err != nil {
32+ t.Fatal(err)
33+ }
34+ creds, err = store.Load()
35+ if err != nil {
36+ t.Fatal(err)
37+ }
38+ if creds.Anthropic.APIKeyCommand != nil || creds.Has("anthropic") {
39+ t.Fatalf("command credential not cleared: %+v", creds.Anthropic)
40+ }
41+}
42+
43+func TestSetAPIKeyReplacesAPIKeyCommand(t *testing.T) {
44+ path := filepath.Join(t.TempDir(), "auth.json")
45+ if err := os.WriteFile(path, []byte(`{"additional_api_key_creds":{"custom":{"api_key_command":{"program":"op"}}}}`), 0o600); err != nil {
46+ t.Fatal(err)
47+ }
48+ store := NewStore(path)
49+ if err := store.SetAPIKey("custom", "literal-key"); err != nil {
50+ t.Fatal(err)
51+ }
52+ creds, err := store.Load()
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ got := creds.AdditionalAPIKeyCreds["custom"]
57+ if got.APIKey != "literal-key" || got.APIKeyCommand != nil {
58+ t.Fatalf("stored credential = %+v", got)
59+ }
60+}
@@ -0,0 +1,60 @@
1+package auth
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestStoreRecognizesAndClearsAPIKeyCommand(t *testing.T) {
10+ path := filepath.Join(t.TempDir(), "auth.json")
11+ if err := os.WriteFile(path, []byte(`{
12+ "anthropic": {
13+ "api_key_command": {
14+ "program": "op",
15+ "args": ["read", "op://example/credential"],
16+ "timeout_ms": 120000
17+ }
18+ }
19+}`), 0o600); err != nil {
20+ t.Fatal(err)
21+ }
22+ store := NewStore(path)
23+ creds, err := store.Load()
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ if !creds.Has("anthropic") || creds.Method("anthropic") != "apikey" {
28+ t.Fatalf("command credential not recognized: %+v", creds.Anthropic)
29+ }
30+
31+ if err := store.ClearAPIKey("anthropic"); err != nil {
32+ t.Fatal(err)
33+ }
34+ creds, err = store.Load()
35+ if err != nil {
36+ t.Fatal(err)
37+ }
38+ if creds.Anthropic.APIKeyCommand != nil || creds.Has("anthropic") {
39+ t.Fatalf("command credential not cleared: %+v", creds.Anthropic)
40+ }
41+}
42+
43+func TestSetAPIKeyReplacesAPIKeyCommand(t *testing.T) {
44+ path := filepath.Join(t.TempDir(), "auth.json")
45+ if err := os.WriteFile(path, []byte(`{"additional_api_key_creds":{"custom":{"api_key_command":{"program":"op"}}}}`), 0o600); err != nil {
46+ t.Fatal(err)
47+ }
48+ store := NewStore(path)
49+ if err := store.SetAPIKey("custom", "literal-key"); err != nil {
50+ t.Fatal(err)
51+ }
52+ creds, err := store.Load()
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ got := creds.AdditionalAPIKeyCreds["custom"]
57+ if got.APIKey != "literal-key" || got.APIKeyCommand != nil {
58+ t.Fatalf("stored credential = %+v", got)
59+ }
60+}

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