From 6183f07f143b34a318848dffc65e8966710af64d Mon Sep 17 00:00:00 2001 From: benbenbang Date: Sun, 11 Oct 2026 11:09:21 +0800 Subject: [PATCH] feat(auth): add JEV_API_KEY and JEVWISE_MCP_TOKEN credential names Introduce canonical environment variable names for upstream API access and local MCP agent authentication, keeping the previous names as ordered fallbacks so existing setups continue to work. **Credential resolution:** * API key lookup now checks `JEV_API_KEY`, then `JEVWISE_API_KEY`, `TS_JEV_API_KEY` and `TYPESAFE_API_KEY`, skipping blank values. * MCP bearer token lookup prefers `JEVWISE_MCP_TOKEN`, then compatibility aliases `JEVWISE_MCP_AUTH_TOKEN` and `JEV_MCP_TOKEN`; a nonempty invalid higher-priority value fails without falling back. * The missing/invalid token error now points to `JEVWISE_MCP_TOKEN` or `--token-file`, and flag help and the `--token-file` override reference the new name. **Documentation and tests:** * Added an Authentication section to the README and expanded configuration, MCP and usage guides to describe the separate upstream and agent credentials and their fallback order. * Updated agent onboarding and smoke-test fixtures to export `JEVWISE_MCP_TOKEN` and `JEV_API_KEY`, and added env-name coverage tests. --- README.md | 30 +++++++++++ docs/configuration.md | 6 ++- docs/mcp-agents.md | 24 ++++----- docs/mcp.md | 6 +-- docs/usage.md | 2 +- internal/cli/decide_test.go | 2 +- internal/cli/env_names_test.go | 69 ++++++++++++++++++++++++++ internal/cli/mcp.go | 19 +++++-- internal/cli/mcp_process_test.go | 5 +- internal/cli/mcp_test.go | 2 +- internal/cli/root.go | 2 +- internal/cli/token_cli_test.go | 2 +- internal/config/config.go | 6 ++- internal/config/config_test.go | 2 +- internal/config/env_names_test.go | 61 +++++++++++++++++++++++ internal/mcpserver/agent_smoke_test.go | 6 +-- 16 files changed, 211 insertions(+), 33 deletions(-) create mode 100644 internal/cli/env_names_test.go create mode 100644 internal/config/env_names_test.go diff --git a/README.md b/README.md index 1f9040f..69c66ad 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,36 @@ Download a binary and `SHA256SUMS` from [GitHub Releases](https://github.com/bit - Checksums detect corruption, not a compromised publisher. See [release assets and verification](docs/releases.md) for details. - Prefer building from source? Follow the [development guide](docs/development.md#build-from-source). +## Authentication + +Supply credentials through a secured environment or protected files, not secret-valued command-line arguments. These names describe the current checkout. + +| Default environment variable | Purpose | +| --- | --- | +| `JEV_API_KEY` | Upstream Jev API access for decisions and the MCP server's backend | +| `JEVWISE_MCP_TOKEN` | Agent authentication to the local MCP server; not an upstream API key | + +### Upstream API key + +Set `JEV_API_KEY` in your secured environment, or reference **any exported variable** in your selected TOML configuration: + +```toml +api_key = "$ABCD_TOKEN" +# Equivalent: api_key = "${ABCD_TOKEN}" +``` + +- `ABCD_TOKEN` is only an example; custom names such as `JEV_MCP_API_KEY` also work. Export the referenced variable before starting Jevwise. +- Only TOML `api_key` expands environment references. Unset variables expand to empty; Jev service initialization requires a nonblank key. +- Changed `--api-key` flags override nonblank API environment values, which override TOML. Direct environment and flag values stay literal. +- API fallback names, in order: `JEVWISE_API_KEY`, `TS_JEV_API_KEY`, `TYPESAFE_API_KEY`. The standalone Go SDK retains `TYPESAFE_API_KEY` as its environment fallback. See [configuration](docs/configuration.md). + +### Local MCP authentication + +- Use a separate bearer token: **never reuse the upstream API key or give it to the agent**. +- Set `JEVWISE_MCP_TOKEN`, or use `jevwise mcp --token-file /path/to/protected/agent-token`. The protected file overrides environment tokens. +- MCP fallback names, in order: `JEVWISE_MCP_AUTH_TOKEN`, `JEV_MCP_TOKEN`. Invalid nonempty tokens fail without fallback; tokens must be printable ASCII without whitespace. +- Keep secrets out of shell history, logs and stored agent configuration. Follow [local MCP setup](docs/mcp.md) and [agent onboarding](docs/mcp-agents.md) for protected files and client environment references. + ## Documentation | Guide | Contents | diff --git a/docs/configuration.md b/docs/configuration.md index f06416b..04d1dc8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -4,11 +4,11 @@ ## CLI configuration -Precedence: **changed flags > nonblank environment > TOML > defaults**. +Precedence: **changed flags > nonblank environment > TOML > defaults**. These names apply to the CLI; the standalone Go SDK retains its `TYPESAFE_API_KEY` fallback. | Setting | Flag | Environment | Default | | --- | --- | --- | --- | -| API key | `--api-key` | `TS_JEV_API_KEY`, then `TYPESAFE_API_KEY` | Required for Jev | +| API key | `--api-key` | `JEV_API_KEY`; aliases: `JEVWISE_API_KEY`, `TS_JEV_API_KEY`, `TYPESAFE_API_KEY` | Required for Jev | | Base URL | `--base-url` | `TS_JEV_BASE_URL`, then `TYPESAFE_BASE_URL` | `https://api.typesafe.ai` | | Model | `--model` | `TS_JEV_MODEL`, then `TYPESAFE_DEFAULT_MODEL` | `jev-latest` | | Provider | `--provider` | `TS_JEV_PROVIDER` | `jev` | @@ -20,6 +20,8 @@ Precedence: **changed flags > nonblank environment > TOML > defaults**. - Quoted `--config "~/.config/jevwise/jevwise.toml"` paths expand the current user's home directory for init, view, edit and runtime loading. `~otheruser` and shell/environment evaluation of paths are not supported. An XDG path beginning with `~/` is supported too. - Trust the current directory before running decision/MCP commands: local settings can change the upstream destination. Environment/changed flags still win. Invalid, oversized (over 1 MiB), linked or nonregular local files fail without global fallback; help/version do not load them. - Start with [config.example.toml](../config.example.toml). Prefer environment credentials rather than command-line secrets. +- TOML `api_key` supports `$NAME`/`${NAME}`, for example `api_key = "$JEV_API_KEY"` or `api_key = "${JEV_MCP_API_KEY}"`. Runtime loading expands references and trims the result; undefined variables become empty. +- API environment names above are checked left to right, skipping blank values. `JEV_API_KEY` is for the upstream API; `JEVWISE_MCP_TOKEN` is separate local MCP authentication. Never reuse the same credential. - TOML `api_key` supports `$NAME`/`${NAME}`, for example `api_key = "${MY_JEV_KEY}"`. Runtime loading expands references and trims the result; undefined variables become empty. - Custom environment names work through TOML references, for example `api_key = "${JEV_MCP_API_KEY}"`. Export the variable in the process environment before starting Jevwise. Exporting `JEV_MCP_API_KEY` alone is not an automatic API-key override; nonblank `TS_JEV_API_KEY`/`TYPESAFE_API_KEY` still take precedence over the file. - Local discovery never loads `config.toml`, `configs.toml`, YAML files or ancestor configs. Only `.jevwise.toml` is a local candidate; visible `jevwise.toml` is not auto-loaded. Legacy `ts-jev/config.toml` is no longer discovered: migrate manually or select it with `--config`. An explicit `--config PATH` can still select another trusted filename. diff --git a/docs/mcp-agents.md b/docs/mcp-agents.md index 28d6379..4a3880a 100644 --- a/docs/mcp-agents.md +++ b/docs/mcp-agents.md @@ -25,21 +25,21 @@ Foreground mode omits `--background`. Keep its terminal open; Ctrl+C stops it. I ## 2. Load only the agent token in a separate client terminal -The agent needs the agent bearer token, **not** the Jev API key or management key. Use a fresh client terminal without `TYPESAFE_API_KEY` or `TS_JEV_API_KEY`. Do not paste token values into client commands, configuration files or chat. +The agent needs the agent bearer token, **not** the Jev API key or management key. Use a fresh client terminal without upstream credentials (`JEV_API_KEY`, `JEVWISE_API_KEY`, `TS_JEV_API_KEY`, `TYPESAFE_API_KEY`, or any custom API-key variable). Do not paste token values into client commands, configuration files or chat. macOS/Linux Bash or Zsh (remove the supported final LF/CRLF): ```sh -export JEV_MCP_TOKEN_FILE=/absolute/path/to/protected/agent-token -JEV_MCP_TOKEN="$(cat "$JEV_MCP_TOKEN_FILE")" -export JEV_MCP_TOKEN="${JEV_MCP_TOKEN%$'\r'}" +export JEVWISE_MCP_TOKEN_FILE=/absolute/path/to/protected/agent-token +JEVWISE_MCP_TOKEN="$(cat "$JEVWISE_MCP_TOKEN_FILE")" +export JEVWISE_MCP_TOKEN="${JEVWISE_MCP_TOKEN%$'\r'}" ``` Windows PowerShell, using your protected token file: ```powershell -$env:JEV_MCP_TOKEN_FILE = 'C:\private\agent-token' -$env:JEV_MCP_TOKEN = (Get-Content -Raw -LiteralPath $env:JEV_MCP_TOKEN_FILE) -replace '\r?\n$', '' +$env:JEVWISE_MCP_TOKEN_FILE = 'C:\private\agent-token' +$env:JEVWISE_MCP_TOKEN = (Get-Content -Raw -LiteralPath $env:JEVWISE_MCP_TOKEN_FILE) -replace '\r?\n$', '' ``` Clients read the token from their process environment. Launch the client from this terminal; an already-running desktop app may not inherit it. Reopen the client after token changes. If an existing client server is already named `jevwise`, inspect it before adding or replacing configuration. @@ -52,7 +52,7 @@ Register the HTTP endpoint, storing the **environment variable name**, not its v ```sh codex mcp add jevwise --url http://127.0.0.1:8080/mcp \ - --bearer-token-env-var JEV_MCP_TOKEN + --bearer-token-env-var JEVWISE_MCP_TOKEN codex mcp get jevwise ``` @@ -61,7 +61,7 @@ Alternatively, merge this entry into your existing Codex `config.toml`, rather t ```toml [mcp_servers.jevwise] url = "http://127.0.0.1:8080/mcp" -bearer_token_env_var = "JEV_MCP_TOKEN" +bearer_token_env_var = "JEVWISE_MCP_TOKEN" ``` `codex mcp get`/`list` inspect configuration, not a successful live handshake. Start Codex from the token-bearing terminal and use `/mcp` to check that `jevwise` is connected and advertises `decide`. Do not request a decision just to check connectivity. @@ -70,12 +70,12 @@ Configuration location, project trust and available options are described in the ### Claude Code -In the project where you use Claude Code, add a local-scoped HTTP entry. The single quotes below preserve `${JEV_MCP_TOKEN}` as a literal environment reference, keeping the token out of command arguments and stored configuration: +In the project where you use Claude Code, add a local-scoped HTTP entry. The single quotes below preserve `${JEVWISE_MCP_TOKEN}` as a literal environment reference, keeping the token out of command arguments and stored configuration: ```sh # macOS/Linux shell claude mcp add-json --scope local jevwise \ - '{"type":"http","url":"http://127.0.0.1:8080/mcp","headers":{"Authorization":"Bearer ${JEV_MCP_TOKEN}"}}' + '{"type":"http","url":"http://127.0.0.1:8080/mcp","headers":{"Authorization":"Bearer ${JEVWISE_MCP_TOKEN}"}}' claude mcp get jevwise ``` @@ -87,7 +87,7 @@ For a project-scoped setup, merge the following entry into `.mcp.json` without r "jevwise": { "type": "http", "url": "http://127.0.0.1:8080/mcp", - "headers": {"Authorization": "Bearer ${JEV_MCP_TOKEN}"} + "headers": {"Authorization": "Bearer ${JEVWISE_MCP_TOKEN}"} } } } @@ -131,4 +131,4 @@ JEVWISE_MCP_AGENT_SMOKE=1 go test -mod=readonly -run '^TestRealAgentMCPDiscovery Stop with `jevwise mcp stop` using the same runtime directory. Acknowledgement is not a guarantee cleanup has already finished. Stop/restart after executable or credential changes. Use the matching/newer CLI for schema-2 instances. -Remove only the client entry you added: `codex mcp remove jevwise`, or `claude mcp remove jevwise --scope local` for the local-scoped example. For project JSON, remove only that entry. Removing client configuration does not stop Jevwise. Clear `JEV_MCP_TOKEN` from the client terminal when finished. +Remove only the client entry you added: `codex mcp remove jevwise`, or `claude mcp remove jevwise --scope local` for the local-scoped example. For project JSON, remove only that entry. Removing client configuration does not stop Jevwise. Clear `JEVWISE_MCP_TOKEN` from the client terminal when finished. diff --git a/docs/mcp.md b/docs/mcp.md index 1853f8b..53ed807 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -32,9 +32,9 @@ jevwise mcp stop | `jevwise mcp stop` | Authenticates the exact instance and requests shutdown; an acknowledgement is not a promise that cleanup has already finished. | | `--listen` | Literal loopback IP and nonzero port; default `127.0.0.1:8080`. | | `--runtime-dir` | Private background state directory; default `os.UserCacheDir()/jevwise-mcp`. Use the same override for start, status and stop. | -| `--token-file` | Explicit protected agent-token file, overriding `JEV_MCP_TOKEN`; unreadable or unsafe files fail without fallback. | +| `--token-file` | Explicit protected agent-token file, overriding `JEVWISE_MCP_TOKEN`; unreadable or unsafe files fail without fallback. | -- Without `--token-file`, startup reads `JEV_MCP_TOKEN`. Tokens must be nonempty printable ASCII without whitespace; a file may end in one LF or CRLF. No secret-valued token flag exists. +- Without `--token-file`, startup reads `JEVWISE_MCP_TOKEN`, then compatibility aliases `JEVWISE_MCP_AUTH_TOKEN` and `JEV_MCP_TOKEN`, skipping only empty values. An invalid nonempty higher-priority value fails without fallback. Tokens must be nonempty printable ASCII without whitespace; a file may end in one LF or CRLF. No secret-valued token flag exists. - Keep credentials out of command arguments and shell history. Use protected files or appropriately secured environment configuration instead of `--api-key`. - `status` and `stop` do not load decision configuration, API keys, the decision service or the agent token file. They use the private runtime management key, with no upstream API request. - Background mode does not install a login item, reboot service or automatic restart. On Windows, a restrictive parent Job Object can prevent detachment or terminate the child when its parent exits. @@ -61,7 +61,7 @@ Connect an HTTP-capable local agent to `http://127.0.0.1:8080/mcp`, supplying `A | Credential | Purpose | Where it belongs | | --- | --- | --- | | Upstream API key | Server-to-Jev decisions | Existing decision configuration; never give it to the agent. | -| Agent bearer token | Agent-to-local `/mcp` access | `JEV_MCP_TOKEN` or an explicit protected token file. | +| Agent bearer token | Agent-to-local `/mcp` access | `JEVWISE_MCP_TOKEN` or an explicit protected token file. | | Management key | Status/stop and instance verification | Separate private runtime file; never give it to the agent or transmit it as a bearer token. | - Background bootstrap passes secrets through owned anonymous pipes, not child arguments, public state or logs. State contains bounded instance metadata, never credentials; the post-v1.1.0 checkout also records an inspection-only PID. Agent and API credentials must differ; the management key is separately generated. diff --git a/docs/usage.md b/docs/usage.md index a48b84c..8bdd4ec 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -7,7 +7,7 @@ The examples assume `jevwise` is installed on PATH. For a source build, use `bin ## Decisions ```sh -export TYPESAFE_API_KEY='' +export JEV_API_KEY='' jevwise decide --prompt 'Which task should I tackle first?' \ --option 'Fix the bug' --option 'Write documentation' diff --git a/internal/cli/decide_test.go b/internal/cli/decide_test.go index 055f975..49bf868 100644 --- a/internal/cli/decide_test.go +++ b/internal/cli/decide_test.go @@ -26,7 +26,7 @@ func (f decideFunc) Decide(ctx context.Context, req service.Request) (service.Re func decisionEnvironment(t *testing.T) { t.Helper() t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - for _, key := range []string{"TS_JEV_API_KEY", "TS_JEV_MODEL", "TS_JEV_PROVIDER", "TS_JEV_BASE_URL", "TS_JEV_TIMEOUT", "TYPESAFE_API_KEY", "TYPESAFE_DEFAULT_MODEL", "TYPESAFE_BASE_URL"} { + for _, key := range []string{"JEV_API_KEY", "JEVWISE_API_KEY", "JEVWISE_MCP_TOKEN", "JEVWISE_MCP_AUTH_TOKEN", "JEV_MCP_TOKEN", "TS_JEV_API_KEY", "TS_JEV_MODEL", "TS_JEV_PROVIDER", "TS_JEV_BASE_URL", "TS_JEV_TIMEOUT", "TYPESAFE_API_KEY", "TYPESAFE_DEFAULT_MODEL", "TYPESAFE_BASE_URL"} { t.Setenv(key, "") } } diff --git a/internal/cli/env_names_test.go b/internal/cli/env_names_test.go new file mode 100644 index 0000000..df57771 --- /dev/null +++ b/internal/cli/env_names_test.go @@ -0,0 +1,69 @@ +package cli + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/bitbrew-dev/jevwise/internal/config" + "github.com/bitbrew-dev/jevwise/internal/mcpserver" + "github.com/bitbrew-dev/jevwise/internal/service" +) + +func TestMCPEnvironmentNamesPrecedence(t *testing.T) { + decisionEnvironment(t) + t.Setenv("JEV_API_KEY", "upstream-fixture") + names := []string{"JEVWISE_MCP_TOKEN", "JEVWISE_MCP_AUTH_TOKEN", "JEV_MCP_TOKEN"} + for i, name := range names { + t.Setenv(name, name+"-fixture") + for _, earlier := range names[:i] { + t.Setenv(earlier, "") + } + for _, later := range names[i+1:] { + t.Setenv(later, "lower-priority-fixture") + } + factory := func(cfg config.Config) (service.DecisionService, func(), error) { + if cfg.APIKey != "upstream-fixture" { + t.Error("API key did not resolve independently") + } + return mcpFixtureService(t), nil, nil + } + ran := false + run := func(_ context.Context, _ service.DecisionService, _ time.Duration, options mcpserver.RunOptions) error { + ran = true + if options.Token != name+"-fixture" { + t.Error("wrong MCP token precedence") + } + return nil + } + out, stderr, err := execute(mcpRoot(factory, run), "mcp") + if err != nil || out != "" || stderr != "" || !ran { + t.Fatal("MCP environment selection failed", name, err) + } + } +} + +func TestMCPInvalidPreferredTokenNeverFallsBack(t *testing.T) { + decisionEnvironment(t) + t.Setenv("JEV_MCP_TOKEN", "valid-legacy-fixture") + factory := func(config.Config) (service.DecisionService, func(), error) { + t.Fatal("invalid or reused token initialized service") + return nil, nil, nil + } + for _, name := range []string{"JEVWISE_MCP_TOKEN", "JEVWISE_MCP_AUTH_TOKEN"} { + for _, token := range []string{" ", "private invalid", "private\nvalue", "private-秘密"} { + t.Setenv(name, token) + out, stderr, err := execute(NewRootWithFactory(factory), "mcp") + if err == nil || !strings.Contains(err.Error(), "JEVWISE_MCP_TOKEN") || strings.Contains(err.Error(), "private") || out != "" || stderr != "" { + t.Fatal("invalid preferred token fell back or leaked", err) + } + } + t.Setenv(name, "") + } + t.Setenv("JEVWISE_MCP_TOKEN", "same-private-fixture") + t.Setenv("JEV_API_KEY", "same-private-fixture") + if _, _, err := execute(NewRootWithFactory(factory), "mcp"); err == nil || !strings.Contains(err.Error(), "must differ") || strings.Contains(err.Error(), "private") { + t.Fatal("canonical credentials were allowed to match or leaked", err) + } +} diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 2fe619a..b1bfe5e 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -40,7 +40,7 @@ func newMCPWithBackground(factory ServiceFactory, run mcpRunner, start mcpStarte if err != nil { return err } - token := os.Getenv("JEV_MCP_TOKEN") + token := mcpEnvironmentToken() if cmd.Flags().Changed("token-file") { path, err := filepath.Abs(tokenFile) if err != nil || tokenFile == "" { @@ -52,7 +52,7 @@ func newMCPWithBackground(factory ServiceFactory, run mcpRunner, start mcpStarte } } if err := mcpserver.ValidateToken(token); err != nil { - return errors.New("JEV_MCP_TOKEN must contain a valid MCP bearer token") + return errors.New("MCP bearer token is missing or invalid: set JEVWISE_MCP_TOKEN or use --token-file") } cfg, err := LoadConfig(cmd) if err != nil { @@ -98,9 +98,9 @@ func newMCPWithBackground(factory ServiceFactory, run mcpRunner, start mcpStarte } return nil }} - cmd.Flags().StringVar(&address, "listen", "127.0.0.1:8080", "Literal loopback IP:port for /mcp; token from JEV_MCP_TOKEN") + cmd.Flags().StringVar(&address, "listen", "127.0.0.1:8080", "Literal loopback IP:port for /mcp; token from JEVWISE_MCP_TOKEN") cmd.Flags().BoolVar(&background, "background", false, "Start an independent MCP background process") - cmd.PersistentFlags().StringVar(&tokenFile, "token-file", "", "Read an owner-private bearer token file instead of JEV_MCP_TOKEN") + cmd.PersistentFlags().StringVar(&tokenFile, "token-file", "", "Read an owner-private bearer token file instead of JEVWISE_MCP_TOKEN") cache, _ := os.UserCacheDir() if cache != "" { runtimeDir = filepath.Join(cache, "jevwise-mcp") @@ -109,3 +109,14 @@ func newMCPWithBackground(factory ServiceFactory, run mcpRunner, start mcpStarte cmd.AddCommand(newMCPManagement("status", &runtimeDir), newMCPManagement("stop", &runtimeDir)) return cmd } + +// Canonical token wins over compatibility aliases. A nonempty invalid token +// must fail validation, not silently authenticate using a lower-priority value. +func mcpEnvironmentToken() string { + for _, name := range []string{"JEVWISE_MCP_TOKEN", "JEVWISE_MCP_AUTH_TOKEN", "JEV_MCP_TOKEN"} { + if token := os.Getenv(name); token != "" { + return token + } + } + return "" +} diff --git a/internal/cli/mcp_process_test.go b/internal/cli/mcp_process_test.go index ed0c27f..916fd05 100644 --- a/internal/cli/mcp_process_test.go +++ b/internal/cli/mcp_process_test.go @@ -242,8 +242,9 @@ func TestMCPActualBackgroundCLIStartStatusDuplicateAndStop(t *testing.T) { fixture := childBootstrap(t) marker := filepath.Join(t.TempDir(), "background-cli-exited") processEnvironment(t, marker) - t.Setenv("JEV_MCP_TOKEN", fixture.AgentToken) - t.Setenv("TS_JEV_API_KEY", fixture.Config.APIKey) + decisionEnvironment(t) + t.Setenv("JEVWISE_MCP_TOKEN", fixture.AgentToken) + t.Setenv("JEV_API_KEY", fixture.Config.APIKey) configPath := filepath.Join(t.TempDir(), "empty.toml") if err := os.WriteFile(configPath, nil, 0600); err != nil { t.Fatal(err) diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go index bdd367b..7ea3a01 100644 --- a/internal/cli/mcp_test.go +++ b/internal/cli/mcp_test.go @@ -47,7 +47,7 @@ func TestMCPHelpIsLazyAndRegistered(t *testing.T) { } for _, args := range [][]string{{"mcp", "--help", "--config", "/nonexistent"}, {"help", "mcp"}} { out, stderr, err := execute(NewRootWithFactory(factory), args...) - if err != nil || stderr != "" || !strings.Contains(out, "--listen") || !strings.Contains(out, "JEV_MCP_TOKEN") { + if err != nil || stderr != "" || !strings.Contains(out, "--listen") || !strings.Contains(out, "JEVWISE_MCP_TOKEN") { t.Fatal("MCP help failed", err) } } diff --git a/internal/cli/root.go b/internal/cli/root.go index 286167b..79cf476 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -53,7 +53,7 @@ func NewRootWithFactory(factory ServiceFactory) *cobra.Command { flags.Bool("verbose", false, "Alias for --debug: write safe structured debug logs to stderr") flags.String("config", "", "Path to TOML configuration") flags.String("provider", "jev", "Decision provider: jev, codex or claude") - flags.String("api-key", "", "Jev API key (prefer environment or config)") + flags.String("api-key", "", "Jev API key (prefer JEV_API_KEY or config)") flags.String("base-url", "https://api.typesafe.ai", "Jev API base URL") flags.String("model", "jev-latest", "Jev model") flags.String("timeout", "10s", "Operation timeout, for example 10s") diff --git a/internal/cli/token_cli_test.go b/internal/cli/token_cli_test.go index c37dcaa..9e83909 100644 --- a/internal/cli/token_cli_test.go +++ b/internal/cli/token_cli_test.go @@ -31,7 +31,7 @@ func cliTokenFile(t *testing.T, token string) string { func TestMCPTokenFileOverridesEnvironmentAndAcceptsRelativePath(t *testing.T) { decisionEnvironment(t) - t.Setenv("JEV_MCP_TOKEN", "invalid environment token") + t.Setenv("JEVWISE_MCP_TOKEN", "invalid environment token") path := cliTokenFile(t, "private-file-token\r\n") // Keep relative-path coverage on the token file volume on Windows. t.Chdir(filepath.Dir(path)) diff --git a/internal/config/config.go b/internal/config/config.go index 685d8f4..5edc053 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -47,7 +47,11 @@ func Load(path string, flags *pflag.FlagSet) (Config, error) { upstream := map[string]string{"api_key": "TYPESAFE_API_KEY", "base_url": "TYPESAFE_BASE_URL", "model": "TYPESAFE_DEFAULT_MODEL"} env := make(map[string]any) for key := range defaults { - for _, name := range []string{"TS_JEV_" + strings.ToUpper(key), upstream[key]} { + names := []string{"TS_JEV_" + strings.ToUpper(key), upstream[key]} + if key == "api_key" { + names = []string{"JEV_API_KEY", "JEVWISE_API_KEY", "TS_JEV_API_KEY", "TYPESAFE_API_KEY"} + } + for _, name := range names { if value := strings.TrimSpace(os.Getenv(name)); value != "" { env[key] = value break diff --git a/internal/config/config_test.go b/internal/config/config_test.go index df31795..4d026df 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -15,7 +15,7 @@ import ( func cleanEnv(t *testing.T) { t.Helper() t.Chdir(t.TempDir()) - for _, name := range []string{"TS_JEV_API_KEY", "TS_JEV_BASE_URL", "TS_JEV_MODEL", "TS_JEV_PROVIDER", "TS_JEV_TIMEOUT", "TYPESAFE_API_KEY", "TYPESAFE_BASE_URL", "TYPESAFE_DEFAULT_MODEL"} { + for _, name := range []string{"JEV_API_KEY", "JEVWISE_API_KEY", "TS_JEV_API_KEY", "TS_JEV_BASE_URL", "TS_JEV_MODEL", "TS_JEV_PROVIDER", "TS_JEV_TIMEOUT", "TYPESAFE_API_KEY", "TYPESAFE_BASE_URL", "TYPESAFE_DEFAULT_MODEL"} { t.Setenv(name, "") } t.Setenv("XDG_CONFIG_HOME", t.TempDir()) diff --git a/internal/config/env_names_test.go b/internal/config/env_names_test.go new file mode 100644 index 0000000..4d210dd --- /dev/null +++ b/internal/config/env_names_test.go @@ -0,0 +1,61 @@ +package config + +import ( + "path/filepath" + "testing" + + "github.com/spf13/pflag" +) + +func TestAPIEnvironmentNamesPrecedence(t *testing.T) { + cleanEnv(t) + path := writeConfig(t, filepath.Join(t.TempDir(), "config.toml"), `api_key = "file-key"`) + names := []string{"JEV_API_KEY", "JEVWISE_API_KEY", "TS_JEV_API_KEY", "TYPESAFE_API_KEY"} + for i, name := range names { + t.Setenv(name, name+"-fixture") + for _, earlier := range names[:i] { + t.Setenv(earlier, " \t ") + } + for _, later := range names[i+1:] { + t.Setenv(later, "lower-priority-fixture") + } + cfg, err := Load(path, nil) + if err != nil || cfg.APIKey != name+"-fixture" { + t.Fatal("wrong API environment precedence", name, err) + } + } + t.Setenv("JEV_API_KEY", "$JEVWISE_TEST_LITERAL") + t.Setenv("JEVWISE_TEST_LITERAL", "must-not-expand") + cfg, err := Load(path, nil) + if err != nil || cfg.APIKey != "$JEVWISE_TEST_LITERAL" { + t.Fatal("canonical environment key was expanded", err) + } + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + flags.String("api-key", "", "") + for _, value := range []string{"flag-fixture", ""} { + if err := flags.Set("api-key", value); err != nil { + t.Fatal(err) + } + cfg, err := Load(path, flags) + if err != nil || cfg.APIKey != value { + t.Fatal("changed API flag did not win", err) + } + } +} + +func TestCustomTOMLAPIReferenceNotMCPAuthentication(t *testing.T) { + cleanEnv(t) + t.Setenv("JEV_MCP_API_KEY", "upstream-fixture") + t.Setenv("JEVWISE_MCP_TOKEN", "agent-fixture") + for _, reference := range []string{"$JEV_MCP_API_KEY", "${JEV_MCP_API_KEY}"} { + path := writeConfig(t, filepath.Join(t.TempDir(), "config.toml"), "api_key = '"+reference+"'") + cfg, err := Load(path, nil) + if err != nil || cfg.APIKey != "upstream-fixture" { + t.Fatal("custom TOML API key did not expand", err) + } + } + cfg, err := Load("", nil) + if err != nil || cfg.APIKey != "" { + t.Fatal("custom or MCP variable became an API override", err) + } +} diff --git a/internal/mcpserver/agent_smoke_test.go b/internal/mcpserver/agent_smoke_test.go index 8b7389f..fc713f7 100644 --- a/internal/mcpserver/agent_smoke_test.go +++ b/internal/mcpserver/agent_smoke_test.go @@ -105,7 +105,7 @@ func TestRealAgentMCPDiscovery(t *testing.T) { "USERPROFILE=" + home, "CODEX_HOME=" + filepath.Join(home, "codex"), "CLAUDE_CONFIG_DIR=" + filepath.Join(home, "claude"), "XDG_CONFIG_HOME=" + filepath.Join(home, "config"), "XDG_CACHE_HOME=" + filepath.Join(home, "cache"), "XDG_DATA_HOME=" + filepath.Join(home, "data"), "TMPDIR=" + filepath.Join(home, "tmp"), "TEMP=" + filepath.Join(home, "tmp"), - "JEV_MCP_TOKEN=" + token, "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", "DISABLE_AUTOUPDATER=1", + "JEVWISE_MCP_TOKEN=" + token, "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", "DISABLE_AUTOUPDATER=1", "DISABLE_TELEMETRY=1", "DISABLE_ERROR_REPORTING=1", "NO_COLOR=1"} command := func(args ...string) *exec.Cmd { cmd := exec.CommandContext(ctx, binary, args...) @@ -126,7 +126,7 @@ wire_api = "responses" requires_openai_auth = false [mcp_servers.jevwise] url = %q -bearer_token_env_var = "JEV_MCP_TOKEN" +bearer_token_env_var = "JEVWISE_MCP_TOKEN" startup_timeout_sec = 10 `, upstream.URL, endpoint) if os.WriteFile(filepath.Join(home, "codex", "config.toml"), []byte(configuration), 0600) != nil { @@ -134,7 +134,7 @@ startup_timeout_sec = 10 } smokeCodex(t, ctx, command("app-server", "--listen", "stdio://")) } else { - configuration := fmt.Sprintf(`{"type":"http","url":%q,"headers":{"Authorization":"Bearer ${JEV_MCP_TOKEN}"}}`, endpoint) + configuration := fmt.Sprintf(`{"type":"http","url":%q,"headers":{"Authorization":"Bearer ${JEVWISE_MCP_TOKEN}"}}`, endpoint) for _, args := range [][]string{{"--bare", "mcp", "add-json", "--scope", "local", "jevwise", configuration}, {"--bare", "mcp", "get", "jevwise"}, {"--bare", "mcp", "list"}} { var output smokeOutput