From 7e3cd043c826059ee551be1adea4a26ea4df7a4a Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 22 Apr 2026 16:17:08 -0700 Subject: [PATCH] feat(provision): propagate workspace model into runtime env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tenant's workspace provisioner now forwards payload.Model (set by canvas Config tab when a user picks a model) through to the workspace's runtime env as HERMES_DEFAULT_MODEL, so install.sh / start.sh in the template can seed the right ~/.hermes/config.yaml without any post-provision manual step. Helper applyRuntimeModelEnv() is runtime-switched so each template owns its own env contract — hermes uses HERMES_DEFAULT_MODEL, future runtimes with different config schemas register their own cases. Runtimes that read model from /configs/config.yaml instead (langgraph, claude-code, deepagents) are unaffected: the switch has no case for them, so this is a no-op in those paths. Applied in both the Docker provisioner path (provisionWorkspaceOpts) and the SaaS/CP path (provisionWorkspaceCP) so local dev and production behave identically. Combined with: - molecule-controlplane#231 (/opt/adapter/install.sh hook) - molecule-ai-workspace-template-hermes#8 (install.sh for bare-host) - molecule-ai-workspace-template-hermes#9 (derive-provider.sh) this completes the MVP flow: customer creates a hermes workspace in canvas with model = minimax/MiniMax-M2.7-highspeed + secret MINIMAX_API_KEY = sk-cp-…, clicks Save, workspace provisions with the MiniMax Token Plan hermes-agent gateway up and ready for the first chat — no ops touch. Foundation this builds on: - env injection works for every runtime - secret passthrough is generic (already via workspace_secrets) - per-runtime env-var contract encoded once (applyRuntimeModelEnv) - canvas Save button for later-edit remains a Files-API-over-EIC concern (tracked separately) See internal/product/designs/workspace-backends.md for the broader architectural direction this fits into. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/handlers/workspace_provision.go | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/workspace-server/internal/handlers/workspace_provision.go b/workspace-server/internal/handlers/workspace_provision.go index 7a46d096..eac23772 100644 --- a/workspace-server/internal/handlers/workspace_provision.go +++ b/workspace-server/internal/handlers/workspace_provision.go @@ -94,6 +94,7 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri // Runs after secret loads so an operator can still override via a // workspace_secret named GIT_AUTHOR_NAME if they want custom identity. applyAgentGitIdentity(envVars, payload.Name) + applyRuntimeModelEnv(envVars, payload.Runtime, payload.Model) // Plugin extension point: run any registered EnvMutators (e.g. // github-app-auth, vault-secrets) AFTER built-in identity injection so @@ -544,6 +545,37 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model return files } +// applyRuntimeModelEnv exposes the workspace's selected model via an +// env var the target runtime's install.sh / start.sh knows to read. +// Each runtime owns its own env-var contract — the tenant just plumbs +// the value through so CP can bake it into user-data. +// +// Why per-runtime rather than a generic MOLECULE_MODEL: each runtime +// installer has its own config schema and naming (hermes writes to +// ~/.hermes/config.yaml with `model.default`; langgraph reads from +// /configs/config.yaml directly; future IoT/robotics targets may have +// firmware manifests). Keeping the contract owned by the runtime +// template means adding a new runtime doesn't require edits on the +// tenant side for each one. +// +// For runtimes with no env-based model override (langgraph etc. read +// model from /configs/config.yaml which CP user-data generates from +// payload.Model at boot), this is a no-op — no harm in the switch +// being empty for those cases. +func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) { + if model == "" { + return + } + switch runtime { + case "hermes": + // template-hermes install.sh reads this into ~/.hermes/config.yaml's + // model.default field; derives HERMES_INFERENCE_PROVIDER from the + // slug prefix (minimax/…, anthropic/…, openai/…, etc.) when the + // provider isn't explicitly set. + envVars["HERMES_DEFAULT_MODEL"] = model + } +} + // loadWorkspaceSecrets loads global + workspace-specific secrets into a map. // Returns nil map + error string on decrypt failure. Shared by both Docker // and control plane provisioning paths to avoid duplication. @@ -600,6 +632,7 @@ func (h *WorkspaceHandler) provisionWorkspaceCP(workspaceID, templatePath string } applyAgentGitIdentity(envVars, payload.Name) + applyRuntimeModelEnv(envVars, payload.Runtime, payload.Model) if err := h.envMutators.Run(ctx, workspaceID, envVars); err != nil { log.Printf("CPProvisioner: env mutator failed for %s: %v", workspaceID, err) // F1086 / #1206: env mutator errors (missing tokens, vault paths) must not