diff --git a/platform/internal/handlers/org.go b/platform/internal/handlers/org.go index 63f55905..583565b5 100644 --- a/platform/internal/handlers/org.go +++ b/platform/internal/handlers/org.go @@ -357,6 +357,17 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, defa role = ws.Role } + // Expand ${VAR} references in workspace_dir against the org's .env files + // before validation. Without this, a template that ships + // `workspace_dir: ${WORKSPACE_DIR}` (so each operator can pick the host + // path to bind-mount) reaches validateWorkspaceDir as the literal + // "${WORKSPACE_DIR}" string and fails with "must be an absolute path". + // Other fields (channel config, prompts) already go through expandWithEnv; + // workspace_dir was the last hold-out. + if ws.WorkspaceDir != "" { + ws.WorkspaceDir = expandWithEnv(ws.WorkspaceDir, loadWorkspaceEnv(orgBaseDir, ws.FilesDir)) + } + // Validate and convert workspace_dir to NULL if empty var workspaceDir interface{} if ws.WorkspaceDir != "" { diff --git a/platform/internal/provisioner/provisioner.go b/platform/internal/provisioner/provisioner.go index f2905b2e..8757322d 100644 --- a/platform/internal/provisioner/provisioner.go +++ b/platform/internal/provisioner/provisioner.go @@ -380,6 +380,15 @@ func buildContainerEnv(cfg WorkspaceConfig) []string { fmt.Sprintf("MOLECULE_URL=%s", cfg.PlatformURL), fmt.Sprintf("TIER=%d", cfg.Tier), "PLUGINS_DIR=/plugins", + // PYTHONPATH=/app makes ADAPTER_MODULE imports resolve regardless of + // runtime cwd. Standalone workspace-template repos COPY adapter.py to + // /app and set ENV ADAPTER_MODULE=adapter, but molecule-runtime is a + // pip console_script entry point so cwd isn't on sys.path automatically. + // Setting PYTHONPATH from the provisioner fixes every adapter image + // (claude-code, hermes, langgraph, …) without needing to PR each + // standalone template repo. Per-template ENV in the Dockerfile can + // still override (Dockerfile ENV is overridden by docker -e at runtime). + "PYTHONPATH=/app", } if cfg.AwarenessNamespace != "" && cfg.AwarenessURL != "" { env = append(env, fmt.Sprintf("AWARENESS_NAMESPACE=%s", cfg.AwarenessNamespace)) diff --git a/platform/internal/provisioner/provisioner_test.go b/platform/internal/provisioner/provisioner_test.go index 362b1d98..bededcd3 100644 --- a/platform/internal/provisioner/provisioner_test.go +++ b/platform/internal/provisioner/provisioner_test.go @@ -469,6 +469,53 @@ func TestBuildContainerEnv_InjectsBothPlatformURLAndMoleculeAIURL(t *testing.T) } } +func TestBuildContainerEnv_InjectsPYTHONPATH(t *testing.T) { + // Standalone workspace-template repos COPY adapter.py to /app and rely on + // `import adapter` resolving via PYTHONPATH. molecule-runtime is a pip + // console_script entry, so cwd isn't on sys.path automatically. The + // provisioner injects PYTHONPATH=/app so every adapter image works + // without per-template Dockerfile patching. See workspace-runtime#1 + // for the runtime-side bug this works around. + cfg := WorkspaceConfig{WorkspaceID: "ws-x", PlatformURL: "http://x", Tier: 1} + env := buildContainerEnv(cfg) + want := "PYTHONPATH=/app" + for _, e := range env { + if e == want { + return + } + } + t.Errorf("expected env to contain %q, got %v", want, env) +} + +func TestBuildContainerEnv_WorkspaceEnvVarsCanOverridePYTHONPATH(t *testing.T) { + // Operator escape hatch: a per-workspace EnvVars["PYTHONPATH"] = "/custom" + // MUST appear AFTER the default in the env slice so Docker uses the + // later one. Without this, an operator who needs a custom path can't + // override the provisioner default. + cfg := WorkspaceConfig{ + WorkspaceID: "ws-x", + PlatformURL: "http://x", + Tier: 1, + EnvVars: map[string]string{"PYTHONPATH": "/custom:/app"}, + } + env := buildContainerEnv(cfg) + defaultIdx, customIdx := -1, -1 + for i, e := range env { + if e == "PYTHONPATH=/app" { + defaultIdx = i + } + if e == "PYTHONPATH=/custom:/app" { + customIdx = i + } + } + if defaultIdx < 0 || customIdx < 0 { + t.Fatalf("expected both default and custom PYTHONPATH entries, got %v", env) + } + if customIdx < defaultIdx { + t.Errorf("custom PYTHONPATH (idx=%d) must come AFTER default (idx=%d) so Docker takes the operator override", customIdx, defaultIdx) + } +} + func TestBuildContainerEnv_MoleculeAIURLAlwaysMatchesPlatformURL(t *testing.T) { // Regression guard: MOLECULE_URL must never drift from PLATFORM_URL — // if someone changes one they must change the other. This test pins