fix(platform): unblock org-template imports against modular workspace templates

Two adjacent fixes that surfaced trying to bring the molecule-dev org
template back up against the new standalone workspace-template-* repos.

1) handlers/org.go — expand ${VAR} in workspace_dir before validation.
   The molecule-dev pm/workspace.yaml (and any operator's per-host
   binding) ships `workspace_dir: ${WORKSPACE_DIR}` so each operator
   can pick the host path PM bind-mounts. Without expansion the literal
   "${WORKSPACE_DIR}" string reaches validateWorkspaceDir and fails with
   "must be an absolute path", aborting the whole org import.
   Other fields (channel config, prompts) already go through expandWithEnv;
   workspace_dir was the last hold-out.

2) provisioner/provisioner.go — inject PYTHONPATH=/app for every
   workspace container. Standalone template Dockerfiles 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 here fixes every adapter image at
   once instead of needing 8 PRs against template repos. Operator
   override still wins (workspace EnvVars are appended after, so Docker
   takes the later duplicate).

   Note: this unblocks the import path but does NOT make claude-code /
   hermes / etc. boot. The runtime itself has a separate top-level
   `from adapters import` that breaks against modular templates —
   tracked at workspace-runtime#1.

Tests: TestBuildContainerEnv_InjectsPYTHONPATH +
TestBuildContainerEnv_WorkspaceEnvVarsCanOverridePYTHONPATH lock the
default + operator-override invariants. expandWithEnv is already covered
by TestExpandWithEnv_* — the workspace_dir use site is a one-line call
to that primitive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
rabbitblood 2026-04-16 07:49:45 -07:00
parent bf9fb7cb51
commit ff2394c085
3 changed files with 67 additions and 0 deletions

View File

@ -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 != "" {

View File

@ -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))

View File

@ -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