Files
molecule-core/workspace-server/internal/handlers/restart_template.go
T
claude-ceo-assistant f7e2976324
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 9s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
Check migration collisions / Migration version collision check (pull_request) Successful in 10s
CI / Detect changes (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Chat / detect-changes (pull_request) Successful in 7s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Harness Replays / detect-changes (pull_request) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 33s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 50s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 58s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Successful in 3s
security-review / approved (pull_request) Successful in 3s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 4s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m6s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m25s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 20s
E2E Chat / E2E Chat (pull_request) Successful in 33s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m58s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m44s
Harness Replays / Harness Replays (pull_request) Successful in 6s
CI / Platform (Go) (pull_request) Successful in 6m9s
CI / Canvas (Next.js) (pull_request) Successful in 7m41s
CI / all-required (pull_request) Successful in 32m0s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Successful in 32s
chore: retire unmaintained workspace runtimes
2026-05-23 23:45:09 -07:00

109 lines
4.8 KiB
Go

package handlers
import (
"log"
"os"
"path/filepath"
)
// restartTemplateInput is the subset of the /workspaces/:id/restart request
// body that affects which config source the provisioner uses. Extracted as
// a type so `resolveRestartTemplate` has a single pure-function signature
// for unit tests — no gin context, no DB, no filesystem writes.
type restartTemplateInput struct {
// Template is an explicit template dir name from the request body.
// Always honoured when resolvable — caller asked by name, that's
// unambiguous consent to overwrite the config volume.
Template string
// ApplyTemplate opts the caller in to name-based auto-match AND the
// runtime-default fallback. Without this flag a restart MUST NOT
// overwrite the user's config volume — a user who edited their
// model/provider/skills/prompts via the Canvas Config tab and hit
// Save+Restart expects their edits to survive. The previous behaviour
// (name-based auto-match unconditionally) silently reverted edits for
// any workspace whose name matched a template dir (e.g. "Hermes Agent"
// → hermes/), which is the regression this fix closes.
ApplyTemplate bool
// RebuildConfig (#239) is the recovery signal used when the workspace's
// config volume was destroyed out-of-band. Tries org-templates as a
// last-resort source so the workspace can self-heal without admin
// intervention. Orthogonal to ApplyTemplate.
RebuildConfig bool
}
// resolveRestartTemplate chooses the config source for a restart in the
// documented priority order:
//
// 1. Explicit `Template` from the request body (always honoured).
// 2. `ApplyTemplate=true` → name-based auto-match via findTemplateByName.
// 3. `RebuildConfig=true` → org-templates recovery fallback (#239).
// 4. `ApplyTemplate=true` + non-empty dbRuntime → runtime-default template
// (e.g. `hermes-default/`) for runtime-change workflows.
// 5. Fall through → empty path + "existing-volume" label. Provisioner
// reuses the workspace's existing config volume from the previous run.
//
// Returns (templatePath, configLabel). An empty templatePath is the signal
// to the provisioner that the existing volume is authoritative — the flow
// that preserves user edits.
//
// Pure function: no writes, no DB access, no network. Safe to unit-test
// with just a temp directory.
func resolveRestartTemplate(configsDir, wsName, dbRuntime string, body restartTemplateInput) (templatePath, configLabel string) {
template := body.Template
// Tier 2: name-based auto-match, gated on ApplyTemplate.
if template == "" && body.ApplyTemplate {
template = findTemplateByName(configsDir, wsName)
}
// Tier 1 + 2 resolve via the same code path — validate + stat.
if template != "" {
candidatePath, resolveErr := resolveInsideRoot(configsDir, template)
if resolveErr != nil {
log.Printf("Restart: invalid template %q: %v — proceeding without it", template, resolveErr)
} else if _, err := os.Stat(candidatePath); err == nil {
return candidatePath, template
} else {
log.Printf("Restart: template %q dir not found — proceeding without it", template)
}
}
// Tier 3: #239 rebuild_config — org-templates as last-resort recovery.
if body.RebuildConfig {
if p, label := resolveOrgTemplate(configsDir, wsName); p != "" {
log.Printf("Restart: rebuild_config — using org-template %s (%s)", label, wsName)
return p, label
}
}
// Tier 4: runtime-default — apply_template=true + known runtime.
// Use case: Canvas Config tab changed the runtime; we need the new
// runtime's base files (entry point, Dockerfile, skill scaffolding)
// because the existing volume was written by the old runtime.
//
// SECURITY (CWE-22 / F1502): dbRuntime comes from the workspaces DB
// column — set by the PATCH Update handler which only validates length
// and newlines, not path-traversal characters. Without sanitisation an
// attacker who holds a workspace token could set runtime to
// "../../../etc" and, if a directory matching that path existed on the
// host, load an arbitrary host directory as the workspace template.
//
// sanitizeRuntime applies an allowlist of known runtimes; any unknown
// value (including traversal strings) is remapped to "claude-code". The
// attacker cannot choose an arbitrary host path — they can at most
// trigger application of the claude-code-default template.
if body.ApplyTemplate && dbRuntime != "" {
safeRuntime := sanitizeRuntime(dbRuntime)
runtimeTemplate := filepath.Join(configsDir, safeRuntime+"-default")
if _, err := os.Stat(runtimeTemplate); err == nil {
label := safeRuntime + "-default"
log.Printf("Restart: applying template %s (runtime change)", label)
return runtimeTemplate, label
}
}
// Tier 5: reuse existing volume. This is the default, and the path
// the Canvas Save+Restart flow MUST hit to preserve user edits.
return "", "existing-volume"
}