Compare commits

..

1 Commits

Author SHA1 Message Date
infra-runtime-be 394e72a1e7 fix(workspace): default PLATFORM_URL to host.docker.internal in all modules
KI-014 follow-on: inside a workspace container, localhost refers to the
container itself, not the platform. Four files had the Docker-aware
if-branch correct but fell through to localhost:8080 as the non-Docker
fallback — effectively making the Docker path the ONLY path that works,
since local dev on Mac/Linux can also resolve host.docker.internal via
the Docker daemon's built-in resolver.

Fix: unify the default to host.docker.internal in both branches, so
the env-var override always works and no caller ever silently falls
back to the wrong address.

- a2a_cli.py: else branch hardcoded localhost → host.docker.internal
- consolidation.py: same
- coordinator.py: same
- builtin_tools/temporal_workflow.py: two inline os.environ.get defaults
  replaced with a _platform_url() helper for DRY + consistent detection

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 08:21:10 +00:00
8 changed files with 19 additions and 124 deletions
-24
View File
@@ -392,15 +392,6 @@ type OrgDefaults struct {
// InitialMemories are default memories seeded into every workspace at
// creation time unless the workspace overrides them. Issue #1050.
InitialMemories []models.MemorySeed `yaml:"initial_memories" json:"initial_memories"`
// RequiredEnv / RecommendedEnv at the defaults level carry the
// runtime's own env requirements (e.g. ANTHROPIC_API_KEY for
// claude-code). They are injected into tmpl.RequiredEnv /
// tmpl.RecommendedEnv during Import so collectOrgEnv picks them up
// without needing an explicit org-level declaration. The same values
// also flow into each workspace's config.yaml at provision time via
// ensureDefaultConfig in workspace_provision.go.
RequiredEnv []EnvRequirement `yaml:"required_env" json:"required_env"`
RecommendedEnv []EnvRequirement `yaml:"recommended_env" json:"recommended_env"`
}
type OrgSchedule struct {
@@ -647,21 +638,6 @@ func (h *OrgHandler) Import(c *gin.Context) {
return
}
// Inject runtime-required env from defaults into the template-level
// union. Each runtime declares its own required_env (e.g.
// ANTHROPIC_API_KEY for claude-code); without this injection,
// collectOrgEnv only sees what's explicitly declared in
// tmpl.RequiredEnv / OrgWorkspace.RequiredEnv and silently misses
// the runtime's needs. The canvas's preflight modal passes a
// correctly-populated tmpl via the `template` body path, so only
// the `dir` (template-on-disk) path needs this injection. Issue #232.
if len(tmpl.Defaults.RequiredEnv) > 0 {
tmpl.RequiredEnv = append(tmpl.RequiredEnv, tmpl.Defaults.RequiredEnv...)
}
if len(tmpl.Defaults.RecommendedEnv) > 0 {
tmpl.RecommendedEnv = append(tmpl.RecommendedEnv, tmpl.Defaults.RecommendedEnv...)
}
// Emit started AFTER the YAML is loaded so payload.name carries the
// resolved template name (was: empty when caller passed `dir` instead
// of inline `template`). Pre-parse error paths above return without
@@ -1076,94 +1076,3 @@ func TestCollectOrgEnv_AnyOfWithInvalidMemberKeepsValidOnes(t *testing.T) {
t.Errorf("expected VALID_ONE to survive, got %v", reqNames(req))
}
}
// TestCollectOrgEnv_DefaultsRequiredEnv verifies that runtime-required_env
// declared at the defaults level is picked up by collectOrgEnv, matching the
// injection that Import performs from Defaults.RequiredEnv into tmpl.RequiredEnv
// before calling collectOrgEnv. Without this injection, a template with
// defaults.runtime=claude-code and no explicit RequiredEnv would silently skip
// ANTHROPIC_API_KEY, causing the import preflight to pass while every workspace
// fails on first LLM call. Issue #232.
func TestCollectOrgEnv_DefaultsRequiredEnv(t *testing.T) {
tmpl := &OrgTemplate{
Defaults: OrgDefaults{
Runtime: "claude-code",
RequiredEnv: strictReq("ANTHROPIC_API_KEY"),
RecommendedEnv: strictReq("SERPER_API_KEY"),
},
Workspaces: []OrgWorkspace{
{
Name: "Worker",
RequiredEnv: strictReq("GITHUB_TOKEN"),
Children: []OrgWorkspace{
{
Name: "Leaf",
},
},
},
},
}
req, rec := collectOrgEnv(tmpl)
// Required is the union: ANTHROPIC_API_KEY (from defaults) + GITHUB_TOKEN (from Worker).
wantReq := []string{"ANTHROPIC_API_KEY", "GITHUB_TOKEN"}
if !stringSlicesEqual(reqNames(req), wantReq) {
t.Errorf("required mismatch: got %v, want %v", reqNames(req), wantReq)
}
wantRec := []string{"SERPER_API_KEY"}
if !stringSlicesEqual(reqNames(rec), wantRec) {
t.Errorf("recommended mismatch: got %v, want %v", reqNames(rec), wantRec)
}
}
// TestCollectOrgEnv_DefaultsRequiredEnv_DedupWithExplicitOrgLevel tests that
// when BOTH defaults.RequiredEnv AND an explicit org-level RequiredEnv declare
// the same key, collectOrgEnv deduplicates and keeps only one entry.
func TestCollectOrgEnv_DefaultsRequiredEnv_DedupWithExplicitOrgLevel(t *testing.T) {
tmpl := &OrgTemplate{
RequiredEnv: strictReq("ANTHROPIC_API_KEY"),
Defaults: OrgDefaults{
Runtime: "claude-code",
RequiredEnv: strictReq("ANTHROPIC_API_KEY"),
RecommendedEnv: strictReq("SERPER_API_KEY"),
},
Workspaces: []OrgWorkspace{
{
Name: "Root",
},
},
}
req, rec := collectOrgEnv(tmpl)
wantReq := []string{"ANTHROPIC_API_KEY"}
if !stringSlicesEqual(reqNames(req), wantReq) {
t.Errorf("required should dedupe: got %v, want %v", reqNames(req), wantReq)
}
if !stringSlicesEqual(reqNames(rec), []string{"SERPER_API_KEY"}) {
t.Errorf("recommended: got %v, want [SERPER_API_KEY]", reqNames(rec))
}
}
// TestCollectOrgEnv_DefaultsRequiredEnv_AnyOfFromDefaults tests that any-of
// groups from defaults.RequiredEnv are preserved and flow into the union.
func TestCollectOrgEnv_DefaultsRequiredEnv_AnyOfFromDefaults(t *testing.T) {
tmpl := &OrgTemplate{
Defaults: OrgDefaults{
Runtime: "claude-code",
RequiredEnv: []EnvRequirement{
anyOfReq("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"),
},
},
}
req, _ := collectOrgEnv(tmpl)
if len(req) != 1 {
t.Fatalf("expected 1 requirement, got %d: %v", len(req), reqNames(req))
}
if req[0].Name != "" {
t.Errorf("expected any-of group from defaults.RequiredEnv, got strict name %q", req[0].Name)
}
want := []string{"ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"}
got := append([]string(nil), req[0].AnyOf...)
sort.Strings(got)
if !stringSlicesEqual(got, want) {
t.Errorf("any-of mismatch: got %v, want %v", got, want)
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ WORKSPACE_ID = _WORKSPACE_ID_raw
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
else:
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080")
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
async def discover(target_id: str) -> dict | None:
+1 -1
View File
@@ -29,7 +29,7 @@ WORKSPACE_ID = _WORKSPACE_ID_raw
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
else:
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080")
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
# Cache workspace ID → name mappings (populated by list_peers calls)
_peer_names: dict[str, str] = {}
+14 -4
View File
@@ -54,6 +54,16 @@ import httpx
logger = logging.getLogger(__name__)
def _platform_url() -> str:
"""Return the platform URL, defaulting to host.docker.internal when running
inside a Docker container (where localhost refers to the container, not the
host). External callers can always override via the PLATFORM_URL env var.
"""
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
return os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
return os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
# ─────────────────────────────────────────────────────────────────────────────
# Constants
# ─────────────────────────────────────────────────────────────────────────────
@@ -79,12 +89,12 @@ async def _fetch_latest_checkpoint(workspace_id: str) -> Optional[dict]:
workspace_id: The workspace to query.
Reads:
PLATFORM_URL Platform base URL (default ``http://localhost:8080``).
PLATFORM_URL Platform base URL (default ``http://host.docker.internal:8080``).
"""
try:
from platform_auth import auth_headers as _auth_headers # type: ignore[import]
platform_url = os.environ.get("PLATFORM_URL", "http://localhost:8080")
platform_url = _platform_url()
url = f"{platform_url}/workspaces/{workspace_id}/checkpoints/latest"
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(url, headers=_auth_headers())
@@ -125,12 +135,12 @@ async def _save_checkpoint(
payload: Optional JSON-serialisable dict stored as JSONB.
Reads:
PLATFORM_URL Platform base URL (default ``http://localhost:8080``).
PLATFORM_URL Platform base URL (default ``http://host.docker.internal:8080``).
"""
try:
from platform_auth import auth_headers as _auth_headers # type: ignore[import]
platform_url = os.environ.get("PLATFORM_URL", "http://localhost:8080")
platform_url = _platform_url()
url = f"{platform_url}/workspaces/{workspace_id}/checkpoints"
body: dict = {
"workflow_id": workflow_id,
+1 -1
View File
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
else:
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080")
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
_WORKSPACE_ID_raw = os.environ.get("WORKSPACE_ID")
if not _WORKSPACE_ID_raw:
raise RuntimeError("WORKSPACE_ID environment variable is required but not set")
+1 -1
View File
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
else:
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080")
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
_WORKSPACE_ID_raw = os.environ.get("WORKSPACE_ID")
if not _WORKSPACE_ID_raw:
raise RuntimeError("WORKSPACE_ID environment variable is required but not set")
+1 -1
View File
@@ -63,7 +63,7 @@ async def main(): # pragma: no cover
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
platform_url = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
else:
platform_url = os.environ.get("PLATFORM_URL", "http://localhost:8080")
platform_url = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
awareness_config = get_awareness_config()
# 0. Initialise OpenTelemetry (no-op if packages not installed)