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 | |||
| 87 | 87 | - Linux fallback: `~/.local/state/zot` | |
| 88 | 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 | 112 | ### `/login` flow | |
| 91 | 113 | ||
| 92 | 114 | 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` flow | 112 | ### `/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, | |||
| 18 | 18 | This avoids broken browser flows in SSH, containers, and `kubectl exec` | |
| 19 | 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 | 56 | Setup-instruction providers: | |
| 22 | 57 | ||
| 23 | 58 | - 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 Bedrock | 58 | - Amazon Bedrock | ||
M packages/agent/build.go +11 -9
| @@ -324,19 +324,19 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 324 | 324 | // by isKnownProvider, so we only reach here for truly unknown | |
| 325 | 325 | // names. | |
| 326 | 326 | provName = "anthropic" | |
| 327 | - | if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil { | |
| 327 | + | if CredentialAvailable("openai") { | |
| 328 | 328 | provName = "openai" | |
| 329 | 329 | } | |
| 330 | - | if _, _, _, err := ResolveCredentialFull("openai-codex", ""); err == nil { | |
| 330 | + | if CredentialAvailable("openai-codex") { | |
| 331 | 331 | provName = "openai-codex" | |
| 332 | 332 | } | |
| 333 | - | if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil { | |
| 333 | + | if CredentialAvailable("kimi") { | |
| 334 | 334 | provName = "kimi" | |
| 335 | 335 | } | |
| 336 | - | if _, _, _, err := ResolveCredentialFull("deepseek", ""); err == nil { | |
| 336 | + | if CredentialAvailable("deepseek") { | |
| 337 | 337 | provName = "deepseek" | |
| 338 | 338 | } | |
| 339 | - | if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil { | |
| 339 | + | if CredentialAvailable("anthropic") { | |
| 340 | 340 | provName = "anthropic" | |
| 341 | 341 | } | |
| 342 | 342 | // Reset the saved config so this doesn't keep happening. | |
| @@ -400,11 +400,13 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 400 | 400 | if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID { | |
| 401 | 401 | continue | |
| 402 | 402 | } | |
| 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 | |
| 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 | ||
| @@ -324,19 +324,19 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 324 | // by isKnownProvider, so we only reach here for truly unknown | 324 | // 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 | continue | 401 | continue | ||
| 402 | } | 402 | } | ||
| 403 | - | if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil { | 403 | + | if !CredentialAvailable(other) { |
| 404 | - | provName = other | 404 | + | 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 { | |||
| 1032 | 1032 | var out []string | |
| 1033 | 1033 | seen := map[string]bool{} | |
| 1034 | 1034 | for _, p := range knownProviders { | |
| 1035 | - | if _, _, err := ResolveCredential(p, ""); err == nil && !seen[p] { | |
| 1035 | + | if CredentialAvailable(p) && !seen[p] { | |
| 1036 | 1036 | out = append(out, p) | |
| 1037 | 1037 | seen[p] = true | |
| 1038 | 1038 | } | |
| 1039 | 1039 | } | |
| 1040 | 1040 | // Include custom providers that have credentials stored. | |
| 1041 | 1041 | for p := range provider.CustomProviders() { | |
| 1042 | - | if _, _, err := ResolveCredential(p, ""); err == nil && !seen[p] { | |
| 1042 | + | if CredentialAvailable(p) && !seen[p] { | |
| 1043 | 1043 | out = append(out, p) | |
| 1044 | 1044 | seen[p] = true | |
| 1045 | 1045 | } | |
| @@ -1032,14 +1032,14 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1032 | var out []string | 1032 | 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] = true | 1037 | 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] = true | 1044 | seen[p] = true | ||
| 1045 | } | 1045 | } | ||
M packages/agent/config.go +103 -33
| @@ -225,17 +225,30 @@ func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) } | |||
| 225 | 225 | // ResolveLlamaCPPConfig resolves the router URL and optional API key. The | |
| 226 | 226 | // environment overrides the credential stored through /login. | |
| 227 | 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 | 232 | baseURL = strings.TrimSpace(os.Getenv("LLAMA_BASE_URL")) | |
| 229 | 233 | apiKey = strings.TrimSpace(os.Getenv("LLAMA_API_KEY")) | |
| 230 | 234 | if baseURL == "" { | |
| 231 | - | var storedKey string | |
| 232 | - | var loadErr error | |
| 233 | - | baseURL, storedKey, loadErr = AuthStoreFor().EndpointCredential(providerpkg.LlamaCPPProviderID) | |
| 235 | + | creds, loadErr := AuthStoreFor().Load() | |
| 234 | 236 | if loadErr != nil { | |
| 235 | 237 | return "", "", loadErr | |
| 236 | 238 | } | |
| 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 | + | } | |
| 239 | 252 | } | |
| 240 | 253 | } | |
| 241 | 254 | if baseURL == "" { | |
| @@ -258,11 +271,34 @@ func ResolveCredential(provider, explicit string) (cred, method string, err erro | |||
| 258 | 271 | 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 | 280 | // ResolveCredentialFull is like ResolveCredential but also returns a | |
| 262 | 281 | // provider-specific accountID when the credential is an OpenAI OAuth | |
| 263 | 282 | // token (the ChatGPT account id extracted from the stored id_token). | |
| 264 | 283 | // accountID is "" for API-key auth and for anthropic. | |
| 265 | 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 | 302 | if explicit != "" { | |
| 267 | 303 | return explicit, "apikey", "", nil | |
| 268 | 304 | } | |
| @@ -313,7 +349,7 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||
| 313 | 349 | return v, "apikey", "", nil | |
| 314 | 350 | } | |
| 315 | 351 | case "llama.cpp": | |
| 316 | - | baseURL, apiKey, resolveErr := ResolveLlamaCPPConfig() | |
| 352 | + | baseURL, apiKey, resolveErr := resolveLlamaCPPConfig(ctx, commandMode) | |
| 317 | 353 | if resolveErr != nil { | |
| 318 | 354 | return "", "", "", resolveErr | |
| 319 | 355 | } | |
| @@ -444,9 +480,12 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||
| 444 | 480 | if err != nil { | |
| 445 | 481 | return "", "", "", err | |
| 446 | 482 | } | |
| 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 | |
| 450 | 489 | } | |
| 451 | 490 | if provider == "xai" && pc.OAuth != nil && pc.OAuth.AccessToken != "" { | |
| 452 | 491 | tok, _ := refreshIfExpired(provider, pc.OAuth) | |
| @@ -455,26 +494,16 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||
| 455 | 494 | } | |
| 456 | 495 | switch provider { | |
| 457 | 496 | case "anthropic": | |
| 458 | - | if c.Anthropic.APIKey != "" { | |
| 459 | - | return c.Anthropic.APIKey, "apikey", "", nil | |
| 460 | - | } | |
| 461 | 497 | if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" { | |
| 462 | 498 | tok, _ := refreshIfExpired("anthropic", c.Anthropic.OAuth) | |
| 463 | 499 | return tok.AccessToken, "oauth", "", nil | |
| 464 | 500 | } | |
| 465 | - | case "openai": | |
| 466 | - | if c.OpenAI.APIKey != "" { | |
| 467 | - | return c.OpenAI.APIKey, "apikey", "", nil | |
| 468 | - | } | |
| 469 | 501 | case "openai-codex": | |
| 470 | 502 | if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" { | |
| 471 | 503 | tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth) | |
| 472 | 504 | return tok.AccessToken, "oauth", tok.AccountID, nil | |
| 473 | 505 | } | |
| 474 | 506 | case "kimi": | |
| 475 | - | if c.Kimi.APIKey != "" { | |
| 476 | - | return c.Kimi.APIKey, "apikey", "", nil | |
| 477 | - | } | |
| 478 | 507 | if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" { | |
| 479 | 508 | tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth) | |
| 480 | 509 | return tok.AccessToken, "oauth", "", nil | |
| @@ -486,21 +515,7 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||
| 486 | 515 | tok, _ = refreshIfExpired("kimi", tok) | |
| 487 | 516 | 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 | 518 | case "github-copilot": | |
| 501 | - | if c.GithubCopilot.APIKey != "" { | |
| 502 | - | return c.GithubCopilot.APIKey, "apikey", "", nil | |
| 503 | - | } | |
| 504 | 519 | if c.GithubCopilot.OAuth != nil && c.GithubCopilot.OAuth.AccessToken != "" { | |
| 505 | 520 | return c.GithubCopilot.OAuth.AccessToken, "oauth", "", nil | |
| 506 | 521 | } | |
| @@ -508,6 +523,61 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||
| 508 | 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 | 581 | func kimiCLIFallbackDisabled() bool { | |
| 512 | 582 | _, err := os.Stat(KimiCLIFallbackDisabledPath()) | |
| 513 | 583 | 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. The | 225 | // 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 string | 235 | + | 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 "", "", loadErr | 237 | return "", "", loadErr | ||
| 236 | } | 238 | } | ||
| 237 | - | if apiKey == "" { | 239 | + | if stored, ok := creds.AdditionalAPIKeyCreds[providerpkg.LlamaCPPProviderID]; ok { |
| 238 | - | apiKey = storedKey | 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 | + | } | |||
| 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, err | 271 | 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 a | 280 | // ResolveCredentialFull is like ResolveCredential but also returns a | ||
| 262 | // provider-specific accountID when the credential is an OpenAI OAuth | 281 | // 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", "", nil | 303 | return explicit, "apikey", "", nil | ||
| 268 | } | 304 | } | ||
| @@ -313,7 +349,7 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||||
| 313 | return v, "apikey", "", nil | 349 | 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 "", "", "", resolveErr | 354 | 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 "", "", "", err | 481 | 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", "", nil | 485 | + | 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", "", nil | 499 | 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, nil | 504 | 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", "", nil | 509 | 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", "", nil | 516 | 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", "", nil | 520 | 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 == nil | 583 | 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() { | |||
| 144 | 144 | go func() { | |
| 145 | 145 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |
| 146 | 146 | defer cancel() | |
| 147 | - | _ = RefreshLlamaCPPModels(ctx) | |
| 147 | + | _ = refreshLlamaCPPModels(ctx, apiKeyCommandSkip) | |
| 148 | 148 | }() | |
| 149 | 149 | } | |
| 150 | 150 | ||
| @@ -152,7 +152,11 @@ func RefreshModelsAsync() { | |||
| 152 | 152 | // active catalog. Unloaded models remain in the management UI and cannot be | |
| 153 | 153 | // selected for inference until they are loaded. | |
| 154 | 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 | 160 | if err != nil || baseURL == "" { | |
| 157 | 161 | return err | |
| 158 | 162 | } | |
| @@ -179,7 +183,7 @@ func refreshModels() { | |||
| 179 | 183 | ||
| 180 | 184 | 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 | 187 | // /v1/models on Anthropic is API-key only; OAuth tokens can | |
| 184 | 188 | // also list models via the bearer header, but we skip OAuth | |
| 185 | 189 | // here to avoid surprise rate-limit hits on subscription keys. | |
| @@ -187,12 +191,12 @@ func refreshModels() { | |||
| 187 | 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 | 195 | if live, err := provider.DiscoverOpenAI(ctx, cred, ""); err == nil { | |
| 192 | 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 | 200 | if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil { | |
| 197 | 201 | for i := range live { | |
| 198 | 202 | live[i].Provider = "kimi" | |
| @@ -201,12 +205,12 @@ func refreshModels() { | |||
| 201 | 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 | 209 | if live, err := provider.DiscoverGoogle(ctx, cred, ""); err == nil { | |
| 206 | 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 | 214 | // /models is public; gate on a credential so the picker only | |
| 211 | 215 | // fills with OpenRouter's hundreds of routes for users who use it. | |
| 212 | 216 | 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 be | 152 | // 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 err | 161 | return err | ||
| 158 | } | 162 | } | ||
| @@ -179,7 +183,7 @@ func refreshModels() { | |||||
| 179 | 183 | ||||
| 180 | var all []provider.Model | 184 | 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 can | 187 | // /v1/models on Anthropic is API-key only; OAuth tokens can | ||
| 184 | // also list models via the bearer header, but we skip OAuth | 188 | // 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 only | 214 | // /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() { | |||
| 4315 | 4315 | }) | |
| 4316 | 4316 | } | |
| 4317 | 4317 | } | |
| 4318 | - | if creds.OpenAI.APIKey != "" { | |
| 4318 | + | if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil { | |
| 4319 | 4319 | items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"}) | |
| 4320 | 4320 | } | |
| 4321 | 4321 | if creds.OpenAI.OAuth != nil { | |
| 4322 | 4322 | items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"}) | |
| 4323 | 4323 | } | |
| 4324 | 4324 | for p, c := range creds.AdditionalAPIKeyCreds { | |
| 4325 | - | if c.APIKey != "" || c.BaseURL != "" { | |
| 4325 | + | if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" { | |
| 4326 | 4326 | items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"}) | |
| 4327 | 4327 | } | |
| 4328 | 4328 | } | |
| @@ -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) { | |||
| 98 | 98 | if creds, err := auth.NewStore(path).Load(); err == nil { | |
| 99 | 99 | d.status["anthropic"] = creds.Method("anthropic") | |
| 100 | 100 | d.status["openai"] = "" | |
| 101 | - | if creds.OpenAI.APIKey != "" { | |
| 101 | + | if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil { | |
| 102 | 102 | d.status["openai"] = "apikey" | |
| 103 | 103 | } | |
| 104 | 104 | 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, | |||
| 534 | 534 | if p.APIKey != "" { | |
| 535 | 535 | 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 | 544 | if p.OAuth == nil { | |
| 538 | 545 | return "", "", fmt.Errorf("no credentials for %s", provider) | |
| 539 | 546 | } | |
| @@ -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", nil | 535 | 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 { | |||
| 29 | 29 | // use either APIKey or OAuth; OpenAI may store both so the public API | |
| 30 | 30 | // route and ChatGPT/Codex subscription route can coexist. | |
| 31 | 31 | 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"` | |
| 35 | 36 | } | |
| 36 | 37 | ||
| 37 | 38 | // OAuthToken is an OAuth 2 token set with refresh support. | |
| @@ -63,7 +64,7 @@ func (t *OAuthToken) Expired() bool { | |||
| 63 | 64 | // Has reports whether at least one credential is present for provider. | |
| 64 | 65 | func (c *Credentials) Has(provider string) bool { | |
| 65 | 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 | 70 | // Method returns "apikey", "oauth", or "" for the given provider. | |
| @@ -72,7 +73,7 @@ func (c *Credentials) Method(provider string) string { | |||
| 72 | 73 | if p == nil { | |
| 73 | 74 | return "" | |
| 74 | 75 | } | |
| 75 | - | if p.APIKey != "" || p.BaseURL != "" { | |
| 76 | + | if p.APIKey != "" || p.APIKeyCommand != nil || p.BaseURL != "" { | |
| 76 | 77 | return "apikey" | |
| 77 | 78 | } | |
| 78 | 79 | if p.OAuth != nil { | |
| @@ -108,7 +109,7 @@ func (c *Credentials) setAdditional(provider string, p ProviderCreds) { | |||
| 108 | 109 | if c.AdditionalAPIKeyCreds == nil { | |
| 109 | 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 | 113 | delete(c.AdditionalAPIKeyCreds, provider) | |
| 113 | 114 | if len(c.AdditionalAPIKeyCreds) == 0 { | |
| 114 | 115 | c.AdditionalAPIKeyCreds = nil | |
| @@ -160,6 +161,7 @@ func (s *Store) SetAPIKey(provider, key string) error { | |||
| 160 | 161 | } | |
| 161 | 162 | if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok { | |
| 162 | 163 | cur.APIKey = key | |
| 164 | + | cur.APIKeyCommand = nil | |
| 163 | 165 | cur.OAuth = nil | |
| 164 | 166 | c.setAdditional(provider, cur) | |
| 165 | 167 | return s.saveLocked(c) | |
| @@ -170,6 +172,7 @@ func (s *Store) SetAPIKey(provider, key string) error { | |||
| 170 | 172 | return s.saveLocked(c) | |
| 171 | 173 | } | |
| 172 | 174 | p.APIKey = key | |
| 175 | + | p.APIKeyCommand = nil | |
| 173 | 176 | if provider != "openai" { | |
| 174 | 177 | p.OAuth = nil | |
| 175 | 178 | } | |
| @@ -212,6 +215,7 @@ func (s *Store) SetOAuth(provider string, tok OAuthToken) error { | |||
| 212 | 215 | } | |
| 213 | 216 | if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok { | |
| 214 | 217 | cur.APIKey = "" | |
| 218 | + | cur.APIKeyCommand = nil | |
| 215 | 219 | cur.OAuth = &tok | |
| 216 | 220 | c.setAdditional(provider, cur) | |
| 217 | 221 | return s.saveLocked(c) | |
| @@ -223,6 +227,7 @@ func (s *Store) SetOAuth(provider string, tok OAuthToken) error { | |||
| 223 | 227 | } | |
| 224 | 228 | if provider != "openai" { | |
| 225 | 229 | p.APIKey = "" | |
| 230 | + | p.APIKeyCommand = nil | |
| 226 | 231 | } | |
| 227 | 232 | p.OAuth = &tok | |
| 228 | 233 | return s.saveLocked(c) | |
| @@ -259,6 +264,7 @@ func (s *Store) ClearAPIKey(provider string) error { | |||
| 259 | 264 | } | |
| 260 | 265 | if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok { | |
| 261 | 266 | cur.APIKey = "" | |
| 267 | + | cur.APIKeyCommand = nil | |
| 262 | 268 | c.setAdditional(provider, cur) | |
| 263 | 269 | return s.saveLocked(c) | |
| 264 | 270 | } | |
| @@ -267,6 +273,7 @@ func (s *Store) ClearAPIKey(provider string) error { | |||
| 267 | 273 | return nil | |
| 268 | 274 | } | |
| 269 | 275 | p.APIKey = "" | |
| 276 | + | p.APIKeyCommand = nil | |
| 270 | 277 | return s.saveLocked(c) | |
| 271 | 278 | } | |
| 272 | 279 | ||
| @@ -29,9 +29,10 @@ type Credentials struct { | |||||
| 29 | // use either APIKey or OAuth; OpenAI may store both so the public API | 29 | // 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 = nil | 115 | 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 = key | 163 | cur.APIKey = key | ||
| 164 | + | cur.APIKeyCommand = nil | |||
| 163 | cur.OAuth = nil | 165 | 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 = key | 174 | p.APIKey = key | ||
| 175 | + | p.APIKeyCommand = nil | |||
| 173 | if provider != "openai" { | 176 | if provider != "openai" { | ||
| 174 | p.OAuth = nil | 177 | 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 = &tok | 219 | 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 = &tok | 232 | 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 nil | 273 | 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 | + | } | |||