Compare commits

...

1 Commits

Author SHA1 Message Date
fullstack-engineer 91ea13cd43 fix(workspace-server): inject <PROVIDER>_BASE_URL fallback when API key is set but URL missing (internal#417)
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
CI / Shellcheck (E2E scripts) (pull_request) Blocked by required conditions
CI / Detect changes (pull_request) Waiting to run
CI / Platform (Go) (pull_request) Waiting to run
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
CI / Python Lint & Test (pull_request) Blocked by required conditions
CI / all-required (pull_request) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / detect-changes (pull_request) Waiting to run
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Runtime PR-Built Compatibility / detect-changes (pull_request) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Successful in 32s
E2E API Smoke Test / detect-changes (pull_request) Successful in 2m11s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m58s
CI / Canvas (Next.js) (pull_request) Successful in 16m48s
qa-review / approved (pull_request) Refired via /qa-recheck by unknown
security-review / approved (pull_request) Refired via /security-recheck by unknown
When the operator saves only a vendor API key (e.g. MINIMAX_API_KEY)
into global_secrets without the matching <PROVIDER>_BASE_URL, the
in-workspace adapter falls back to its hardcoded registry default —
which for MiniMax is api.minimaxi.com (China region), while every
operator-host key in the post-2026-05-06 stack is issued against
api.minimax.io (global region). The result is silent 401 on every
LLM call from the workspace.

Fix: in workspace-server/internal/handlers/workspace_provision.go's
loadWorkspaceSecrets, after both DB queries, run a fallback pass that
injects the canonical <PROVIDER>_BASE_URL for every vendor whose API
key is set but whose URL is absent. Operator-saved URLs always win
(no overwrite); empty keys are treated as unset (no injection).

The canonical URL registry lives in
workspace-server/internal/handlers/provider_defaults.go and mirrors
the third-party provider lists in workspace-configs-templates/
openclaw + claude-code-default + hermes adapters. Six providers
covered (openai, groq, openrouter, qianfan, minimax, moonshot).

Also seed MINIMAX_API_KEY + MINIMAX_BASE_URL into the dev-local
post-rebuild-setup.sh global_secrets block so dev parity matches
prod-style provisioning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:28:06 -07:00
4 changed files with 241 additions and 2 deletions
+11 -2
View File
@@ -41,6 +41,13 @@ until curl -s --max-time 5 "$PLATFORM_URL/health" >/dev/null 2>&1; do
done
echo " platform up"
# MINIMAX_BASE_URL defaults to the global endpoint (api.minimax.io) —
# matches the operator-host MINIMAX_API_KEY scope. Override by setting
# MINIMAX_BASE_URL before running this script (e.g. for the China
# endpoint api.minimaxi.com). See RFC internal#417 for why the global
# URL is the safe default.
: "${MINIMAX_BASE_URL:=https://api.minimax.io}"
echo "=== Inserting global secrets ==="
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -c "
INSERT INTO global_secrets (key, encrypted_value, encryption_version) VALUES
@@ -50,10 +57,12 @@ INSERT INTO global_secrets (key, encrypted_value, encryption_version) VALUES
('ANTHROPIC_SMALL_FAST_MODEL', 'MiniMax-M2.7', 0),
('API_TIMEOUT_MS', '3000000', 0),
('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', '1', 0),
('GITHUB_TOKEN', '$GITHUB_PAT', 0)
('GITHUB_TOKEN', '$GITHUB_PAT', 0),
('MINIMAX_API_KEY', '$MINIMAX_API_KEY', 0),
('MINIMAX_BASE_URL', '$MINIMAX_BASE_URL', 0)
ON CONFLICT (key) DO UPDATE SET encrypted_value = EXCLUDED.encrypted_value;
"
echo " 7 global secrets set"
echo " 9 global secrets set"
echo "=== Importing org template ==="
curl -s --max-time 600 -X POST "$PLATFORM_URL/org/import" \
@@ -0,0 +1,97 @@
package handlers
// Mirror of the third-party LLM provider registry baked into adapter
// templates (workspace-configs-templates/openclaw/adapter.py +
// workspace-configs-templates/claude-code-default/adapter.py + the
// hermes counterparts). Must stay in sync — if a provider is added in
// any of those adapters' base_url defaults, add it here too.
//
// Why this exists (RFC internal#417):
//
// Operators saving a vendor-scoped API key (e.g. MINIMAX_API_KEY) into
// the platform `global_secrets` table expect the workspace to talk to
// the canonical regional endpoint for that key. Pre-fix, when no
// matching <PROVIDER>_BASE_URL was also saved, the adapter inside the
// workspace fell back to its hardcoded registry default — which for
// MiniMax is api.minimaxi.com (China), while every operator key issued
// against api.minimax.io (global) hits 401 there. The fix is platform-
// side: when we see the API key but no URL, we inject the canonical URL
// so the workspace's adapter precedence chain (operator URL > runtime
// config > adapter default) ends up pointing at the right region.
//
// This is a FALLBACK, never an override. If the operator already
// saved <PROVIDER>_BASE_URL we leave it alone; their explicit choice
// always wins. If the API key isn't set, we don't inject a stray URL
// for a provider the workspace never uses.
//
// MINIMAX_BASE_URL is set to api.minimax.io (NOT api.minimaxi.com) —
// the global endpoint matches the keys all operator-host secrets are
// issued against. See RFC internal#417 §Phase 1 for the regional
// ambiguity table.
var ProviderBaseURLDefaults = map[string]string{
"OPENAI_BASE_URL": "https://api.openai.com/v1",
"GROQ_BASE_URL": "https://api.groq.com/openai/v1",
"OPENROUTER_BASE_URL": "https://openrouter.ai/api/v1",
"QIANFAN_BASE_URL": "https://qianfan.baidubce.com/v2",
"MINIMAX_BASE_URL": "https://api.minimax.io",
"MOONSHOT_BASE_URL": "https://api.moonshot.ai/v1",
}
// applyProviderBaseURLDefaults injects each provider's canonical
// BASE_URL into envVars when the matching <PROVIDER>_API_KEY is set
// and non-empty AND no <PROVIDER>_BASE_URL is already present.
//
// The function mutates envVars in place. It is a pure helper (no DB,
// no I/O) so unit tests drive it directly without sqlmock.
//
// Pairing rule: each entry in ProviderBaseURLDefaults names a key of
// the form <PREFIX>_BASE_URL; the paired API-key var is derived by
// swapping the suffix to _API_KEY (e.g. MINIMAX_BASE_URL ↔
// MINIMAX_API_KEY). This mirrors how every adapter registry pairs the
// two — see workspace-configs-templates/*/adapter.py for the canonical
// pairing and RFC internal#417 §Phase 2 for the design rationale.
//
// Returns the list of base-URL keys that were injected, so the caller
// can log which fallbacks fired without re-iterating the map.
func applyProviderBaseURLDefaults(envVars map[string]string) []string {
if envVars == nil {
return nil
}
var injected []string
for baseURLKey, defaultURL := range ProviderBaseURLDefaults {
// Derive the paired API-key var name. Every entry in the map
// MUST end in `_BASE_URL`; if a future entry breaks that
// convention this strip-and-swap silently misses it. The unit
// test TestApplyProviderBaseURLDefaults_KeyShape pins the
// shape so the miss surfaces immediately on next test run.
const baseSuffix = "_BASE_URL"
if len(baseURLKey) <= len(baseSuffix) ||
baseURLKey[len(baseURLKey)-len(baseSuffix):] != baseSuffix {
continue
}
apiKeyKey := baseURLKey[:len(baseURLKey)-len(baseSuffix)] + "_API_KEY"
// API key must be present AND non-empty. An empty string is
// treated as unset — operators sometimes save a placeholder
// row and we don't want to silently route them to a real
// endpoint with no credential.
apiKeyVal, hasKey := envVars[apiKeyKey]
if !hasKey || apiKeyVal == "" {
continue
}
// Don't overwrite an explicit operator URL. The operator may
// have legitimately scoped this provider to a custom or
// regional endpoint (e.g. a corporate proxy, a different
// MiniMax region). Their explicit value always wins — same
// precedence rule as molecule_runtime.adapter_base's
// resolve_provider_routing.
if existing, ok := envVars[baseURLKey]; ok && existing != "" {
continue
}
envVars[baseURLKey] = defaultURL
injected = append(injected, baseURLKey)
}
return injected
}
@@ -0,0 +1,118 @@
package handlers
import (
"strings"
"testing"
)
// TestApplyProviderBaseURLDefaults_InjectsWhenKeySetAndURLAbsent — the
// happy path that closes RFC internal#417's reported bug: operator
// saves MINIMAX_API_KEY only, fallback fills in the canonical URL.
func TestApplyProviderBaseURLDefaults_InjectsWhenKeySetAndURLAbsent(t *testing.T) {
envVars := map[string]string{
"MINIMAX_API_KEY": "sk-real-key",
}
injected := applyProviderBaseURLDefaults(envVars)
if got := envVars["MINIMAX_BASE_URL"]; got != "https://api.minimax.io" {
t.Fatalf("MINIMAX_BASE_URL: got %q, want %q", got, "https://api.minimax.io")
}
if len(injected) != 1 || injected[0] != "MINIMAX_BASE_URL" {
t.Fatalf("injected list: got %v, want [MINIMAX_BASE_URL]", injected)
}
}
// TestApplyProviderBaseURLDefaults_DoesNotOverrideExplicitURL — operator
// override wins. If they saved both MINIMAX_API_KEY and a custom URL
// (say a corporate-proxy or a different region), we leave their URL
// alone. This is the precedence rule from RFC §Phase 2.
func TestApplyProviderBaseURLDefaults_DoesNotOverrideExplicitURL(t *testing.T) {
envVars := map[string]string{
"MINIMAX_API_KEY": "sk-real-key",
"MINIMAX_BASE_URL": "https://custom.example.com",
}
injected := applyProviderBaseURLDefaults(envVars)
if got := envVars["MINIMAX_BASE_URL"]; got != "https://custom.example.com" {
t.Fatalf("MINIMAX_BASE_URL: got %q, want operator value %q",
got, "https://custom.example.com")
}
for _, k := range injected {
if k == "MINIMAX_BASE_URL" {
t.Fatalf("MINIMAX_BASE_URL should not appear in injected list when operator set it explicitly; got %v", injected)
}
}
}
// TestApplyProviderBaseURLDefaults_NoKeyNoInjection — provider whose
// API key isn't set should not get a stray URL injected. Keeps the
// workspace env clean and avoids accidentally signalling that the
// provider is available when it isn't.
func TestApplyProviderBaseURLDefaults_NoKeyNoInjection(t *testing.T) {
envVars := map[string]string{
// Some unrelated key set, but no MINIMAX_API_KEY.
"OPENAI_API_KEY": "sk-openai",
}
injected := applyProviderBaseURLDefaults(envVars)
if _, ok := envVars["MINIMAX_BASE_URL"]; ok {
t.Fatalf("MINIMAX_BASE_URL was injected without MINIMAX_API_KEY being set; envVars=%v", envVars)
}
// OPENAI_API_KEY is set, so OPENAI_BASE_URL should be injected —
// keeps this test honest about what "no injection" means (it's
// per-provider, not blanket).
if envVars["OPENAI_BASE_URL"] != "https://api.openai.com/v1" {
t.Fatalf("OPENAI_BASE_URL: got %q, want %q",
envVars["OPENAI_BASE_URL"], "https://api.openai.com/v1")
}
for _, k := range injected {
if k == "MINIMAX_BASE_URL" {
t.Fatalf("MINIMAX_BASE_URL should not appear in injected list; got %v", injected)
}
}
}
// TestApplyProviderBaseURLDefaults_EmptyKeyTreatedAsUnset — operators
// sometimes save a placeholder empty-string row (e.g. they cleared the
// key but didn't delete the row). Treat that the same as the key being
// absent: don't route to a real endpoint with no credential.
func TestApplyProviderBaseURLDefaults_EmptyKeyTreatedAsUnset(t *testing.T) {
envVars := map[string]string{
"MINIMAX_API_KEY": "", // explicitly empty
}
injected := applyProviderBaseURLDefaults(envVars)
if _, ok := envVars["MINIMAX_BASE_URL"]; ok {
t.Fatalf("MINIMAX_BASE_URL should not be injected when MINIMAX_API_KEY=\"\"; envVars=%v", envVars)
}
if len(injected) != 0 {
t.Fatalf("injected list should be empty when key is empty; got %v", injected)
}
}
// TestApplyProviderBaseURLDefaults_KeyShape — every key in the
// registry must end in `_BASE_URL` so the strip-and-swap in
// applyProviderBaseURLDefaults can derive the paired API-key var
// name. If a future entry breaks that convention the fallback
// silently misses it; this test catches the drift at next CI run.
func TestApplyProviderBaseURLDefaults_KeyShape(t *testing.T) {
for k := range ProviderBaseURLDefaults {
if !strings.HasSuffix(k, "_BASE_URL") {
t.Errorf("ProviderBaseURLDefaults key %q must end in _BASE_URL", k)
}
}
}
// TestApplyProviderBaseURLDefaults_NilMap — defensive: the helper is
// called from loadWorkspaceSecrets right before return, after both
// query loops. If a future refactor passes nil (e.g. an error path
// returns the bare nil map literal), the helper must not panic. The
// function's nil-guard pins this.
func TestApplyProviderBaseURLDefaults_NilMap(t *testing.T) {
// Must not panic. Return value can be nil/empty either way.
_ = applyProviderBaseURLDefaults(nil)
}
@@ -830,6 +830,21 @@ func loadWorkspaceSecrets(ctx context.Context, workspaceID string) (map[string]s
log.Printf("Provisioner: workspace_secrets rows.Err workspace=%s: %v", workspaceID, err)
}
}
// Provider BASE_URL fallback (RFC internal#417): when the operator
// saved a vendor API key (e.g. MINIMAX_API_KEY) but no matching
// <PROVIDER>_BASE_URL, inject the canonical regional URL so the
// in-workspace adapter doesn't fall back to its hardcoded default
// (which is wrong for at least MiniMax — api.minimaxi.com China vs
// api.minimax.io global). Operator-saved URLs win; this only fills
// holes. See provider_defaults.go for the registry + precedence.
if injected := applyProviderBaseURLDefaults(envVars); len(injected) > 0 {
for _, k := range injected {
log.Printf("Provisioner: provider BASE_URL fallback applied for %s: %s=%s",
workspaceID, k, envVars[k])
}
}
return envVars, ""
}