mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-27 14:59:09 +01:00
[client] Route agent-network through the daemon and shape env per provider
Two field-test findings drive this change: the direct-dial path needed sudo (the profile's WireGuard key is root-owned), and a single flat ANTHROPIC_* export set is wrong for providers that speak other API shapes. Relay the setup request through the daemon instead: a new GetAgentNetworkSetup daemon RPC forwards to management over the engine's existing peer connection, so unprivileged callers get the caller-scoped answer the same way 'netbird status' works — no sudo, and the key never leaves the daemon. The daemon's JSON gateway exposes the RPC for the desktop UI for free. Teach 'agent-network env' the per-provider environment contracts, mirroring Claude Code's LLM-gateway configuration: - anthropic flavor: ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL - bedrock_api: CLAUDE_CODE_USE_BEDROCK, ANTHROPIC_BEDROCK_BASE_URL, CLAUDE_CODE_SKIP_BEDROCK_AUTH (the proxy injects AWS credentials) - vertex_ai_api: CLAUDE_CODE_USE_VERTEX, ANTHROPIC_VERTEX_BASE_URL, CLAUDE_CODE_SKIP_VERTEX_AUTH, plus comments for the admin-supplied ANTHROPIC_VERTEX_PROJECT_ID and CLOUD_ML_REGION (the proxy forwards the URL path, so those values must be the operator's real ones) - openai flavor: OPENAI_BASE_URL / OPENAI_API_KEY - anything else: comment lines only — no guessed variables Selection stays explicit: --provider picks by operator label or catalog id and is required when several providers are authorized; --model is validated against the provider's allowed set and required when several models are allowed. Ambiguity renders as shell comments, never as exports. Linear: NET-1399
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
func anTestSetup(providers ...*proto.AgentNetworkProvider) *proto.GetAgentNetworkSetupResponse {
|
||||
return &proto.GetAgentNetworkSetupResponse{
|
||||
Configured: true,
|
||||
Endpoint: "https://calm-otter.proxy.example.com",
|
||||
Providers: providers,
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentNetworkEnv_AnthropicSingleModel(t *testing.T) {
|
||||
setup := anTestSetup(&proto.AgentNetworkProvider{
|
||||
Name: "Anthropic prod", CatalogId: "anthropic_api", ApiFlavor: "anthropic",
|
||||
Models: []string{"claude-sonnet-4-5"},
|
||||
})
|
||||
|
||||
lines, err := buildAgentNetworkEnv(setup, "", "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{
|
||||
"export ANTHROPIC_BASE_URL='https://calm-otter.proxy.example.com'",
|
||||
"export ANTHROPIC_AUTH_TOKEN='netbird'",
|
||||
"export ANTHROPIC_MODEL='claude-sonnet-4-5'",
|
||||
}, lines)
|
||||
}
|
||||
|
||||
func TestBuildAgentNetworkEnv_MultipleModelsBecomeComments(t *testing.T) {
|
||||
setup := anTestSetup(&proto.AgentNetworkProvider{
|
||||
Name: "Anthropic prod", CatalogId: "anthropic_api", ApiFlavor: "anthropic",
|
||||
Models: []string{"claude-sonnet-4-5", "claude-haiku-4-5"},
|
||||
})
|
||||
|
||||
lines, err := buildAgentNetworkEnv(setup, "", "")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, lines, "export ANTHROPIC_BASE_URL='https://calm-otter.proxy.example.com'")
|
||||
assert.NotContains(t, strings.Join(lines, "\n"), "ANTHROPIC_MODEL=", "no model is ever guessed")
|
||||
assert.Contains(t, strings.Join(lines, "\n"), "# Multiple models are allowed")
|
||||
}
|
||||
|
||||
func TestBuildAgentNetworkEnv_ModelFlagValidated(t *testing.T) {
|
||||
setup := anTestSetup(&proto.AgentNetworkProvider{
|
||||
Name: "Anthropic prod", CatalogId: "anthropic_api", ApiFlavor: "anthropic",
|
||||
Models: []string{"claude-sonnet-4-5", "claude-haiku-4-5"},
|
||||
})
|
||||
|
||||
lines, err := buildAgentNetworkEnv(setup, "", "Claude-Haiku-4-5")
|
||||
require.NoError(t, err, "model match is case-insensitive")
|
||||
assert.Contains(t, lines, "export ANTHROPIC_MODEL='Claude-Haiku-4-5'")
|
||||
|
||||
_, err = buildAgentNetworkEnv(setup, "", "gpt-4o")
|
||||
require.Error(t, err, "a model outside the allowlist is rejected")
|
||||
}
|
||||
|
||||
func TestBuildAgentNetworkEnv_BedrockFlavor(t *testing.T) {
|
||||
setup := anTestSetup(&proto.AgentNetworkProvider{
|
||||
Name: "Bedrock prod", CatalogId: "bedrock_api", ApiFlavor: "",
|
||||
Models: []string{"anthropic.claude-sonnet-4-5"},
|
||||
})
|
||||
|
||||
lines, err := buildAgentNetworkEnv(setup, "", "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{
|
||||
"export CLAUDE_CODE_USE_BEDROCK='1'",
|
||||
"export ANTHROPIC_BEDROCK_BASE_URL='https://calm-otter.proxy.example.com'",
|
||||
"export CLAUDE_CODE_SKIP_BEDROCK_AUTH='1'",
|
||||
"export ANTHROPIC_MODEL='anthropic.claude-sonnet-4-5'",
|
||||
}, lines)
|
||||
}
|
||||
|
||||
func TestBuildAgentNetworkEnv_VertexFlavorNotesProjectAndRegion(t *testing.T) {
|
||||
setup := anTestSetup(&proto.AgentNetworkProvider{
|
||||
Name: "Vertex prod", CatalogId: "vertex_ai_api", ApiFlavor: "",
|
||||
Models: []string{"claude-sonnet-4-5"},
|
||||
})
|
||||
|
||||
lines, err := buildAgentNetworkEnv(setup, "", "")
|
||||
require.NoError(t, err)
|
||||
joined := strings.Join(lines, "\n")
|
||||
assert.Contains(t, lines, "export CLAUDE_CODE_USE_VERTEX='1'")
|
||||
assert.Contains(t, lines, "export ANTHROPIC_VERTEX_BASE_URL='https://calm-otter.proxy.example.com'")
|
||||
assert.Contains(t, lines, "export CLAUDE_CODE_SKIP_VERTEX_AUTH='1'")
|
||||
assert.Contains(t, lines, "export ANTHROPIC_MODEL='claude-sonnet-4-5'")
|
||||
assert.Contains(t, joined, "ANTHROPIC_VERTEX_PROJECT_ID", "project id must be called out as admin-supplied")
|
||||
assert.Contains(t, joined, "CLOUD_ML_REGION", "region must be called out as admin-supplied")
|
||||
}
|
||||
|
||||
func TestBuildAgentNetworkEnv_OpenAIFlavor(t *testing.T) {
|
||||
setup := anTestSetup(&proto.AgentNetworkProvider{
|
||||
Name: "OpenAI prod", CatalogId: "openai_api", ApiFlavor: "openai",
|
||||
Models: []string{"gpt-5.4"},
|
||||
})
|
||||
|
||||
lines, err := buildAgentNetworkEnv(setup, "", "")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, lines, "export OPENAI_BASE_URL='https://calm-otter.proxy.example.com'")
|
||||
assert.Contains(t, lines, "export OPENAI_API_KEY='netbird'")
|
||||
assert.NotContains(t, strings.Join(lines, "\n"), "ANTHROPIC_", "openai flavor must not emit anthropic variables")
|
||||
}
|
||||
|
||||
func TestBuildAgentNetworkEnv_MultipleProvidersRequireFlag(t *testing.T) {
|
||||
anthropic := &proto.AgentNetworkProvider{Name: "Anthropic prod", CatalogId: "anthropic_api", ApiFlavor: "anthropic", Models: []string{"claude-sonnet-4-5"}}
|
||||
bedrock := &proto.AgentNetworkProvider{Name: "Bedrock prod", CatalogId: "bedrock_api", Models: []string{"anthropic.claude-sonnet-4-5"}}
|
||||
setup := anTestSetup(anthropic, bedrock)
|
||||
|
||||
lines, err := buildAgentNetworkEnv(setup, "", "")
|
||||
require.NoError(t, err)
|
||||
joined := strings.Join(lines, "\n")
|
||||
assert.NotContains(t, joined, "export ", "ambiguous provider choice must export nothing")
|
||||
assert.Contains(t, joined, "--provider")
|
||||
assert.Contains(t, joined, "Bedrock prod")
|
||||
|
||||
// Selection by operator label, case-insensitive.
|
||||
lines, err = buildAgentNetworkEnv(setup, "bedrock prod", "")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, lines, "export CLAUDE_CODE_USE_BEDROCK='1'")
|
||||
|
||||
// Selection by catalog id.
|
||||
lines, err = buildAgentNetworkEnv(setup, "anthropic_api", "")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, lines, "export ANTHROPIC_AUTH_TOKEN='netbird'")
|
||||
|
||||
// Unknown provider is an error naming the available ones.
|
||||
_, err = buildAgentNetworkEnv(setup, "vertex", "")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Anthropic prod")
|
||||
}
|
||||
|
||||
func TestBuildAgentNetworkEnv_AllModelsAllowed(t *testing.T) {
|
||||
setup := anTestSetup(&proto.AgentNetworkProvider{
|
||||
Name: "Anthropic prod", CatalogId: "anthropic_api", ApiFlavor: "anthropic",
|
||||
AllModelsAllowed: true, Models: []string{"claude-sonnet-4-5"},
|
||||
})
|
||||
|
||||
// A courtesy-listed single model is still ambiguous when everything is allowed.
|
||||
lines, err := buildAgentNetworkEnv(setup, "", "")
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, strings.Join(lines, "\n"), "ANTHROPIC_MODEL=")
|
||||
|
||||
// --model passes without allowlist validation.
|
||||
lines, err = buildAgentNetworkEnv(setup, "", "claude-opus-4-8")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, lines, "export ANTHROPIC_MODEL='claude-opus-4-8'")
|
||||
}
|
||||
|
||||
func TestBuildAgentNetworkEnv_UnknownFlavorFallsBackToComments(t *testing.T) {
|
||||
setup := anTestSetup(&proto.AgentNetworkProvider{
|
||||
Name: "Kimi", CatalogId: "kimi_api", ApiFlavor: "",
|
||||
Models: []string{"kimi-k3"},
|
||||
})
|
||||
|
||||
lines, err := buildAgentNetworkEnv(setup, "", "")
|
||||
require.NoError(t, err)
|
||||
joined := strings.Join(lines, "\n")
|
||||
assert.NotContains(t, joined, "export ", "unknown API shape must not guess variables")
|
||||
assert.Contains(t, joined, "https://calm-otter.proxy.example.com")
|
||||
}
|
||||
Reference in New Issue
Block a user