Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 4 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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.
Expand Down
24 changes: 12 additions & 12 deletions docs/mcp-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
```

Expand All @@ -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.
Expand All @@ -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
```

Expand All @@ -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}"}
}
}
}
Expand Down Expand Up @@ -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.
6 changes: 3 additions & 3 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ The examples assume `jevwise` is installed on PATH. For a source build, use `bin
## Decisions

```sh
export TYPESAFE_API_KEY='<your-key>'
export JEV_API_KEY='<your-key>'
jevwise decide --prompt 'Which task should I tackle first?' \
--option 'Fix the bug' --option 'Write documentation'

Expand Down
2 changes: 1 addition & 1 deletion internal/cli/decide_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
}
}
Expand Down
69 changes: 69 additions & 0 deletions internal/cli/env_names_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
19 changes: 15 additions & 4 deletions internal/cli/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
Expand All @@ -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 ""
}
5 changes: 3 additions & 2 deletions internal/cli/mcp_process_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading