From b724a8dbcf3ea933b0de8693d0a817ca6fa873c5 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Tue, 6 Jan 2026 10:57:58 +0800 Subject: [PATCH] feat: enable dynamic API key retrieval with helper commands - Add support for dynamic API key retrieval using shell commands, with optional file-based caching and refresh intervals. - Document the new API key helper feature in English, Simplified Chinese, and Traditional Chinese, including setup instructions and security considerations. - Register new config options for OpenAI and Gemini API key helpers and their refresh intervals. - Update provider initialization to prioritize dynamic API key retrieval, with fallback to static config and environment variables. - Implement a utility for securely executing helper commands, caching API keys, and handling process timeouts. - Add comprehensive tests for the API key helper utility, covering command execution, caching, cache expiration, error handling, and file permissions. Signed-off-by: Bo-Yi Wu --- README.md | 111 +++++++++-- README.zh-cn.md | 111 +++++++++-- README.zh-tw.md | 111 +++++++++-- cmd/config_list.go | 56 +++--- cmd/config_set.go | 19 ++ cmd/provider.go | 104 +++++++++- util/api_key_helper.go | 263 ++++++++++++++++++++++++ util/api_key_helper_test.go | 386 ++++++++++++++++++++++++++++++++++++ 8 files changed, 1068 insertions(+), 93 deletions(-) create mode 100644 util/api_key_helper.go create mode 100644 util/api_key_helper_test.go diff --git a/README.md b/README.md index 4167df3..03d09da 100644 --- a/README.md +++ b/README.md @@ -191,26 +191,97 @@ codegpt config set openai.api_key sk-xxxxxxx This will create a `.codegpt.yaml` file in your home directory ($HOME/.config/codegpt/.codegpt.yaml). The following options are available: -| Option | Description | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **openai.base_url** | Replace the default base URL (`https://api.openai.com/v1`). | -| **openai.api_key** | Generate API key from [openai platform page](https://platform.openai.com/account/api-keys). | -| **openai.org_id** | Identifier for this organization sometimes used in API requests. See [organization settings](https://platform.openai.com/account/org-settings). Only for `openai` service. | -| **openai.model** | Default model is `gpt-4o`, you can change to other custom model (Groq or OpenRouter provider). | -| **openai.proxy** | HTTP/HTTPS client proxy. | -| **openai.socks** | SOCKS client proxy. | -| **openai.timeout** | Default HTTP timeout is `10s` (ten seconds). | -| **openai.skip_verify** | Default skip_verify is `false`, You can change it to `true` to ignore SSL verification. | -| **openai.max_tokens** | Default max tokens is `300`. See reference [max_tokens](https://platform.openai.com/docs/api-reference/completions/create#completions/create-max_tokens). | -| **openai.temperature** | Default temperature is `1`. See reference [temperature](https://platform.openai.com/docs/api-reference/completions/create#completions/create-temperature). | -| **git.diff_unified** | Generate diffs with `` lines of context, default is `3`. | -| **git.exclude_list** | Exclude file from `git diff` command. | -| **openai.provider** | Default service provider is `openai`, you can change to `azure`. | -| **output.lang** | Default language is `en` and available languages `zh-tw`, `zh-cn`, `ja`. | -| **openai.top_p** | Default top_p is `1.0`. See reference [top_p](https://platform.openai.com/docs/api-reference/completions/create#completions/create-top_p). | -| **openai.frequency_penalty** | Default frequency_penalty is `0.0`. See reference [frequency_penalty](https://platform.openai.com/docs/api-reference/completions/create#completions/create-frequency_penalty). | -| **openai.presence_penalty** | Default presence_penalty is `0.0`. See reference [presence_penalty](https://platform.openai.com/docs/api-reference/completions/create#completions/create-presence_penalty). | -| **prompt.folder** | Default prompt folder is `$HOME/.config/codegpt/prompt`. | +| Option | Description | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **openai.base_url** | Replace the default base URL (`https://api.openai.com/v1`). | +| **openai.api_key** | Generate API key from [openai platform page](https://platform.openai.com/account/api-keys). | +| **openai.api_key_helper** | Shell command to dynamically generate API key (e.g., from password manager or secret service). | +| **openai.api_key_helper_refresh_interval** | Interval in seconds to refresh credentials from `api_key_helper` (default: `900` seconds / 15 minutes). Set to `0` to disable caching. | +| **openai.org_id** | Identifier for this organization sometimes used in API requests. See [organization settings](https://platform.openai.com/account/org-settings). Only for `openai` service. | +| **openai.model** | Default model is `gpt-4o`, you can change to other custom model (Groq or OpenRouter provider). | +| **openai.proxy** | HTTP/HTTPS client proxy. | +| **openai.socks** | SOCKS client proxy. | +| **openai.timeout** | Default HTTP timeout is `10s` (ten seconds). | +| **openai.skip_verify** | Default skip_verify is `false`, You can change it to `true` to ignore SSL verification. | +| **openai.max_tokens** | Default max tokens is `300`. See reference [max_tokens](https://platform.openai.com/docs/api-reference/completions/create#completions/create-max_tokens). | +| **openai.temperature** | Default temperature is `1`. See reference [temperature](https://platform.openai.com/docs/api-reference/completions/create#completions/create-temperature). | +| **git.diff_unified** | Generate diffs with `` lines of context, default is `3`. | +| **git.exclude_list** | Exclude file from `git diff` command. | +| **openai.provider** | Default service provider is `openai`, you can change to `azure`. | +| **output.lang** | Default language is `en` and available languages `zh-tw`, `zh-cn`, `ja`. | +| **openai.top_p** | Default top_p is `1.0`. See reference [top_p](https://platform.openai.com/docs/api-reference/completions/create#completions/create-top_p). | +| **openai.frequency_penalty** | Default frequency_penalty is `0.0`. See reference [frequency_penalty](https://platform.openai.com/docs/api-reference/completions/create#completions/create-frequency_penalty). | +| **openai.presence_penalty** | Default presence_penalty is `0.0`. See reference [presence_penalty](https://platform.openai.com/docs/api-reference/completions/create#completions/create-presence_penalty). | +| **prompt.folder** | Default prompt folder is `$HOME/.config/codegpt/prompt`. | + +### Using API Key Helper for Dynamic Credentials + +Instead of storing your API key directly in the config file, you can use a shell command to dynamically retrieve it from a password manager or secret service. This is especially useful for: + +- Fetching keys from password managers (1Password, Bitwarden, etc.) +- Using cloud secret services (AWS Secrets Manager, Google Secret Manager, etc.) +- Implementing token rotation and short-lived credentials +- Enhancing security by not storing keys in plain text + +#### Setup API Key Helper + +Configure a shell command to retrieve your API key: + +```sh +# Using 1Password CLI +codegpt config set openai.api_key_helper "op read op://vault/openai/api_key" + +# Using AWS Secrets Manager +codegpt config set openai.api_key_helper "aws secretsmanager get-secret-value --secret-id openai-key --query SecretString --output text" + +# Using Google Cloud Secret Manager +codegpt config set openai.api_key_helper "gcloud secrets versions access latest --secret=openai-api-key" + +# Using environment variable +codegpt config set openai.api_key_helper "echo \$MY_OPENAI_KEY" + +# Custom script +codegpt config set openai.api_key_helper "/path/to/get-api-key.sh" +``` + +#### Configure Refresh Interval + +By default, the API key is cached for 15 minutes (900 seconds) to avoid excessive calls to your secret service: + +```sh +# Set refresh interval to 5 minutes +codegpt config set openai.api_key_helper_refresh_interval 300 + +# Set refresh interval to 30 minutes +codegpt config set openai.api_key_helper_refresh_interval 1800 + +# Disable caching (fetch key every time) +codegpt config set openai.api_key_helper_refresh_interval 0 +``` + +#### Gemini-Specific API Key Helper + +For Gemini provider, you can set a separate helper: + +```sh +codegpt config set gemini.api_key_helper "gcloud secrets versions access latest --secret=gemini-key" +codegpt config set gemini.api_key_helper_refresh_interval 600 +``` + +#### How It Works + +1. **First execution**: CodeGPT runs your helper command and caches the API key in `~/.config/codegpt/.cache/` with restrictive permissions (0600) +2. **Subsequent executions**: Within the refresh interval, CodeGPT uses the cached key +3. **After expiration**: CodeGPT automatically re-runs the helper command and updates the cache +4. **Security**: Cache files are stored with owner-only read/write permissions + +#### Priority Order + +When multiple API key sources are configured, CodeGPT uses this priority: + +1. `openai.api_key_helper` (if configured) +2. `openai.api_key` (static config) +3. `OPENAI_API_KEY` environment variable ### How to Customize the Default Prompt Folder diff --git a/README.zh-cn.md b/README.zh-cn.md index 2590347..3b5b98e 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -188,26 +188,97 @@ codegpt config set openai.api_key sk-xxxxxxx 这将在你的主目录中创建一个 `.codegpt.yaml` 文件($HOME/.config/codegpt/.codegpt.yaml)。以下选项可用。 -| 选项 | 描述 | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **openai.base_url** | 替换默认的基本 URL (`https://api.openai.com/v1`)。 | -| **openai.api_key** | 从 [openai 平台页面](https://platform.openai.com/account/api-keys) 生成 API key。 | -| **openai.org_id** | 在 API 请求中有时使用的组织标识符。参见 [组织设置](https://platform.openai.com/account/org-settings)。仅适用于 `openai` 服务。 | -| **openai.model** | 默认模型是 `gpt-4o`,你可以更改为其他自定义模型(Groq 或 OpenRouter 提供)。 | -| **openai.proxy** | HTTP/HTTPS 客户端代理。 | -| **openai.socks** | SOCKS 客户端代理。 | -| **openai.timeout** | 默认 HTTP 超时时间是 `10s`(十秒)。 | -| **openai.skip_verify** | 默认 skip_verify 设置为 `false`,可以将其更改为 `true` 以忽略 SSL 验证。 | -| **openai.max_tokens** | 默认最大 token 数是 `300`。参见参考 [max_tokens](https://platform.openai.com/docs/api-reference/completions/create#completions/create-max_tokens)。 | -| **openai.temperature** | 默认温度是 `1`。参见参考 [temperature](https://platform.openai.com/docs/api-reference/completions/create#completions/create-temperature)。 | -| **git.diff_unified** | 生成具有 `` 行上下文的差异,默认是 `3`。 | -| **git.exclude_list** | 从 `git diff` 命令中排除文件。 | -| **openai.provider** | 默认服务提供商是 `openai`,你可以更改为 `azure`。 | -| **output.lang** | 默认语言是 `en`,可用语言有 `zh-tw`、`zh-cn`、`ja`。 | -| **openai.top_p** | 默认 top_p 是 `1.0`。参见参考 [top_p](https://platform.openai.com/docs/api-reference/completions/create#completions/create-top_p)。 | -| **openai.frequency_penalty** | 默认 frequency_penalty 是 `0.0`。参见参考 [frequency_penalty](https://platform.openai.com/docs/api-reference/completions/create#completions/create-frequency_penalty)。 | -| **openai.presence_penalty** | 默认 presence_penalty 是 `0.0`。参见参考 [presence_penalty](https://platform.openai.com/docs/api-reference/completions/create#completions/create-presence_penalty)。 | -| **prompt.folder** | 默认提示文件夹是 `$HOME/.config/codegpt/prompt`。 | +| 选项 | 描述 | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **openai.base_url** | 替换默认的基本 URL (`https://api.openai.com/v1`)。 | +| **openai.api_key** | 从 [openai 平台页面](https://platform.openai.com/account/api-keys) 生成 API key。 | +| **openai.api_key_helper** | 用于动态生成 API key 的 Shell 命令(例如从密码管理器或密钥服务获取)。 | +| **openai.api_key_helper_refresh_interval** | 从 `api_key_helper` 刷新凭证的间隔秒数(默认:`900` 秒 / 15 分钟)。设置为 `0` 以禁用缓存。 | +| **openai.org_id** | 在 API 请求中有时使用的组织标识符。参见 [组织设置](https://platform.openai.com/account/org-settings)。仅适用于 `openai` 服务。 | +| **openai.model** | 默认模型是 `gpt-4o`,你可以更改为其他自定义模型(Groq 或 OpenRouter 提供)。 | +| **openai.proxy** | HTTP/HTTPS 客户端代理。 | +| **openai.socks** | SOCKS 客户端代理。 | +| **openai.timeout** | 默认 HTTP 超时时间是 `10s`(十秒)。 | +| **openai.skip_verify** | 默认 skip_verify 设置为 `false`,可以将其更改为 `true` 以忽略 SSL 验证。 | +| **openai.max_tokens** | 默认最大 token 数是 `300`。参见参考 [max_tokens](https://platform.openai.com/docs/api-reference/completions/create#completions/create-max_tokens)。 | +| **openai.temperature** | 默认温度是 `1`。参见参考 [temperature](https://platform.openai.com/docs/api-reference/completions/create#completions/create-temperature)。 | +| **git.diff_unified** | 生成具有 `` 行上下文的差异,默认是 `3`。 | +| **git.exclude_list** | 从 `git diff` 命令中排除文件。 | +| **openai.provider** | 默认服务提供商是 `openai`,你可以更改为 `azure`。 | +| **output.lang** | 默认语言是 `en`,可用语言有 `zh-tw`、`zh-cn`、`ja`。 | +| **openai.top_p** | 默认 top_p 是 `1.0`。参见参考 [top_p](https://platform.openai.com/docs/api-reference/completions/create#completions/create-top_p)。 | +| **openai.frequency_penalty** | 默认 frequency_penalty 是 `0.0`。参见参考 [frequency_penalty](https://platform.openai.com/docs/api-reference/completions/create#completions/create-frequency_penalty)。 | +| **openai.presence_penalty** | 默认 presence_penalty 是 `0.0`。参见参考 [presence_penalty](https://platform.openai.com/docs/api-reference/completions/create#completions/create-presence_penalty)。 | +| **prompt.folder** | 默认提示文件夹是 `$HOME/.config/codegpt/prompt`。 | + +### 使用 API Key Helper 动态获取凭证 + +你可以使用 Shell 命令从密码管理器或密钥服务动态获取 API key,而不是直接将其存储在配置文件中。这特别适用于: + +- 从密码管理器获取密钥(1Password、Bitwarden 等) +- 使用云端密钥服务(AWS Secrets Manager、Google Secret Manager 等) +- 实现密钥轮换和短期凭证 +- 提高安全性,避免以明文存储密钥 + +#### 设置 API Key Helper + +配置 Shell 命令来获取你的 API key: + +```sh +# 使用 1Password CLI +codegpt config set openai.api_key_helper "op read op://vault/openai/api_key" + +# 使用 AWS Secrets Manager +codegpt config set openai.api_key_helper "aws secretsmanager get-secret-value --secret-id openai-key --query SecretString --output text" + +# 使用 Google Cloud Secret Manager +codegpt config set openai.api_key_helper "gcloud secrets versions access latest --secret=openai-api-key" + +# 使用环境变量 +codegpt config set openai.api_key_helper "echo \$MY_OPENAI_KEY" + +# 自定义脚本 +codegpt config set openai.api_key_helper "/path/to/get-api-key.sh" +``` + +#### 配置刷新间隔 + +默认情况下,API key 会被缓存 15 分钟(900 秒),以避免对密钥服务的过度调用: + +```sh +# 设置刷新间隔为 5 分钟 +codegpt config set openai.api_key_helper_refresh_interval 300 + +# 设置刷新间隔为 30 分钟 +codegpt config set openai.api_key_helper_refresh_interval 1800 + +# 禁用缓存(每次都重新获取密钥) +codegpt config set openai.api_key_helper_refresh_interval 0 +``` + +#### Gemini 专用 API Key Helper + +对于 Gemini 提供者,你可以设置单独的 helper: + +```sh +codegpt config set gemini.api_key_helper "gcloud secrets versions access latest --secret=gemini-key" +codegpt config set gemini.api_key_helper_refresh_interval 600 +``` + +#### 工作原理 + +1. **首次执行**:CodeGPT 运行你的 helper 命令并将 API key 缓存到 `~/.config/codegpt/.cache/`,文件权限为 0600(仅所有者可读写) +2. **后续执行**:在刷新间隔内,CodeGPT 使用缓存的密钥 +3. **过期后**:CodeGPT 自动重新运行 helper 命令并更新缓存 +4. **安全性**:缓存文件以仅所有者可读写的权限存储 + +#### 优先顺序 + +当配置了多个 API key 来源时,CodeGPT 使用以下优先顺序: + +1. `openai.api_key_helper`(如果已配置) +2. `openai.api_key`(静态配置) +3. `OPENAI_API_KEY` 环境变量 ### 如何自定义默认提示文件夹 diff --git a/README.zh-tw.md b/README.zh-tw.md index 222715a..d3572cb 100644 --- a/README.zh-tw.md +++ b/README.zh-tw.md @@ -190,26 +190,97 @@ codegpt config set openai.api_key sk-xxxxxxx 這將在您的主目錄中創建一個 `.codegpt.yaml` 文件($HOME/.config/codegpt/.codegpt.yaml)。以下選項可用: -| 選項 | 描述 | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **openai.base_url** | 替換默認的基礎 URL (`https://api.openai.com/v1`)。 | -| **openai.api_key** | 從 [openai 平台頁面](https://platform.openai.com/account/api-keys) 生成 API key。 | -| **openai.org_id** | 在 API 請求中有時使用的組織標識符。請參閱 [組織設置](https://platform.openai.com/account/org-settings)。僅適用於 `openai` 服務。 | -| **openai.model** | 默認模型是 `gpt-4o`,您可以更改為其他自定義模型(Groq 或 OpenRouter 提供者)。 | -| **openai.proxy** | HTTP/HTTPS 客戶端代理。 | -| **openai.socks** | SOCKS 客戶端代理。 | -| **openai.timeout** | 默認 HTTP 超時為 `10s`(十秒)。 | -| **openai.skip_verify** | 默認 skip_verify 是 `false` 你可以改為 `true` 以忽略 SSL 驗證。 | -| **openai.max_tokens** | 默認最大 token 為 `300`。請參閱參考 [max_tokens](https://platform.openai.com/docs/api-reference/completions/create#completions/create-max_tokens)。 | -| **openai.temperature** | 默認溫度為 `1`。請參閱參考 [temperature](https://platform.openai.com/docs/api-reference/completions/create#completions/create-temperature)。 | -| **git.diff_unified** | 生成具有 `` 行上下文的差異,默認為 `3`。 | -| **git.exclude_list** | 從 `git diff` 命令中排除文件。 | -| **openai.provider** | 默認服務提供者是 `openai`,您可以更改為 `azure`。 | -| **output.lang** | 默認語言是 `en`,可用語言有 `zh-tw`、`zh-cn`、`ja`。 | -| **openai.top_p** | 默認 top_p 為 `1.0`。請參閱參考 [top_p](https://platform.openai.com/docs/api-reference/completions/create#completions/create-top_p)。 | -| **openai.frequency_penalty** | 默認 frequency_penalty 為 `0.0`。請參閱參考 [frequency_penalty](https://platform.openai.com/docs/api-reference/completions/create#completions/create-frequency_penalty)。 | -| **openai.presence_penalty** | 默認 presence_penalty 炭 `0.0`。請參閱參考 [presence_penalty](https://platform.openai.com/docs/api-reference/completions/create#completions/create-presence_penalty)。 | -| **prompt.folder** | 默認提示文件夾是 `$HOME/.config/codegpt/prompt`。 | +| 選項 | 描述 | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **openai.base_url** | 替換默認的基礎 URL (`https://api.openai.com/v1`)。 | +| **openai.api_key** | 從 [openai 平台頁面](https://platform.openai.com/account/api-keys) 生成 API key。 | +| **openai.api_key_helper** | 用於動態生成 API key 的 Shell 命令(例如從密碼管理器或密鑰服務獲取)。 | +| **openai.api_key_helper_refresh_interval** | 從 `api_key_helper` 刷新憑證的間隔秒數(默認:`900` 秒 / 15 分鐘)。設置為 `0` 以禁用緩存。 | +| **openai.org_id** | 在 API 請求中有時使用的組織標識符。請參閱 [組織設置](https://platform.openai.com/account/org-settings)。僅適用於 `openai` 服務。 | +| **openai.model** | 默認模型是 `gpt-4o`,您可以更改為其他自定義模型(Groq 或 OpenRouter 提供者)。 | +| **openai.proxy** | HTTP/HTTPS 客戶端代理。 | +| **openai.socks** | SOCKS 客戶端代理。 | +| **openai.timeout** | 默認 HTTP 超時為 `10s`(十秒)。 | +| **openai.skip_verify** | 默認 skip_verify 是 `false` 你可以改為 `true` 以忽略 SSL 驗證。 | +| **openai.max_tokens** | 默認最大 token 為 `300`。請參閱參考 [max_tokens](https://platform.openai.com/docs/api-reference/completions/create#completions/create-max_tokens)。 | +| **openai.temperature** | 默認溫度為 `1`。請參閱參考 [temperature](https://platform.openai.com/docs/api-reference/completions/create#completions/create-temperature)。 | +| **git.diff_unified** | 生成具有 `` 行上下文的差異,默認為 `3`。 | +| **git.exclude_list** | 從 `git diff` 命令中排除文件。 | +| **openai.provider** | 默認服務提供者是 `openai`,您可以更改為 `azure`。 | +| **output.lang** | 默認語言是 `en`,可用語言有 `zh-tw`、`zh-cn`、`ja`。 | +| **openai.top_p** | 默認 top_p 為 `1.0`。請參閱參考 [top_p](https://platform.openai.com/docs/api-reference/completions/create#completions/create-top_p)。 | +| **openai.frequency_penalty** | 默認 frequency_penalty 為 `0.0`。請參閱參考 [frequency_penalty](https://platform.openai.com/docs/api-reference/completions/create#completions/create-frequency_penalty)。 | +| **openai.presence_penalty** | 默認 presence_penalty 炭 `0.0`。請參閱參考 [presence_penalty](https://platform.openai.com/docs/api-reference/completions/create#completions/create-presence_penalty)。 | +| **prompt.folder** | 默認提示文件夾是 `$HOME/.config/codegpt/prompt`。 | + +### 使用 API Key Helper 動態獲取憑證 + +您可以使用 Shell 命令從密碼管理器或密鑰服務動態獲取 API key,而不是直接將其存儲在配置文件中。這特別適用於: + +- 從密碼管理器獲取密鑰(1Password、Bitwarden 等) +- 使用雲端密鑰服務(AWS Secrets Manager、Google Secret Manager 等) +- 實現密鑰輪換和短期憑證 +- 提高安全性,避免以明文存儲密鑰 + +#### 設置 API Key Helper + +配置 Shell 命令來獲取您的 API key: + +```sh +# 使用 1Password CLI +codegpt config set openai.api_key_helper "op read op://vault/openai/api_key" + +# 使用 AWS Secrets Manager +codegpt config set openai.api_key_helper "aws secretsmanager get-secret-value --secret-id openai-key --query SecretString --output text" + +# 使用 Google Cloud Secret Manager +codegpt config set openai.api_key_helper "gcloud secrets versions access latest --secret=openai-api-key" + +# 使用環境變數 +codegpt config set openai.api_key_helper "echo \$MY_OPENAI_KEY" + +# 自定義腳本 +codegpt config set openai.api_key_helper "/path/to/get-api-key.sh" +``` + +#### 配置刷新間隔 + +默認情況下,API key 會被緩存 15 分鐘(900 秒),以避免對密鑰服務的過度調用: + +```sh +# 設置刷新間隔為 5 分鐘 +codegpt config set openai.api_key_helper_refresh_interval 300 + +# 設置刷新間隔為 30 分鐘 +codegpt config set openai.api_key_helper_refresh_interval 1800 + +# 禁用緩存(每次都重新獲取密鑰) +codegpt config set openai.api_key_helper_refresh_interval 0 +``` + +#### Gemini 專用 API Key Helper + +對於 Gemini 提供者,您可以設置單獨的 helper: + +```sh +codegpt config set gemini.api_key_helper "gcloud secrets versions access latest --secret=gemini-key" +codegpt config set gemini.api_key_helper_refresh_interval 600 +``` + +#### 工作原理 + +1. **首次執行**:CodeGPT 運行您的 helper 命令並將 API key 緩存到 `~/.config/codegpt/.cache/`,文件權限為 0600(僅所有者可讀寫) +2. **後續執行**:在刷新間隔內,CodeGPT 使用緩存的密鑰 +3. **過期後**:CodeGPT 自動重新運行 helper 命令並更新緩存 +4. **安全性**:緩存文件以僅所有者可讀寫的權限存儲 + +#### 優先順序 + +當配置了多個 API key 來源時,CodeGPT 使用以下優先順序: + +1. `openai.api_key_helper`(如果已配置) +2. `openai.api_key`(靜態配置) +3. `OPENAI_API_KEY` 環境變數 ### 如何自定義默認提示文件夾 diff --git a/cmd/config_list.go b/cmd/config_list.go index 5dde28c..5144e51 100644 --- a/cmd/config_list.go +++ b/cmd/config_list.go @@ -15,32 +15,36 @@ func init() { // availableKeys is a map of configuration keys and their descriptions var availableKeys = map[string]string{ - "git.diff_unified": "Number of context lines in git diff output (default: 3)", - "git.exclude_list": "Files to exclude from git diff command", - "git.template_file": "Path to template file for commit messages", - "git.template_string": "Template string for formatting commit messages", - "openai.socks": "SOCKS proxy URL for API connections", - "openai.api_key": "Authentication key for OpenAI API access", - "openai.model": "AI model identifier to use for requests", - "openai.org_id": "Organization ID for multi-org OpenAI accounts", - "openai.proxy": "HTTP proxy URL for API connections", - "output.lang": "Language for summarization output (default: English)", - "openai.base_url": "Custom base URL for API requests", - "openai.timeout": "Maximum duration to wait for API response", - "openai.max_tokens": "Maximum token limit for generated completions", - "openai.temperature": "Randomness control parameter (0-1): lower values for focused results, higher for creative variety", - "openai.provider": "Service provider selection ('openai' or 'azure')", - "openai.skip_verify": "Option to bypass TLS certificate verification", - "openai.headers": "Additional custom HTTP headers for API requests", - "openai.api_version": "Specific API version to target", - "openai.top_p": "Nucleus sampling parameter: controls diversity by limiting to top percentage of probability mass", - "openai.frequency_penalty": "Parameter to reduce repetition by penalizing tokens based on their frequency", - "openai.presence_penalty": "Parameter to encourage topic diversity by penalizing previously used tokens", - "prompt.folder": "Directory path for custom prompt templates", - "gemini.project_id": "VertexAI project for Gemini provider", - "gemini.location": "VertexAI location for Gemini provider", - "gemini.backend": "Gemini backend (BackendGeminiAPI or BackendVertexAI)", - "gemini.api_key": "API key for Gemini provider", + "git.diff_unified": "Number of context lines in git diff output (default: 3)", + "git.exclude_list": "Files to exclude from git diff command", + "git.template_file": "Path to template file for commit messages", + "git.template_string": "Template string for formatting commit messages", + "openai.socks": "SOCKS proxy URL for API connections", + "openai.api_key": "Authentication key for OpenAI API access", + "openai.api_key_helper": "Shell command to dynamically generate API key", + "openai.api_key_helper_refresh_interval": "Interval in seconds to refresh credentials from apiKeyHelper (default: 900)", + "openai.model": "AI model identifier to use for requests", + "openai.org_id": "Organization ID for multi-org OpenAI accounts", + "openai.proxy": "HTTP proxy URL for API connections", + "output.lang": "Language for summarization output (default: English)", + "openai.base_url": "Custom base URL for API requests", + "openai.timeout": "Maximum duration to wait for API response", + "openai.max_tokens": "Maximum token limit for generated completions", + "openai.temperature": "Randomness control parameter (0-1): lower values for focused results, higher for creative variety", + "openai.provider": "Service provider selection ('openai' or 'azure')", + "openai.skip_verify": "Option to bypass TLS certificate verification", + "openai.headers": "Additional custom HTTP headers for API requests", + "openai.api_version": "Specific API version to target", + "openai.top_p": "Nucleus sampling parameter: controls diversity by limiting to top percentage of probability mass", + "openai.frequency_penalty": "Parameter to reduce repetition by penalizing tokens based on their frequency", + "openai.presence_penalty": "Parameter to encourage topic diversity by penalizing previously used tokens", + "prompt.folder": "Directory path for custom prompt templates", + "gemini.project_id": "VertexAI project for Gemini provider", + "gemini.location": "VertexAI location for Gemini provider", + "gemini.backend": "Gemini backend (BackendGeminiAPI or BackendVertexAI)", + "gemini.api_key": "API key for Gemini provider", + "gemini.api_key_helper": "Shell command to dynamically generate Gemini API key", + "gemini.api_key_helper_refresh_interval": "Interval in seconds to refresh Gemini credentials from apiKeyHelper (default: 900)", } // configListCmd represents the command to list the configuration values. diff --git a/cmd/config_set.go b/cmd/config_set.go index e947c1d..0018475 100644 --- a/cmd/config_set.go +++ b/cmd/config_set.go @@ -13,6 +13,9 @@ func init() { configCmd.AddCommand(configSetCmd) configSetCmd.Flags().StringP("base_url", "b", "", availableKeys["openai.base_url"]) configSetCmd.Flags().StringP("api_key", "k", "", availableKeys["openai.api_key"]) + configSetCmd.Flags().String("api_key_helper", "", availableKeys["openai.api_key_helper"]) + configSetCmd.Flags(). + Int("api_key_helper_refresh_interval", 900, availableKeys["openai.api_key_helper_refresh_interval"]) configSetCmd.Flags().StringP("model", "m", "gpt-4o", availableKeys["openai.model"]) configSetCmd.Flags().StringP("lang", "l", "en", availableKeys["openai.lang"]) configSetCmd.Flags().StringP("org_id", "o", "", availableKeys["openai.org_id"]) @@ -41,10 +44,18 @@ func init() { configSetCmd.Flags(). String("gemini.backend", "BackendGeminiAPI", availableKeys["gemini.backend"]) configSetCmd.Flags().String("gemini.api_key", "", availableKeys["gemini.api_key"]) + configSetCmd.Flags().String("gemini.api_key_helper", "", availableKeys["gemini.api_key_helper"]) + configSetCmd.Flags(). + Int("gemini.api_key_helper_refresh_interval", 900, availableKeys["gemini.api_key_helper_refresh_interval"]) _ = viper.BindPFlag("openai.base_url", configSetCmd.Flags().Lookup("base_url")) _ = viper.BindPFlag("openai.org_id", configSetCmd.Flags().Lookup("org_id")) _ = viper.BindPFlag("openai.api_key", configSetCmd.Flags().Lookup("api_key")) + _ = viper.BindPFlag("openai.api_key_helper", configSetCmd.Flags().Lookup("api_key_helper")) + _ = viper.BindPFlag( + "openai.api_key_helper_refresh_interval", + configSetCmd.Flags().Lookup("api_key_helper_refresh_interval"), + ) _ = viper.BindPFlag("openai.model", configSetCmd.Flags().Lookup("model")) _ = viper.BindPFlag("openai.proxy", configSetCmd.Flags().Lookup("proxy")) _ = viper.BindPFlag("openai.socks", configSetCmd.Flags().Lookup("socks")) @@ -65,6 +76,14 @@ func init() { _ = viper.BindPFlag("gemini.location", configSetCmd.Flags().Lookup("gemini.location")) _ = viper.BindPFlag("gemini.backend", configSetCmd.Flags().Lookup("gemini.backend")) _ = viper.BindPFlag("gemini.api_key", configSetCmd.Flags().Lookup("gemini.api_key")) + _ = viper.BindPFlag( + "gemini.api_key_helper", + configSetCmd.Flags().Lookup("gemini.api_key_helper"), + ) + _ = viper.BindPFlag( + "gemini.api_key_helper_refresh_interval", + configSetCmd.Flags().Lookup("gemini.api_key_helper_refresh_interval"), + ) } // configSetCmd updates the config value. diff --git a/cmd/provider.go b/cmd/provider.go index 80b843a..bb13ae4 100644 --- a/cmd/provider.go +++ b/cmd/provider.go @@ -3,18 +3,43 @@ package cmd import ( "context" "errors" + "time" "github.com/appleboy/CodeGPT/core" "github.com/appleboy/CodeGPT/provider/anthropic" "github.com/appleboy/CodeGPT/provider/gemini" "github.com/appleboy/CodeGPT/provider/openai" + "github.com/appleboy/CodeGPT/util" "github.com/spf13/viper" ) -func NewOpenAI() (*openai.Client, error) { +func NewOpenAI(ctx context.Context) (*openai.Client, error) { + var apiKey string + + // Try to get API key from helper first, fallback to static config + if helper := viper.GetString("openai.api_key_helper"); helper != "" { + var refreshInterval time.Duration + if viper.IsSet("openai.api_key_helper_refresh_interval") { + // User explicitly set a value (could be 0 to disable cache) + refreshInterval = time.Duration( + viper.GetInt("openai.api_key_helper_refresh_interval"), + ) * time.Second + } else { + // Not set, use default + refreshInterval = util.DefaultRefreshInterval + } + key, err := util.GetAPIKeyFromHelperWithCache(ctx, helper, refreshInterval) + if err != nil { + return nil, err + } + apiKey = key + } else { + apiKey = viper.GetString("openai.api_key") + } + return openai.New( - openai.WithToken(viper.GetString("openai.api_key")), + openai.WithToken(apiKey), openai.WithModel(viper.GetString("openai.model")), openai.WithOrgID(viper.GetString("openai.org_id")), openai.WithProxyURL(viper.GetString("openai.proxy")), @@ -35,10 +60,52 @@ func NewOpenAI() (*openai.Client, error) { // NewGemini returns a new Gemini client func NewGemini(ctx context.Context) (*gemini.Client, error) { - apiKey := viper.GetString("gemini.api_key") - if apiKey == "" { - apiKey = viper.GetString("openai.api_key") + var apiKey string + + // Try gemini.api_key_helper first + if helper := viper.GetString("gemini.api_key_helper"); helper != "" { + var refreshInterval time.Duration + if viper.IsSet("gemini.api_key_helper_refresh_interval") { + // User explicitly set a value (could be 0 to disable cache) + refreshInterval = time.Duration( + viper.GetInt("gemini.api_key_helper_refresh_interval"), + ) * time.Second + } else { + // Not set, use default + refreshInterval = util.DefaultRefreshInterval + } + key, err := util.GetAPIKeyFromHelperWithCache(ctx, helper, refreshInterval) + if err != nil { + return nil, err + } + apiKey = key + } else { + // Fallback to static config: gemini.api_key -> openai.api_key + apiKey = viper.GetString("gemini.api_key") + if apiKey == "" { + // Try openai.api_key_helper as fallback + if helper := viper.GetString("openai.api_key_helper"); helper != "" { + var refreshInterval time.Duration + if viper.IsSet("openai.api_key_helper_refresh_interval") { + // User explicitly set a value (could be 0 to disable cache) + refreshInterval = time.Duration( + viper.GetInt("openai.api_key_helper_refresh_interval"), + ) * time.Second + } else { + // Not set, use default + refreshInterval = util.DefaultRefreshInterval + } + key, err := util.GetAPIKeyFromHelperWithCache(ctx, helper, refreshInterval) + if err != nil { + return nil, err + } + apiKey = key + } else { + apiKey = viper.GetString("openai.api_key") + } + } } + return gemini.New( ctx, gemini.WithToken(apiKey), @@ -63,8 +130,31 @@ func NewGemini(ctx context.Context) (*gemini.Client, error) { // - A pointer to an anthropic.Client instance. // - An error if the client could not be created. func NewAnthropic(ctx context.Context) (*anthropic.Client, error) { + var apiKey string + + // Try to get API key from helper first, fallback to static config + if helper := viper.GetString("openai.api_key_helper"); helper != "" { + var refreshInterval time.Duration + if viper.IsSet("openai.api_key_helper_refresh_interval") { + // User explicitly set a value (could be 0 to disable cache) + refreshInterval = time.Duration( + viper.GetInt("openai.api_key_helper_refresh_interval"), + ) * time.Second + } else { + // Not set, use default + refreshInterval = util.DefaultRefreshInterval + } + key, err := util.GetAPIKeyFromHelperWithCache(ctx, helper, refreshInterval) + if err != nil { + return nil, err + } + apiKey = key + } else { + apiKey = viper.GetString("openai.api_key") + } + return anthropic.New( - anthropic.WithAPIKey(viper.GetString("openai.api_key")), + anthropic.WithAPIKey(apiKey), anthropic.WithModel(viper.GetString("openai.model")), anthropic.WithMaxTokens(viper.GetInt("openai.max_tokens")), anthropic.WithTemperature(float32(viper.GetFloat64("openai.temperature"))), @@ -82,7 +172,7 @@ func GetClient(ctx context.Context, p core.Platform) (core.Generative, error) { case core.Gemini: return NewGemini(ctx) case core.OpenAI, core.Azure: - return NewOpenAI() + return NewOpenAI(ctx) case core.Anthropic: return NewAnthropic(ctx) } diff --git a/util/api_key_helper.go b/util/api_key_helper.go new file mode 100644 index 0000000..465709f --- /dev/null +++ b/util/api_key_helper.go @@ -0,0 +1,263 @@ +package util + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" +) + +const ( + // HelperTimeout is the maximum time to wait for the API key helper script to execute + HelperTimeout = 10 * time.Second + // DefaultRefreshInterval is the default interval for refreshing API keys + DefaultRefreshInterval = 900 * time.Second // 15 minutes +) + +// apiKeyCache stores cached API keys with their metadata +type apiKeyCache struct { + APIKey string `json:"apiKey"` + LastFetchTime time.Time `json:"lastFetchTime"` + HelperCmd string `json:"helperCmd"` +} + +// getCacheDir returns the cache directory path +func getCacheDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + cacheDir := filepath.Join(home, ".config", "codegpt", ".cache") + return cacheDir, nil +} + +// getCacheFilePath returns the cache file path for a given helper command +func getCacheFilePath(helperCmd string) (string, error) { + cacheDir, err := getCacheDir() + if err != nil { + return "", err + } + + // Create cache directory if it doesn't exist + if err := os.MkdirAll(cacheDir, 0o700); err != nil { + return "", fmt.Errorf("failed to create cache directory: %w", err) + } + + // Use hash of helper command as filename + hash := sha256.Sum256([]byte(helperCmd)) + filename := hex.EncodeToString(hash[:]) + ".json" + return filepath.Join(cacheDir, filename), nil +} + +// readCache reads the cached API key from file +func readCache(helperCmd string) (*apiKeyCache, error) { + cachePath, err := getCacheFilePath(helperCmd) + if err != nil { + return nil, err + } + + data, err := os.ReadFile(cachePath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil // Cache doesn't exist yet + } + return nil, fmt.Errorf("failed to read cache file: %w", err) + } + + var cache apiKeyCache + if err := json.Unmarshal(data, &cache); err != nil { + return nil, fmt.Errorf("failed to parse cache file: %w", err) + } + + // Verify the helper command matches + if cache.HelperCmd != helperCmd { + return nil, nil // Cache is for a different command + } + + return &cache, nil +} + +// writeCache writes the API key cache to file +func writeCache(helperCmd, apiKey string) error { + cachePath, err := getCacheFilePath(helperCmd) + if err != nil { + return err + } + + cache := apiKeyCache{ + APIKey: apiKey, + LastFetchTime: time.Now(), + HelperCmd: helperCmd, + } + + data, err := json.MarshalIndent(cache, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal cache: %w", err) + } + + // Write with restrictive permissions (only owner can read/write) + if err := os.WriteFile(cachePath, data, 0o600); err != nil { + return fmt.Errorf("failed to write cache file: %w", err) + } + + return nil +} + +// needsRefresh checks if the cached key needs to be refreshed +func needsRefresh(cache *apiKeyCache, refreshInterval time.Duration) bool { + if cache == nil { + return true + } + + // Always refresh if interval is 0 + if refreshInterval == 0 { + return true + } + + // Check if cache is expired + return time.Since(cache.LastFetchTime) >= refreshInterval +} + +// GetAPIKeyFromHelper executes a shell command to dynamically generate an API key. +// The command is executed in /bin/sh with a timeout controlled by the provided context. +// It returns the trimmed output from stdout, or an error if the command fails. +// +// On timeout, it kills the entire process group (shell and all descendants) using +// a two-phase approach: SIGTERM for graceful termination, then SIGKILL if needed. +// +// Security note: The returned API key is sensitive and should not be logged. +func GetAPIKeyFromHelper(ctx context.Context, helperCmd string) (string, error) { + if helperCmd == "" { + return "", fmt.Errorf("api_key_helper command is empty") + } + + // Create context with timeout if not already set + if _, hasDeadline := ctx.Deadline(); !hasDeadline { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, HelperTimeout) + defer cancel() + } + + // Execute command in /bin/sh + cmd := exec.CommandContext(ctx, "/bin/sh", "-c", helperCmd) + + // Create a new process group so we can kill all descendants on timeout + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Start the command + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("api_key_helper start failed: %w", err) + } + + // Wait for command completion in a goroutine + done := make(chan error, 1) + go func() { + // Always Wait to avoid zombie processes + done <- cmd.Wait() + }() + + select { + case err := <-done: + // Command completed normally + if err != nil { + // Don't include stderr in error message as it might contain sensitive info + return "", fmt.Errorf("api_key_helper command failed: %w", err) + } + apiKey := strings.TrimSpace(stdout.String()) + if apiKey == "" { + return "", fmt.Errorf("api_key_helper command returned empty output") + } + return apiKey, nil + + case <-ctx.Done(): + // Timeout or cancellation: terminate the process group gracefully, then forcefully + pgid := cmd.Process.Pid + + // First attempt: send SIGTERM to the entire process group for graceful shutdown + _ = syscall.Kill(-pgid, syscall.SIGTERM) + + // Wait for graceful termination with a grace period + select { + case err := <-done: + if err != nil { + return "", fmt.Errorf("api_key_helper terminated after timeout: %w", err) + } + apiKey := strings.TrimSpace(stdout.String()) + if apiKey == "" { + return "", fmt.Errorf( + "api_key_helper command returned empty output after timeout termination", + ) + } + return apiKey, nil + + case <-time.After(2 * time.Second): + // Grace period expired: send SIGKILL to force termination + _ = syscall.Kill(-pgid, syscall.SIGKILL) + <-done // Wait for cleanup + return "", fmt.Errorf("api_key_helper command timed out after %v", HelperTimeout) + } + } +} + +// GetAPIKeyFromHelperWithCache executes a shell command to dynamically generate an API key, +// with file-based caching support. The API key is cached for the specified refresh interval. +// If refreshInterval is 0, the cache is disabled and the command is executed every time. +// +// The cache is stored in ~/.config/codegpt/.cache/ directory with restrictive permissions (0600). +// +// Parameters: +// - ctx: Context for controlling execution and timeouts +// - helperCmd: The shell command to execute +// - refreshInterval: How long to cache the API key (0 to disable caching) +// +// Returns the API key from cache if still valid, otherwise executes the helper command. +// +// Security note: The returned API key is sensitive and should not be logged. +// Cache files are stored with 0600 permissions but contain the API key in JSON format. +func GetAPIKeyFromHelperWithCache( + ctx context.Context, + helperCmd string, + refreshInterval time.Duration, +) (string, error) { + if helperCmd == "" { + return "", fmt.Errorf("api_key_helper command is empty") + } + + // Try to read from cache + cache, err := readCache(helperCmd) + if err != nil { + // If cache read fails, log but continue to fetch fresh key + // Don't fail the entire operation just because cache is broken + cache = nil + } + + // Check if we need to refresh + if !needsRefresh(cache, refreshInterval) { + return cache.APIKey, nil + } + + // Fetch new API key + apiKey, err := GetAPIKeyFromHelper(ctx, helperCmd) + if err != nil { + return "", err + } + + // Write to cache (ignore errors to not block the operation) + _ = writeCache(helperCmd, apiKey) + + return apiKey, nil +} diff --git a/util/api_key_helper_test.go b/util/api_key_helper_test.go new file mode 100644 index 0000000..5d37654 --- /dev/null +++ b/util/api_key_helper_test.go @@ -0,0 +1,386 @@ +package util + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestGetAPIKeyFromHelper_Success(t *testing.T) { + tests := []struct { + name string + command string + expected string + }{ + { + name: "simple echo command", + command: "echo 'test-api-key'", + expected: "test-api-key", + }, + { + name: "command with whitespace", + command: "echo ' test-key-with-spaces '", + expected: "test-key-with-spaces", + }, + { + name: "command with newlines", + command: "printf 'key-with-newline\\n'", + expected: "key-with-newline", + }, + { + name: "multi-word echo", + command: "echo sk-1234567890abcdef", + expected: "sk-1234567890abcdef", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GetAPIKeyFromHelper(context.Background(), tt.command) + if err != nil { + t.Fatalf("GetAPIKeyFromHelper() error = %v, want nil", err) + } + if result != tt.expected { + t.Errorf("GetAPIKeyFromHelper() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestGetAPIKeyFromHelper_EmptyCommand(t *testing.T) { + _, err := GetAPIKeyFromHelper(context.Background(), "") + if err == nil { + t.Fatal("GetAPIKeyFromHelper() with empty command should return error") + } + if !strings.Contains(err.Error(), "empty") { + t.Errorf("error message should mention empty command, got: %v", err) + } +} + +func TestGetAPIKeyFromHelper_EmptyOutput(t *testing.T) { + tests := []struct { + name string + command string + }{ + { + name: "true command with no output", + command: "true", + }, + { + name: "command outputting only whitespace", + command: "echo ' '", + }, + { + name: "command outputting only newlines", + command: "printf '\\n\\n'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := GetAPIKeyFromHelper(context.Background(), tt.command) + if err == nil { + t.Fatal("GetAPIKeyFromHelper() with empty output should return error") + } + if !strings.Contains(err.Error(), "empty output") { + t.Errorf("error message should mention empty output, got: %v", err) + } + }) + } +} + +func TestGetAPIKeyFromHelper_CommandFailure(t *testing.T) { + tests := []struct { + name string + command string + wantErr string + }{ + { + name: "non-existent command", + command: "nonexistentcommand12345", + wantErr: "failed", + }, + { + name: "command with exit code 1", + command: "exit 1", + wantErr: "failed", + }, + { + name: "command with syntax error", + command: "if then fi", + wantErr: "failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := GetAPIKeyFromHelper(context.Background(), tt.command) + if err == nil { + t.Fatal("GetAPIKeyFromHelper() should return error for failed command") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error should contain %q, got: %v", tt.wantErr, err) + } + }) + } +} + +func TestGetAPIKeyFromHelper_Timeout(t *testing.T) { + // Command that sleeps longer than the timeout + // The process group mechanism will kill both shell and sleep subprocess + command := "sleep 15" + + start := time.Now() + _, err := GetAPIKeyFromHelper(context.Background(), command) + duration := time.Since(start) + + if err == nil { + t.Fatal("GetAPIKeyFromHelper() should return timeout error") + } + // Error message can be either: + // - "terminated after timeout" if SIGTERM succeeded + // - "timed out" if SIGKILL was needed + if !strings.Contains(err.Error(), "timeout") && !strings.Contains(err.Error(), "terminated") { + t.Errorf("error message should mention timeout or termination, got: %v", err) + } + + // Verify it actually timed out around the expected timeout duration + // Allow up to 4 seconds margin (2s grace period + 2s buffer) + if duration < HelperTimeout || duration > HelperTimeout+4*time.Second { + t.Errorf("timeout duration = %v, want around %v", duration, HelperTimeout) + } +} + +func TestGetAPIKeyFromHelper_ComplexCommands(t *testing.T) { + tests := []struct { + name string + command string + expected string + }{ + { + name: "piped commands", + command: "echo 'my-api-key' | tr '[:lower:]' '[:upper:]'", + expected: "MY-API-KEY", + }, + { + name: "command with variables", + command: "KEY=test-123; echo $KEY", + expected: "test-123", + }, + { + name: "command with subshell", + command: "echo $(printf 'nested-key')", + expected: "nested-key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GetAPIKeyFromHelper(context.Background(), tt.command) + if err != nil { + t.Fatalf("GetAPIKeyFromHelper() error = %v, want nil", err) + } + if result != tt.expected { + t.Errorf("GetAPIKeyFromHelper() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestGetAPIKeyFromHelper_SecurityStderr(t *testing.T) { + // Command that outputs to stderr (sensitive info should not be leaked in error) + command := "echo 'secret-data' >&2; exit 1" + + _, err := GetAPIKeyFromHelper(context.Background(), command) + if err == nil { + t.Fatal("GetAPIKeyFromHelper() should return error when command fails") + } + + // The error message should NOT contain the stderr output (security consideration) + if strings.Contains(err.Error(), "secret-data") { + t.Error("error message should not leak stderr content (security issue)") + } +} + +func TestGetAPIKeyFromHelperWithCache_NoCaching(t *testing.T) { + // Test with refreshInterval = 0 (no caching) + command := "echo 'test-key-no-cache'" + + key1, err := GetAPIKeyFromHelperWithCache(context.Background(), command, 0) + if err != nil { + t.Fatalf("GetAPIKeyFromHelperWithCache() error = %v", err) + } + if key1 != "test-key-no-cache" { + t.Errorf("Expected 'test-key-no-cache', got %q", key1) + } + + // Second call should also execute (no caching) + key2, err := GetAPIKeyFromHelperWithCache(context.Background(), command, 0) + if err != nil { + t.Fatalf("GetAPIKeyFromHelperWithCache() error = %v", err) + } + if key2 != "test-key-no-cache" { + t.Errorf("Expected 'test-key-no-cache', got %q", key2) + } +} + +func TestGetAPIKeyFromHelperWithCache_WithCaching(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + + // Override home directory for testing + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", originalHome) + + // Use a counter file to generate different values each time the command runs + counterFile := filepath.Join(tmpDir, "counter.txt") + command := fmt.Sprintf( + "f=%s; echo $(($(cat $f 2>/dev/null || echo 0) + 1)) | tee $f", + counterFile, + ) + + // First call should execute and cache + key1, err := GetAPIKeyFromHelperWithCache(context.Background(), command, 5*time.Second) + if err != nil { + t.Fatalf("GetAPIKeyFromHelperWithCache() error = %v", err) + } + + // Small delay to ensure time difference + time.Sleep(100 * time.Millisecond) + + // Second call should return cached value (same as first) + key2, err := GetAPIKeyFromHelperWithCache(context.Background(), command, 5*time.Second) + if err != nil { + t.Fatalf("GetAPIKeyFromHelperWithCache() error = %v", err) + } + + if key1 != key2 { + t.Errorf("Cache should return same value: key1=%q, key2=%q", key1, key2) + } +} + +func TestGetAPIKeyFromHelperWithCache_CacheExpiration(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + + // Override home directory for testing + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", originalHome) + + // Create a counter file that we'll update manually + counterFile := filepath.Join(tmpDir, "counter2.txt") + command := fmt.Sprintf("cat %s", counterFile) + + // Write initial value + if err := os.WriteFile(counterFile, []byte("value1"), 0o600); err != nil { + t.Fatalf("Failed to write counter file: %v", err) + } + + // First call with short refresh interval + key1, err := GetAPIKeyFromHelperWithCache(context.Background(), command, 500*time.Millisecond) + if err != nil { + t.Fatalf("GetAPIKeyFromHelperWithCache() error = %v", err) + } + + // Update the file with a different value + if err := os.WriteFile(counterFile, []byte("value2"), 0o600); err != nil { + t.Fatalf("Failed to update counter file: %v", err) + } + + // Wait for cache to expire + time.Sleep(600 * time.Millisecond) + + // Second call should fetch fresh value + key2, err := GetAPIKeyFromHelperWithCache(context.Background(), command, 500*time.Millisecond) + if err != nil { + t.Fatalf("GetAPIKeyFromHelperWithCache() error = %v", err) + } + + // Keys should be different (cache expired) + if key1 == key2 { + t.Errorf("Cache should have expired and returned new value: key1=%q, key2=%q", key1, key2) + } + if key1 != "value1" { + t.Errorf("First key should be 'value1', got %q", key1) + } + if key2 != "value2" { + t.Errorf("Second key should be 'value2', got %q", key2) + } +} + +func TestGetAPIKeyFromHelperWithCache_DifferentCommands(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + + // Override home directory for testing + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", originalHome) + + cmd1 := "echo 'key-one'" + cmd2 := "echo 'key-two'" + + // Get keys from different commands + key1, err := GetAPIKeyFromHelperWithCache(context.Background(), cmd1, 5*time.Second) + if err != nil { + t.Fatalf("GetAPIKeyFromHelperWithCache() error = %v", err) + } + + key2, err := GetAPIKeyFromHelperWithCache(context.Background(), cmd2, 5*time.Second) + if err != nil { + t.Fatalf("GetAPIKeyFromHelperWithCache() error = %v", err) + } + + // Keys should be different (different commands) + if key1 == key2 { + t.Errorf("Different commands should return different keys: key1=%q, key2=%q", key1, key2) + } + + if key1 != "key-one" { + t.Errorf("Expected 'key-one', got %q", key1) + } + if key2 != "key-two" { + t.Errorf("Expected 'key-two', got %q", key2) + } +} + +func TestGetAPIKeyFromHelperWithCache_CacheFilePermissions(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + + // Override home directory for testing + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", originalHome) + + command := "echo 'test-permissions'" + + // Execute command to create cache file + _, err := GetAPIKeyFromHelperWithCache(context.Background(), command, 5*time.Second) + if err != nil { + t.Fatalf("GetAPIKeyFromHelperWithCache() error = %v", err) + } + + // Check cache file permissions + cachePath, err := getCacheFilePath(command) + if err != nil { + t.Fatalf("getCacheFilePath() error = %v", err) + } + + info, err := os.Stat(cachePath) + if err != nil { + t.Fatalf("os.Stat() error = %v", err) + } + + // Check that file has restrictive permissions (0600) + perm := info.Mode().Perm() + if perm != 0o600 { + t.Errorf("Cache file should have 0600 permissions, got %o", perm) + } +}