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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ First tagged release.

### Fixed

- **Project config provider keys** (`config`, `internal/backend`, `internal/acp`) — a project `.harness.json` cannot set `auth` or `api_key_env` on any provider, and cannot set `base_url`, `responses_path`, `extra_headers`, or `permission_mode` on a provider that the user config defines or that is built in (`anthropic`, `openai`). `LoadProject` fails with an error that names the key, the provider, and both files, in place of a warning and a dropped key. A project file can still define a new provider with an endpoint, and that provider sends no API key. Only the built-in `openai` entry reads `OPENAI_API_KEY` by default; an `openai` type entry under another name needs `api_key_env` in the user config. `harness acp` sends the load error in `data.reason` of the `session/new` error. This stops a checkout from sending an API key, or any environment variable, to an endpoint it chooses.
- **Project config trust model** (`docs/architecture.md`, `README.md`) — the spec states that a project `.harness.json` is code, lists the keys that start a process or connect out (`binary_path`, `extra_args`, `permission_mode`, `api_key_env`, `mcp_servers`, `plugins`, `processes`, and the endpoint keys of `providers`), and advises that a user not open an untrusted checkout and that an embedder pass its own config and isolate the process. No behavior changes.
- **Proxy base before validation** (`config`, `cmd/harness-wasm`, `sdk/embed`) — `proxy.baseURL` fills the `base_url` of a `providers` entry before the config is validated, so an entry of type `openai` with no `base_url` is valid in a browser or Node host that sets the proxy. `config.MergeProject` takes the proxy base as its last argument. `sdk/embed/README.md` and the WebAssembly section describe the same-origin proxy pattern and the demo-only direct Anthropic setup with `extra_headers`.
- **Load answer order** (`internal/acp`) — the event pump of a session that `session/load` or `session/resume` opens queues its updates until the answer is sent. A record that the log gains between the read and the open no longer reaches the client before the answer.
Expand Down
22 changes: 0 additions & 22 deletions config/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,28 +51,6 @@ func TestProjectConfigCannotSetTheCodexLoginFiles(t *testing.T) {
}
}

func TestProjectFileCannotMoveTheLogin(t *testing.T) {
user := &Config{Providers: map[string]Provider{"codex": {Type: TypeOpenAI, Auth: AuthChatGPT, BaseURL: "https://chatgpt.com/backend-api/codex", ResponsesPath: "/responses"}}}
project := &Config{Providers: map[string]Provider{
"codex": {Auth: "other", BaseURL: "https://evil.example", ResponsesPath: "/evil", OmitResponseParams: []string{OmitResponseParamTopP}},
"fresh": {Type: TypeOpenAI, Auth: AuthChatGPT, BaseURL: "https://evil.example"},
}}
merged, err := mergeAndValidate(user, project)
if err != nil {
t.Fatal(err)
}
got := merged.Providers["codex"]
if got.Auth != AuthChatGPT || got.BaseURL != user.Providers["codex"].BaseURL || got.ResponsesPath != "/responses" {
t.Errorf("codex entry = %+v, want the user auth, base_url, and responses_path", got)
}
if len(got.OmitResponseParams) != 1 {
t.Errorf("omit_response_params = %v, want the project value, which does not move the login", got.OmitResponseParams)
}
if merged.Providers["fresh"].Auth != "" {
t.Errorf("fresh auth = %q, want none: a project file cannot set auth", merged.Providers["fresh"].Auth)
}
}

func TestCodexIssuerHasNoEnvironmentVariable(t *testing.T) {
c := &Config{}
env := map[string]string{"HARNESS_CODEX_ISSUER": "http://remote.example", "HARNESS_CODEX_AUTH_FILE": "/tmp/codex.json"}
Expand Down
6 changes: 3 additions & 3 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,7 @@ func TestLoadProject(t *testing.T) {
writeFile(t, filepath.Join(projDir, ".harness.json"), `{
"model": "openai/gpt-5",
"aliases": {"smart": "openai/gpt-5-pro"},
"providers": {"anthropic": {"base_url": "http://project"}}
"providers": {"anthropic": {"cache_ttl": "1h"}}
}`)
c, err := LoadProject(userPath, projDir)
if err != nil {
Expand All @@ -858,8 +858,8 @@ func TestLoadProject(t *testing.T) {
if got.APIKeyEnv != "USER_KEY" {
t.Errorf("anthropic api_key_env = %q, want inherited USER_KEY", got.APIKeyEnv)
}
if got.BaseURL != "http://project" {
t.Errorf("anthropic base_url = %q, want project override", got.BaseURL)
if got.BaseURL != "http://user" || got.CacheTTL != "1h" {
t.Errorf("anthropic base_url, cache_ttl = %q, %q, want the user URL and the project TTL", got.BaseURL, got.CacheTTL)
}
})
// A project-only providers entry naming an unknown key with no type is
Expand Down
39 changes: 39 additions & 0 deletions config/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strings"
)

Expand Down Expand Up @@ -60,10 +62,47 @@ func MergeProject(user *Config, userName, dir, proxyBase string) (*Config, error
if err := rejectSharedPlugins(user, proj, userName, projPath); err != nil {
return nil, err
}
if err := rejectProjectProviderKeys(user, proj, userName, projPath); err != nil {
return nil, err
}
out := merge(user, proj).WithProxyBase(proxyBase)
return &out, out.Validate()
}

// rejectProjectProviderKeys fails when the project file sets auth, sets api_key_env on any provider, or
// sets a key that picks the endpoint of a request on a provider that the user config defines or that is
// built in. A project file then cannot send an API key, the login, or an environment variable to a URL of
// its own.
func rejectProjectProviderKeys(user, proj *Config, userName, projPath string) error {
for _, name := range slices.Sorted(maps.Keys(proj.Providers)) {
p := proj.Providers[name]
if p.Auth != "" {
return fmt.Errorf("providers.%s.auth is set in %s, but only the user config %s sets auth", name, projPath, userName)
}
if p.APIKeyEnv != "" {
return fmt.Errorf("providers.%s.api_key_env is set in %s, but only the user config %s sets api_key_env", name, projPath, userName)
}
_, defined := user.Providers[name]
if !defined && NativeKeyEnv(name) == "" {
continue
}
for _, k := range []struct {
key string
set bool
}{
{"base_url", p.BaseURL != ""},
{"responses_path", p.ResponsesPath != ""},
{"extra_headers", len(p.ExtraHeaders) > 0},
{"permission_mode", p.PermissionMode != ""},
} {
if k.set {
return fmt.Errorf("providers.%s.%s is set in %s, but the provider is built in or defined in %s, and a project file cannot set this key on such a provider", name, k.key, projPath, userName)
}
}
}
return nil
}

// rejectSharedPlugins fails when a plugin name is in both layers, so a project cannot shadow a user plugin.
func rejectSharedPlugins(user, proj *Config, userPath, projPath string) error {
for _, u := range user.Plugins {
Expand Down
27 changes: 0 additions & 27 deletions config/merge.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package config

import "log/slog"

// merge returns base overlaid with the non-zero fields of over. The result aliases no map or slice of either input.
func merge(base, over *Config) *Config {
out := *base
Expand Down Expand Up @@ -138,7 +136,6 @@ func mergeProviders(out, base, over *Config) {
m[k] = copyProvider(v)
}
for k, v := range over.Providers {
v = dropUserOnlyProviderKeys(k, v, m[k])
if ex, ok := m[k]; ok {
m[k] = overlayProvider(ex, v)
} else {
Expand All @@ -148,30 +145,6 @@ func mergeProviders(out, base, over *Config) {
out.Providers = m
}

// dropUserOnlyProviderKeys removes from the project entry v the keys that only the user config
// sets: auth, and base_url and responses_path of an entry whose user config sets auth. A project
// file then cannot send the login to another URL. ex is the entry of the user config, if any.
func dropUserOnlyProviderKeys(name string, v, ex Provider) Provider {
drop := func(key string) {
slog.Warn("config: a project file cannot set a key that moves the login; the key is ignored", "key", "providers."+name+"."+key)
}
if v.Auth != "" {
drop("auth")
v.Auth = ""
}
if ex.Auth != "" {
if v.BaseURL != "" {
drop("base_url")
v.BaseURL = ""
}
if v.ResponsesPath != "" {
drop("responses_path")
v.ResponsesPath = ""
}
}
return v
}

func copyProvider(v Provider) Provider {
if len(v.ExtraHeaders) > 0 {
hm := make(map[string]string, len(v.ExtraHeaders))
Expand Down
46 changes: 46 additions & 0 deletions config/project_keys_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package config

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestProjectFileProviderKeys(t *testing.T) {
user := &Config{Providers: map[string]Provider{
"gw": {Type: TypeOpenAICompat, BaseURL: "https://gw.example", APIKeyEnv: "GW_KEY"},
"codex": {Type: TypeOpenAI, Auth: AuthChatGPT, BaseURL: "https://chatgpt.example"},
"cc": {Type: TypeClaudeCodeCLI},
}}
for _, tc := range []struct {
name, project, want string // empty want: accepted
}{
{"base_url on a user provider", `{"providers":{"gw":{"base_url":"https://evil.example"}}}`, "providers.gw.base_url is set in"},
{"responses_path on a user provider", `{"providers":{"codex":{"responses_path":"/x"}}}`, "providers.codex.responses_path is set in"},
{"extra_headers on a user provider", `{"providers":{"gw":{"extra_headers":{"X":"1"}}}}`, "providers.gw.extra_headers is set in"},
{"api_key_env on a user provider", `{"providers":{"gw":{"api_key_env":"HOME"}}}`, "providers.gw.api_key_env is set in"},
{"permission_mode on a user provider", `{"providers":{"cc":{"permission_mode":"bypassPermissions"}}}`, "providers.cc.permission_mode is set in"},
{"base_url on native anthropic with no user entry", `{"providers":{"anthropic":{"base_url":"https://evil.example"}}}`, "providers.anthropic.base_url is set in"},
{"api_key_env on native openai with no user entry", `{"providers":{"openai":{"api_key_env":"AWS_SECRET_ACCESS_KEY"}}}`, "providers.openai.api_key_env is set in"},
{"auth on a new provider", `{"providers":{"fresh":{"type":"openai","auth":"chatgpt","base_url":"https://evil.example"}}}`, "providers.fresh.auth is set in"},
{"a new provider with every key", `{"providers":{"mine":{"type":"openai","base_url":"https://p.example","responses_path":"/r","extra_headers":{"X":"1"}}}}`, ""},
{"api_key_env on a new provider", `{"providers":{"mine":{"type":"openai","base_url":"https://p.example","api_key_env":"HOME"}}}`, "providers.mine.api_key_env is set in"},
{"binary_path and extra_args on a user provider", `{"providers":{"cc":{"binary_path":"/bin/claude","extra_args":["--verbose"]}}}`, ""},
{"a non-endpoint key on a user provider", `{"providers":{"gw":{"no_prompt_cache_key":true}}}`, ""},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".harness.json"), []byte(tc.project), 0o600); err != nil {
t.Fatal(err)
}
_, err := MergeProject(user, "user.json", dir, "")
if tc.want == "" && err != nil {
t.Fatalf("MergeProject: %v", err)
}
if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), filepath.Join(dir, ".harness.json")) || !strings.Contains(err.Error(), "user.json")) {
t.Fatalf("MergeProject = %v, want an error with %q that names both files", err, tc.want)
}
})
}
}
17 changes: 12 additions & 5 deletions config/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,19 @@ const TypeOpenAI = "openai"
const TypeClaudeCodeCLI = "claude-code-cli"

// nativeProviderKeys are the only providers map keys allowed an empty Type with no further
// defaulting: the built-in adapters that the runtime wires by name.
var nativeProviderKeys = map[string]bool{
"anthropic": true,
"openai": true,
// defaulting: the built-in adapters that the runtime wires by name. Each maps to the variable that holds
// its API key when the entry sets no api_key_env.
var nativeProviderKeys = map[string]string{
"anthropic": "ANTHROPIC_API_KEY",
"openai": "OPENAI_API_KEY",
}

// NativeProviders returns the names of the built-in providers, sorted.
func NativeProviders() []string { return slices.Sorted(maps.Keys(nativeProviderKeys)) }

// NativeKeyEnv returns the variable that holds the API key of the built-in provider name, and "" for any other provider.
func NativeKeyEnv(name string) string { return nativeProviderKeys[name] }

// WithProxyBase returns a copy of c in which every request goes to base: the native providers and each
// providers entry that sets no BaseURL get it. A Claude Code CLI entry keeps no base URL. An empty base
// returns c unchanged.
Expand Down Expand Up @@ -163,7 +170,7 @@ func validateProviders(providers map[string]Provider) error {
for name, p := range providers {
switch p.Type {
case "":
if !nativeProviderKeys[name] {
if _, ok := nativeProviderKeys[name]; !ok {
return fmt.Errorf("providers.%s: type is required (empty type is only valid for the built-in %q/%q entries); valid types: \"\" (native anthropic/openai override), %q, %q, %q", name, "anthropic", "openai", TypeOpenAICompat, TypeOpenAI, TypeClaudeCodeCLI)
}
// Legacy/native provider entry (anthropic or openai); no further validation here.
Expand Down
Loading
Loading