chore(manifest): prune to 4 actively-supported runtimes

Deletes the 5 unsupported workspace_templates from manifest.json
(langgraph, crewai, autogen, deepagents, gemini-cli). The runtime
matrix is now claude-code / hermes / openclaw / codex — the four
templates with shipping images, working A2A integration, and active
CI publish-image cascades.

Mirrors the prune in:
  - workspace-server/internal/handlers/runtime_registry.go
    (fallbackRuntimes for dev/test contexts that boot without the
    manifest mounted)
  - workspace-server/internal/handlers/workspace_provision.go
    (sanitizeRuntime: empty/unknown → "claude-code", was "langgraph";
    removes the langgraph/deepagents-specific runtime_config skip
    branch — they're no longer supported, so the block is dead)
  - tests for both: rename TestEnsureDefaultConfig_LangGraph →
    _Hermes, TestEnsureDefaultConfig_EmptyRuntimeDefaultsToLangGraph
    → _ClaudeCode, drop TestEnsureDefaultConfig_DeepAgents,
    update TestSanitizeRuntime_Allowlist + the two
    TestResolveRestartTemplate_* cases that pinned langgraph-default
    as the safe-default name

Why this is safe: production reads manifest.json at boot and uses it
as the authoritative allowlist; the 5 removed runtimes have not
shipped working images for ≥1 release cycle. Any provision request
naming one will now coerce to claude-code (with a log line) instead
of returning a runtime that has no functioning template repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-05-02 19:21:47 -07:00
parent 35cb6ba089
commit f33e59ba8c
5 changed files with 37 additions and 81 deletions

View File

@ -26,12 +26,7 @@
],
"workspace_templates": [
{"name": "claude-code-default", "repo": "Molecule-AI/molecule-ai-workspace-template-claude-code", "ref": "main"},
{"name": "langgraph", "repo": "Molecule-AI/molecule-ai-workspace-template-langgraph", "ref": "main"},
{"name": "crewai", "repo": "Molecule-AI/molecule-ai-workspace-template-crewai", "ref": "main"},
{"name": "autogen", "repo": "Molecule-AI/molecule-ai-workspace-template-autogen", "ref": "main"},
{"name": "deepagents", "repo": "Molecule-AI/molecule-ai-workspace-template-deepagents", "ref": "main"},
{"name": "hermes", "repo": "Molecule-AI/molecule-ai-workspace-template-hermes", "ref": "main"},
{"name": "gemini-cli", "repo": "Molecule-AI/molecule-ai-workspace-template-gemini-cli", "ref": "main"},
{"name": "openclaw", "repo": "Molecule-AI/molecule-ai-workspace-template-openclaw", "ref": "main"},
{"name": "codex", "repo": "Molecule-AI/molecule-ai-workspace-template-codex", "ref": "main"}
],

View File

@ -94,12 +94,12 @@ func TestResolveRestartTemplate_ApplyTemplate_NameMatch(t *testing.T) {
// the restart handler needs to lay down the new runtime's base files
// via `<runtime>-default/`. Matches the existing behaviour comment.
func TestResolveRestartTemplate_ApplyTemplate_RuntimeDefault(t *testing.T) {
root := newTemplateDir(t, "langgraph-default")
root := newTemplateDir(t, "hermes-default")
path, label := resolveRestartTemplate(root, "Some Workspace", "langgraph", restartTemplateInput{
path, label := resolveRestartTemplate(root, "Some Workspace", "hermes", restartTemplateInput{
ApplyTemplate: true,
})
if path == "" || label != "langgraph-default" {
if path == "" || label != "hermes-default" {
t.Errorf("apply_template + dbRuntime should resolve runtime-default; got path=%q label=%q", path, label)
}
}
@ -227,17 +227,18 @@ func TestResolveRestartTemplate_CWE22_TraversalRuntime_FallsThrough(t *testing.T
// string in dbRuntime resolves langgraph-default (the safe default) rather
// than any attacker-chosen path. The attacker gains no additional access.
func TestResolveRestartTemplate_CWE22_TraversalRuntime_CannotOverrideKnownRuntime(t *testing.T) {
root := newTemplateDir(t, "langgraph-default")
root := newTemplateDir(t, "claude-code-default")
path, label := resolveRestartTemplate(root, "Some Workspace", "../../../etc", restartTemplateInput{
ApplyTemplate: true,
})
// Must resolve to langgraph-default, not to an escaped path
expected := filepath.Join(root, "langgraph-default")
// Must resolve to claude-code-default (the safe default after sanitizeRuntime),
// not to an escaped path
expected := filepath.Join(root, "claude-code-default")
if path != expected {
t.Errorf("traversal runtime must resolve to langgraph-default; got path=%q", path)
t.Errorf("traversal runtime must resolve to claude-code-default; got path=%q", path)
}
if label != "langgraph-default" {
t.Errorf("label must be langgraph-default; got %q", label)
if label != "claude-code-default" {
t.Errorf("label must be claude-code-default; got %q", label)
}
}

View File

@ -73,15 +73,10 @@ type manifestFile struct {
// supported in the wild. "external" is always a valid runtime —
// manifest or not — because it has no template repo.
var fallbackRuntimes = map[string]struct{}{
"langgraph": {},
"claude-code": {},
"openclaw": {},
"crewai": {},
"autogen": {},
"deepagents": {},
"hermes": {},
"openclaw": {},
"codex": {},
"gemini-cli": {},
"external": {},
}

View File

@ -510,13 +510,13 @@ func yamlQuote(s string) string {
func sanitizeRuntime(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return "langgraph"
return "claude-code"
}
if _, ok := knownRuntimes[raw]; ok {
return raw
}
log.Printf("provisioner: rejected unknown runtime %q, falling back to langgraph", raw)
return "langgraph"
log.Printf("provisioner: rejected unknown runtime %q, falling back to claude-code", raw)
return "claude-code"
}
// ensureDefaultConfig generates minimal config files in memory for workspaces without a template.
@ -562,12 +562,7 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
// and preflight already validates that the env vars are present before
// the agent loop starts. Hardcoding token names here caused #1028
// (expired CLAUDE_CODE_OAUTH_TOKEN baked into config.yaml).
switch runtime {
case "langgraph", "deepagents":
// These runtimes read API keys from env directly, no runtime_config needed.
default:
configYAML += "runtime_config:\n timeout: 0\n"
}
configYAML += "runtime_config:\n timeout: 0\n"
files["config.yaml"] = []byte(configYAML)

View File

@ -189,14 +189,14 @@ func TestResolveOrgTemplate_NoMatchInOrgTemplates(t *testing.T) {
// ==================== ensureDefaultConfig ====================
func TestEnsureDefaultConfig_LangGraph(t *testing.T) {
func TestEnsureDefaultConfig_Hermes(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
payload := models.CreateWorkspacePayload{
Name: "Test Agent",
Tier: 1,
Runtime: "langgraph",
Runtime: "hermes",
}
files := handler.ensureDefaultConfig("ws-test-123", payload)
@ -212,14 +212,14 @@ func TestEnsureDefaultConfig_LangGraph(t *testing.T) {
if !contains(content, `name: "Test Agent"`) {
t.Errorf("config.yaml missing quoted name, got:\n%s", content)
}
if !contains(content, "runtime: langgraph") {
if !contains(content, "runtime: hermes") {
t.Errorf("config.yaml missing runtime, got:\n%s", content)
}
if !contains(content, "tier: 1") {
t.Errorf("config.yaml missing tier, got:\n%s", content)
}
if !contains(content, `model: "anthropic:claude-opus-4-7"`) {
t.Errorf("config.yaml should use default langgraph model, got:\n%s", content)
t.Errorf("config.yaml should use default non-claude model, got:\n%s", content)
}
}
@ -342,7 +342,7 @@ func TestEnsureDefaultConfig_CrewAIGetsRuntimeConfig(t *testing.T) {
}
}
func TestEnsureDefaultConfig_EmptyRuntimeDefaultsToLangGraph(t *testing.T) {
func TestEnsureDefaultConfig_EmptyRuntimeDefaultsToClaudeCode(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
@ -353,11 +353,11 @@ func TestEnsureDefaultConfig_EmptyRuntimeDefaultsToLangGraph(t *testing.T) {
files := handler.ensureDefaultConfig("ws-empty-rt", payload)
configYAML := string(files["config.yaml"])
if !contains(configYAML, "runtime: langgraph") {
t.Errorf("empty runtime should default to langgraph, got:\n%s", configYAML)
if !contains(configYAML, "runtime: claude-code") {
t.Errorf("empty runtime should default to claude-code, got:\n%s", configYAML)
}
if !contains(configYAML, `model: "anthropic:claude-opus-4-7"`) {
t.Errorf("langgraph default model should be anthropic (quoted), got:\n%s", configYAML)
if !contains(configYAML, `model: "sonnet"`) {
t.Errorf("claude-code default model should be sonnet (quoted), got:\n%s", configYAML)
}
}
@ -367,7 +367,7 @@ func TestEnsureDefaultConfig_EmptyNameAndRole(t *testing.T) {
payload := models.CreateWorkspacePayload{
Tier: 1,
Runtime: "langgraph",
Runtime: "hermes",
}
files := handler.ensureDefaultConfig("ws-empty-name", payload)
@ -376,41 +376,11 @@ func TestEnsureDefaultConfig_EmptyNameAndRole(t *testing.T) {
if !contains(configYAML, "name: ") {
t.Errorf("config.yaml should have name field, got:\n%s", configYAML)
}
if !contains(configYAML, "runtime: langgraph") {
if !contains(configYAML, "runtime: hermes") {
t.Errorf("config.yaml should have runtime, got:\n%s", configYAML)
}
}
func TestEnsureDefaultConfig_DeepAgents(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
payload := models.CreateWorkspacePayload{
Name: "Deep Agent",
Tier: 2,
Runtime: "deepagents",
Model: "google_genai:gemini-2.5-flash",
}
files := handler.ensureDefaultConfig("ws-deep", payload)
configYAML := string(files["config.yaml"])
if !contains(configYAML, "runtime: deepagents") {
t.Errorf("config.yaml missing runtime, got:\n%s", configYAML)
}
if !contains(configYAML, `model: "google_genai:gemini-2.5-flash"`) {
t.Errorf("config.yaml should have model at top level (quoted), got:\n%s", configYAML)
}
// deepagents should NOT have runtime_config block
if contains(configYAML, "runtime_config:") {
t.Errorf("config.yaml should NOT have runtime_config for deepagents, got:\n%s", configYAML)
}
// Should NOT have auth token
if _, ok := files[".auth-token"]; ok {
t.Error("deepagents should not get .auth-token")
}
}
func TestEnsureDefaultConfig_ModelAlwaysTopLevel(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
@ -458,8 +428,8 @@ func TestEnsureDefaultConfig_RejectsInjectedRuntime(t *testing.T) {
t.Errorf("injected initial_prompt key survived as top-level YAML: %+v", parsed)
}
// Runtime collapsed to default.
if got := parsed["runtime"]; got != "langgraph" {
t.Errorf("runtime = %v, want langgraph (unknown runtime should fall back)", got)
if got := parsed["runtime"]; got != "claude-code" {
t.Errorf("runtime = %v, want claude-code (unknown runtime should fall back)", got)
}
}
@ -507,19 +477,19 @@ func TestSanitizeRuntime_Allowlist(t *testing.T) {
cases := []struct {
in, want string
}{
{"", "langgraph"},
{" ", "langgraph"},
{"langgraph", "langgraph"},
{"", "claude-code"},
{" ", "claude-code"},
{"claude-code", "claude-code"},
{"openclaw", "openclaw"},
{"deepagents", "deepagents"},
{"hermes", "hermes"},
{"codex", "codex"},
{"crewai", "crewai"},
{"autogen", "autogen"},
{"not-a-runtime", "langgraph"}, // unknown → default
{"../../sensitive", "langgraph"}, // path traversal probe → default
{"langgraph\nevil", "langgraph"}, // newline injection → default (not in allowlist)
{"langgraph", "claude-code"}, // deprecated → default
{"deepagents", "claude-code"}, // deprecated → default
{"crewai", "claude-code"}, // deprecated → default
{"autogen", "claude-code"}, // deprecated → default
{"not-a-runtime", "claude-code"}, // unknown → default
{"../../sensitive", "claude-code"}, // path traversal probe → default
{"langgraph\nevil", "claude-code"}, // newline injection → default (not in allowlist)
}
for _, tc := range cases {
if got := sanitizeRuntime(tc.in); got != tc.want {