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
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,11 @@ Harness provider 'codeaf' needs a model key and none is configured. Set OPENROUT
The check honors per-call environment overrides, compatible `OPENAI_API_KEY`
credentials (an `sk-or-` key or a custom base URL), saved profile `api_key` or
`model_sources`, and connected-provider credential files. Unreadable/malformed
profiles permit execution conservatively. Refusal warns once per process; an
Agent with an explicit harness config resolving to CodeAF also warns once at
construction without failing. Legacy aforge pins are unchanged.
profiles permit execution conservatively. The warning is logged once per process —
at construction for an Agent with a CodeAF harness config and no key (counting
the config's `env`), otherwise at the first refused call. Construction never
fails; every refused call still fails with the not-configured error. Legacy
aforge pins are unchanged.

**Harness availability gate** — DO NOT use unless ALL true:

Expand Down
7 changes: 4 additions & 3 deletions docs/harness-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ Harness provider 'codeaf' needs a model key and none is configured. Set OPENROUT
The error remains catchable as the SDK's unavailable-provider error (Python and
TypeScript expose `HarnessProviderNotConfigured`; Go uses
`ProviderUnavailableError` with `NotConfigured: true`). It identifies `codeaf`
and lists `OPENROUTER_API_KEY` as missing. Refused calls log a warning once per
process. An Agent with an explicit harness config resolving to CodeAF also warns
once at construction; construction continues normally.
and lists `OPENROUTER_API_KEY` as missing. The warning is logged once per process —
at construction for an Agent with a CodeAF harness config and no key (counting
the config's `env`), otherwise at the first refused call. Construction continues
normally; every refused call still fails with the not-configured error.

The check uses the effective child environment, including per-call `env`
overrides and the SDK's default `CODEAF_HOME`. It accepts `OPENROUTER_API_KEY`,
Expand Down
2 changes: 1 addition & 1 deletion sdk/codeaf-auth-contract.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 2 additions & 5 deletions sdk/go/agent/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,17 @@ package agent

import (
"context"
"sync"

"github.com/Agent-Field/agentfield/sdk/go/harness"
)

var codeAFConfigWarning sync.Once

func warnCodeAFConfiguration(cfg Config) {
if cfg.HarnessConfig == nil {
return
}
provider, _ := harness.BuildProvider(cfg.HarnessConfig.Provider, cfg.HarnessConfig.BinPath)
if _, ok := provider.(*harness.CodeAFProvider); ok && !harness.CodeAFHasCredentials(harness.Options{}) {
codeAFConfigWarning.Do(func() { cfg.Logger.Print("WARNING " + harness.CodeAFMissingKeyWarning) })
if _, ok := provider.(*harness.CodeAFProvider); ok && !harness.CodeAFHasCredentials(harness.Options{Env: cfg.HarnessConfig.Env}) {
harness.WarnCodeAFMissingKeyOnce(func(message string) { cfg.Logger.Print("WARNING " + message) })
}
}

Expand Down
218 changes: 200 additions & 18 deletions sdk/go/agent/harness_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,93 @@ package agent

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"testing"

"github.com/Agent-Field/agentfield/sdk/go/harness"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCodeAFK6ConstructionWarning(t *testing.T) {
type codeAFAgentAuthContract struct {
Warning string `json:"warning"`
Refusal string `json:"refusal"`
Cases []struct {
Name string `json:"name"`
Allow bool `json:"allow"`
Env map[string]string `json:"env"`
Overlay map[string]string `json:"overlay"`
Files map[string]*string `json:"files"`
} `json:"cases"`
}

func readCodeAFAgentAuthContract(t *testing.T) codeAFAgentAuthContract {
t.Helper()
data, err := os.ReadFile("../../codeaf-auth-contract.json")
require.NoError(t, err)
var contract struct {
Warning string `json:"warning"`
}
var contract codeAFAgentAuthContract
require.NoError(t, json.Unmarshal(data, &contract))
return contract
}

// A fresh process gives each Agent test an unspent harness warning budget
// without exporting a production API solely to reset sync.Once in tests.
func runCodeAFAuthInFreshProcess(t *testing.T) bool {
t.Helper()
if os.Getenv("AGENTFIELD_TEST_CODEAF_AUTH_PROCESS") == t.Name() {
return false
}
command := exec.Command(os.Args[0], "-test.run=^"+regexp.QuoteMeta(t.Name())+"$", "-test.count=1")
command.Env = append(os.Environ(), "AGENTFIELD_TEST_CODEAF_AUTH_PROCESS="+t.Name())
output, err := command.CombinedOutput()
require.NoError(t, err, "%s", output)
return true
}

type codeAFAgentNoDownload struct{ t *testing.T }

func (transport codeAFAgentNoDownload) RoundTrip(*http.Request) (*http.Response, error) {
transport.t.Error("download attempted")
return nil, errors.New("downloads forbidden by CodeAF auth tests")
}

func isolateCodeAFAgentAuth(t *testing.T) (string, *bytes.Buffer) {
t.Helper()
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
if strings.HasPrefix(key, "CODEAF_") || strings.HasPrefix(key, "AFORGE_") || strings.HasPrefix(key, "AGENTFIELD_") || key == "OPENROUTER_API_KEY" || key == "OPENAI_API_KEY" {
t.Setenv(key, "")
require.NoError(t, os.Unsetenv(key))
}
}
directory := t.TempDir()
t.Setenv("HOME", filepath.Join(directory, "home"))
t.Setenv("PATH", directory)
t.Setenv("AGENTFIELD_HOME", filepath.Join(directory, "agentfield"))
t.Setenv("CODEAF_TELEMETRY", "off")
var output bytes.Buffer
previousWriter, previousTransport := log.Writer(), http.DefaultTransport
log.SetOutput(&output)
http.DefaultTransport = codeAFAgentNoDownload{t}
t.Cleanup(func() {
log.SetOutput(previousWriter)
http.DefaultTransport = previousTransport
})
return directory, &output
}

func TestCodeAFK6ConstructionWarning(t *testing.T) {
contract := readCodeAFAgentAuthContract(t)
for _, tc := range []struct {
name, provider, envProvider, key string
configured, legacy, warn bool
Expand All @@ -36,25 +104,16 @@ func TestCodeAFK6ConstructionWarning(t *testing.T) {
{name: "env-other", envProvider: "claude-code", configured: true},
} {
t.Run(tc.name, func(t *testing.T) {
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
if strings.HasPrefix(key, "CODEAF_") || strings.HasPrefix(key, "AFORGE_") || strings.HasPrefix(key, "AGENTFIELD_") || key == "OPENROUTER_API_KEY" || key == "OPENAI_API_KEY" {
t.Setenv(key, "")
require.NoError(t, os.Unsetenv(key))
}
if runCodeAFAuthInFreshProcess(t) {
return
}
directory := t.TempDir()
t.Setenv("HOME", filepath.Join(directory, "home"))
t.Setenv("AGENTFIELD_HOME", filepath.Join(directory, "agentfield"))
t.Setenv("CODEAF_TELEMETRY", "off")
_, output := isolateCodeAFAgentAuth(t)
t.Setenv("OPENROUTER_API_KEY", tc.key)
t.Setenv("AGENTFIELD_HARNESS_PROVIDER", tc.envProvider)
if tc.legacy {
t.Setenv("AFORGE_BIN", "/fake/legacy")
}
codeAFConfigWarning = sync.Once{}
var output bytes.Buffer
cfg := Config{NodeID: "auth-test", Version: "1", Logger: log.New(&output, "", 0)}
cfg := Config{NodeID: "auth-test", Version: "1", Logger: log.New(output, "", 0)}
if tc.configured {
cfg.HarnessConfig = &HarnessConfig{Provider: tc.provider}
}
Expand All @@ -70,3 +129,126 @@ func TestCodeAFK6ConstructionWarning(t *testing.T) {
})
}
}

func codeAFAgentFakeBinary(t *testing.T, directory string) (string, string) {
t.Helper()
binary, marker := filepath.Join(directory, "fake-codeaf"), filepath.Join(directory, "ran")
require.NoError(t, os.WriteFile(binary, []byte(fmt.Sprintf("#!/bin/sh\n/bin/cat >/dev/null\nprintf ran > '%s'\nprintf '%%s\\n' '{\"ok\":true,\"stop\":\"done\",\"answer\":\"answer\"}'\n", marker)), 0o755))
return binary, marker
}

func TestCodeAFW1SharedProcessWarningBudget(t *testing.T) {
contract := readCodeAFAgentAuthContract(t)
for _, tc := range []struct {
name string
constructions, calls int
configured bool
key string
}{
{name: "construction-then-calls", constructions: 1, calls: 2, configured: true},
{name: "two-agents", constructions: 2, calls: 1, configured: true},
{name: "first-refusal", constructions: 1, calls: 2},
{name: "call-clears-key", constructions: 1, calls: 1, configured: true, key: "key"},
} {
t.Run(tc.name, func(t *testing.T) {
if runCodeAFAuthInFreshProcess(t) {
return
}
directory, output := isolateCodeAFAgentAuth(t)
t.Setenv("OPENROUTER_API_KEY", tc.key)
binary, marker := codeAFAgentFakeBinary(t, directory)
cfg := Config{NodeID: "auth-test", Version: "1", Logger: log.New(output, "", 0)}
if tc.configured {
cfg.HarnessConfig = &HarnessConfig{Provider: harness.ProviderCodeAF, BinPath: binary}
}
var agent *Agent
for i := 0; i < tc.constructions; i++ {
var err error
agent, err = New(cfg)
require.NoError(t, err)
}
expected := 0
if tc.configured && tc.key == "" {
expected = 1
}
assert.Equal(t, expected, strings.Count(output.String(), contract.Warning))
for call := 0; call < tc.calls; call++ {
_, err := agent.Harness(context.Background(), "task", nil, nil, harness.Options{Env: map[string]string{"OPENROUTER_API_KEY": ""}})
var unavailable *harness.ProviderUnavailableError
require.ErrorAs(t, err, &unavailable)
assert.ErrorIs(t, err, harness.ErrProviderUnavailable)
assert.True(t, unavailable.NotConfigured)
assert.Equal(t, contract.Refusal, err.Error())
assert.Equal(t, harness.ProviderCodeAF, unavailable.Provider)
assert.Equal(t, []string{"OPENROUTER_API_KEY"}, unavailable.MissingAuthEnv)
assert.Equal(t, "af codeaf ensure", unavailable.InstallCommand)
assert.Equal(t, 1, strings.Count(output.String(), contract.Warning))
}
cfg.HarnessConfig = &HarnessConfig{Env: map[string]string{"OPENROUTER_API_KEY": ""}}
_, err := New(cfg)
require.NoError(t, err)
assert.Equal(t, 1, strings.Count(output.String(), contract.Warning))
assert.NoFileExists(t, marker)
})
}
}

func TestCodeAFW3ConstructionUsesConfigEnvironment(t *testing.T) {
contract := readCodeAFAgentAuthContract(t)
for _, tc := range contract.Cases {
t.Run(tc.Name, func(t *testing.T) {
if runCodeAFAuthInFreshProcess(t) {
return
}
directory, output := isolateCodeAFAgentAuth(t)
expand := strings.NewReplacer("$STATE", filepath.Join(directory, "agentfield", "codeaf"), "$PROFILE", filepath.Join(directory, "profile"), "$HOME", filepath.Join(directory, "home")).Replace
for key, value := range tc.Env {
t.Setenv(key, expand(value))
}
for file, contents := range tc.Files {
target := expand(file)
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
if contents == nil {
require.NoError(t, os.Mkdir(target, 0o755))
} else {
require.NoError(t, os.WriteFile(target, []byte(*contents), 0o600))
}
}
overlay := map[string]string{}
for key, value := range tc.Overlay {
overlay[key] = expand(value)
}
_, err := New(Config{
NodeID: "auth-test", Version: "1", Logger: log.New(output, "", 0),
HarnessConfig: &HarnessConfig{Env: overlay},
})
require.NoError(t, err)
expected := 0
if !tc.Allow {
expected = 1
}
assert.Equal(t, expected, strings.Count(output.String(), contract.Warning))
})
}
}

func TestCodeAFW3KeyOnlyInConfigEnvLaunchesStub(t *testing.T) {
if runCodeAFAuthInFreshProcess(t) {
return
}
contract := readCodeAFAgentAuthContract(t)
directory, output := isolateCodeAFAgentAuth(t)
binary, marker := codeAFAgentFakeBinary(t, directory)
agent, err := New(Config{
NodeID: "auth-test", Version: "1", Logger: log.New(output, "", 0),
HarnessConfig: &HarnessConfig{Provider: harness.ProviderCodeAF, BinPath: binary, Env: map[string]string{"OPENROUTER_API_KEY": "config-key"}},
})
require.NoError(t, err)
assert.NotContains(t, output.String(), contract.Warning)
result, err := agent.Harness(context.Background(), "task", nil, nil, harness.Options{})
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "answer", result.Result)
assert.FileExists(t, marker)
assert.NotContains(t, output.String(), contract.Warning)
}
8 changes: 7 additions & 1 deletion sdk/go/harness/codeaf.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ var (
codeAFMissingKeyOnce sync.Once
)

// WarnCodeAFMissingKeyOnce shares one process-wide warning budget between Agent
// construction and refused calls, using the caller's warning logger.
func WarnCodeAFMissingKeyOnce(warn func(string)) {
codeAFMissingKeyOnce.Do(func() { warn(CodeAFMissingKeyWarning) })
}

// CodeAFProvider invokes CodeAF's canonical headless exec or do protocol.
// Binary discovery and managed installation happen lazily on first execution.
// When CodeAF is unavailable it can delegate to the unchanged legacy adapter.
Expand Down Expand Up @@ -274,7 +280,7 @@ func (p *CodeAFProvider) Execute(ctx context.Context, prompt string, options Opt
override = options.BinPath
}
if !CodeAFHasCredentials(options) {
codeAFMissingKeyOnce.Do(func() { log.Print("WARNING " + CodeAFMissingKeyWarning) })
WarnCodeAFMissingKeyOnce(func(message string) { log.Print("WARNING " + message) })
binary := firstNonEmpty(override, os.Getenv("CODEAF_BIN"), "codeaf")
return nil, &ProviderUnavailableError{
Provider: ProviderCodeAF, Binary: binary, InstallCommand: providerSpecs[ProviderCodeAF].InstallCommand,
Expand Down
7 changes: 4 additions & 3 deletions sdk/go/harness/codeaf_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,12 @@ func assertAuthRefusal(t *testing.T, err error, contract authContract) {
assert.ErrorIs(t, err, ErrProviderUnavailable)
assert.Equal(t, contract.Refusal, err.Error())
assert.Equal(t, ProviderCodeAF, unavailable.Provider)
assert.True(t, unavailable.NotConfigured)
assert.Equal(t, []string{"OPENROUTER_API_KEY"}, unavailable.MissingAuthEnv)
assert.Equal(t, "af codeaf ensure", unavailable.InstallCommand)
}

func TestCodeAFK1K3K4K7SharedKeyLadder(t *testing.T) {
func TestCodeAFW4K1K3K4K7SharedKeyLadder(t *testing.T) {
contract := readAuthContract(t)
for _, tc := range contract.Cases {
t.Run(tc.Name, func(t *testing.T) {
Expand Down Expand Up @@ -135,7 +136,7 @@ func TestCodeAFK1K3K4K7SharedKeyLadder(t *testing.T) {
}
}

func TestCodeAFK1K2BeforeResolution(t *testing.T) {
func TestCodeAFW2K1K2BeforeResolution(t *testing.T) {
contract := readAuthContract(t)
for _, provider := range []string{"", "codeaf", "aforge"} {
for _, pin := range []string{"automatic", "env", "option"} {
Expand All @@ -157,7 +158,7 @@ func TestCodeAFK1K2BeforeResolution(t *testing.T) {
if pin == "option" {
opts.BinPath = binary
}
for call := 0; call < 2; call++ {
for call := 0; call < 3; call++ {
_, err := p.Execute(context.Background(), "task", opts)
assertAuthRefusal(t, err, contract)
}
Expand Down
Loading
Loading