- Remove compiled workspace-server/server binary from git - Fix .gitignore, .gitattributes, .githooks/pre-commit for renamed dirs - Fix CI workflow path filters (workspace-template → workspace) - Replace real EC2 IP and personal slug in test_saas_tenant.sh - Scrub molecule-controlplane references in docs - Fix stale workspace-template/ paths in provisioner, handlers, tests - Clean tracked Python cache files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1052 lines
39 KiB
Go
1052 lines
39 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/channels"
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/crypto"
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/scheduler"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// OrgHandler manages org template import/export.
|
|
// workspaceCreatePacingMs is the brief delay between sibling workspace creations
|
|
// during org import. Prevents overwhelming Docker when creating many containers.
|
|
const workspaceCreatePacingMs = 50
|
|
|
|
// orgImportScheduleSQL is the upsert executed for every schedule during
|
|
// org/import. Extracted to a const so TestImport_OrgScheduleSQLShape can
|
|
// assert its shape without regex-scanning org.go (issue #24 follow-up).
|
|
//
|
|
// Guarantees, in one statement:
|
|
// - INSERT new rows with source='template'
|
|
// - On (workspace_id, name) collision, only refresh template-source rows
|
|
// (runtime-added schedules are preserved across re-imports)
|
|
// - No DELETE — removal is out of scope (additive semantics)
|
|
const orgImportScheduleSQL = `
|
|
INSERT INTO workspace_schedules (workspace_id, name, cron_expr, timezone, prompt, enabled, next_run_at, source)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'template')
|
|
ON CONFLICT (workspace_id, name) DO UPDATE
|
|
SET cron_expr = EXCLUDED.cron_expr,
|
|
timezone = EXCLUDED.timezone,
|
|
prompt = EXCLUDED.prompt,
|
|
enabled = EXCLUDED.enabled,
|
|
next_run_at = EXCLUDED.next_run_at,
|
|
updated_at = now()
|
|
WHERE workspace_schedules.source = 'template'
|
|
`
|
|
|
|
type OrgHandler struct {
|
|
workspace *WorkspaceHandler
|
|
broadcaster *events.Broadcaster
|
|
provisioner *provisioner.Provisioner
|
|
channelMgr *channels.Manager
|
|
configsDir string
|
|
orgDir string // path to org-templates/
|
|
}
|
|
|
|
func NewOrgHandler(wh *WorkspaceHandler, b *events.Broadcaster, p *provisioner.Provisioner, channelMgr *channels.Manager, configsDir, orgDir string) *OrgHandler {
|
|
return &OrgHandler{
|
|
workspace: wh,
|
|
broadcaster: b,
|
|
provisioner: p,
|
|
channelMgr: channelMgr,
|
|
configsDir: configsDir,
|
|
orgDir: orgDir,
|
|
}
|
|
}
|
|
|
|
// OrgTemplate is the YAML structure for an org hierarchy.
|
|
type OrgTemplate struct {
|
|
Name string `yaml:"name" json:"name"`
|
|
Description string `yaml:"description" json:"description"`
|
|
Defaults OrgDefaults `yaml:"defaults" json:"defaults"`
|
|
Workspaces []OrgWorkspace `yaml:"workspaces" json:"workspaces"`
|
|
}
|
|
|
|
type OrgDefaults struct {
|
|
Runtime string `yaml:"runtime" json:"runtime"`
|
|
Tier int `yaml:"tier" json:"tier"`
|
|
Model string `yaml:"model" json:"model"`
|
|
Plugins []string `yaml:"plugins" json:"plugins"`
|
|
InitialPrompt string `yaml:"initial_prompt" json:"initial_prompt"`
|
|
// InitialPromptFile is a file ref alternative to InitialPrompt. Path is
|
|
// resolved relative to the workspace's files_dir (or the org base dir
|
|
// when used at defaults level — defaults don't have their own files_dir,
|
|
// so the file must live at the org root). Inline InitialPrompt wins
|
|
// when both are set.
|
|
InitialPromptFile string `yaml:"initial_prompt_file" json:"initial_prompt_file"`
|
|
// IdlePrompt / IdleIntervalSeconds are the workspace-default idle-loop
|
|
// body and cadence (see workspace/heartbeat.py). They were
|
|
// previously dropped by the org importer because the struct didn't
|
|
// declare them — causing live configs to boot without idle_prompts
|
|
// even when org.yaml had them. Phase 1 scalability work adds both
|
|
// inline + file-ref forms.
|
|
IdlePrompt string `yaml:"idle_prompt" json:"idle_prompt"`
|
|
IdlePromptFile string `yaml:"idle_prompt_file" json:"idle_prompt_file"`
|
|
IdleIntervalSeconds int `yaml:"idle_interval_seconds" json:"idle_interval_seconds"`
|
|
// CategoryRouting maps issue/audit category → list of target roles.
|
|
// Per-workspace blocks UNION + override per-key with these defaults.
|
|
// Rendered into each workspace's config.yaml so agent prompts can read it
|
|
// generically (no hardcoded role names in prompts). See issue #51.
|
|
CategoryRouting map[string][]string `yaml:"category_routing" json:"category_routing"`
|
|
}
|
|
|
|
type OrgSchedule struct {
|
|
Name string `yaml:"name" json:"name"`
|
|
CronExpr string `yaml:"cron_expr" json:"cron_expr"`
|
|
Timezone string `yaml:"timezone" json:"timezone"`
|
|
Prompt string `yaml:"prompt" json:"prompt"`
|
|
// PromptFile is a file ref alternative to inline Prompt. Path is
|
|
// resolved relative to the workspace's files_dir. Inline Prompt wins
|
|
// when both are set. Scalability: hourly/weekly cron prompts are the
|
|
// largest text bodies in org.yaml (~1-5 KB each); externalizing them
|
|
// cuts the file by ~62%.
|
|
PromptFile string `yaml:"prompt_file" json:"prompt_file"`
|
|
Enabled *bool `yaml:"enabled" json:"enabled"`
|
|
}
|
|
|
|
// OrgChannel defines a social channel (Telegram, Slack, etc.) to auto-link
|
|
// when the workspace is created. Config values may reference env vars
|
|
// using ${VAR_NAME} syntax — useful for keeping bot tokens out of YAML.
|
|
type OrgChannel struct {
|
|
Type string `yaml:"type" json:"type"`
|
|
Config map[string]string `yaml:"config" json:"config"`
|
|
AllowedUsers []string `yaml:"allowed_users" json:"allowed_users"`
|
|
Enabled *bool `yaml:"enabled" json:"enabled"`
|
|
}
|
|
|
|
type OrgWorkspace struct {
|
|
Name string `yaml:"name" json:"name"`
|
|
Role string `yaml:"role" json:"role"`
|
|
Runtime string `yaml:"runtime" json:"runtime"`
|
|
Tier int `yaml:"tier" json:"tier"`
|
|
Template string `yaml:"template" json:"template"`
|
|
FilesDir string `yaml:"files_dir" json:"files_dir"`
|
|
// SystemPrompt is an inline override. Normally each role's system-prompt.md
|
|
// lives at `<files_dir>/system-prompt.md` and is copied via the files_dir
|
|
// template-copy step; inline overrides that path for ad-hoc workspaces.
|
|
SystemPrompt string `yaml:"system_prompt" json:"system_prompt"`
|
|
Model string `yaml:"model" json:"model"`
|
|
WorkspaceDir string `yaml:"workspace_dir" json:"workspace_dir"`
|
|
WorkspaceAccess string `yaml:"workspace_access" json:"workspace_access"` // #65: "none" (default), "read_only", "read_write"
|
|
Plugins []string `yaml:"plugins" json:"plugins"`
|
|
// InitialPrompt is the one-shot boot prompt. Agents run this once on first
|
|
// start; the body often clones the repo, reads CLAUDE.md + system-prompt,
|
|
// and commits conventions to memory. InitialPromptFile is the file-ref
|
|
// alternative — read at import time from `<files_dir>/<InitialPromptFile>`.
|
|
// Inline wins when both are set.
|
|
InitialPrompt string `yaml:"initial_prompt" json:"initial_prompt"`
|
|
InitialPromptFile string `yaml:"initial_prompt_file" json:"initial_prompt_file"`
|
|
// IdlePrompt / IdleIntervalSeconds drive the idle-loop reflection
|
|
// pattern (#205). When IdlePrompt is non-empty, the workspace self-sends
|
|
// this prompt every IdleIntervalSeconds while heartbeat.active_tasks == 0.
|
|
// Both fields were previously dropped by the org importer (struct didn't
|
|
// declare them); Phase 1 scalability PR adds them so engineer + researcher
|
|
// idle loops propagate correctly from org.yaml → /configs/config.yaml.
|
|
// IdlePromptFile is the file-ref alternative — same semantics as
|
|
// InitialPromptFile. Inline wins when both are set.
|
|
IdlePrompt string `yaml:"idle_prompt" json:"idle_prompt"`
|
|
IdlePromptFile string `yaml:"idle_prompt_file" json:"idle_prompt_file"`
|
|
IdleIntervalSeconds int `yaml:"idle_interval_seconds" json:"idle_interval_seconds"`
|
|
// CategoryRouting extends/overrides defaults.category_routing per-workspace.
|
|
// Merge semantics: workspace keys replace defaults' value for the same key
|
|
// (empty list drops the category entirely); new keys are added. See
|
|
// mergeCategoryRouting.
|
|
CategoryRouting map[string][]string `yaml:"category_routing" json:"category_routing"`
|
|
Schedules []OrgSchedule `yaml:"schedules" json:"schedules"`
|
|
Channels []OrgChannel `yaml:"channels" json:"channels"`
|
|
External bool `yaml:"external" json:"external"`
|
|
URL string `yaml:"url" json:"url"`
|
|
Canvas struct {
|
|
X float64 `yaml:"x" json:"x"`
|
|
Y float64 `yaml:"y" json:"y"`
|
|
} `yaml:"canvas" json:"canvas"`
|
|
Children []OrgWorkspace `yaml:"children" json:"children"`
|
|
}
|
|
|
|
// ListTemplates handles GET /org/templates — lists available org templates.
|
|
func (h *OrgHandler) ListTemplates(c *gin.Context) {
|
|
templates := []map[string]interface{}{}
|
|
|
|
entries, err := os.ReadDir(h.orgDir)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, templates)
|
|
return
|
|
}
|
|
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
// Look for org.yaml inside the directory
|
|
templateDir := filepath.Join(h.orgDir, e.Name())
|
|
orgFile := filepath.Join(templateDir, "org.yaml")
|
|
data, err := os.ReadFile(orgFile)
|
|
if err != nil {
|
|
// Try org.yml
|
|
orgFile = filepath.Join(templateDir, "org.yml")
|
|
data, err = os.ReadFile(orgFile)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
}
|
|
// Expand !include directives before unmarshal so templates that
|
|
// split across team/role files still report an accurate workspace
|
|
// count on the /org/templates listing.
|
|
if expanded, err := resolveYAMLIncludes(data, templateDir); err == nil {
|
|
data = expanded
|
|
}
|
|
var tmpl OrgTemplate
|
|
if err := yaml.Unmarshal(data, &tmpl); err != nil {
|
|
continue
|
|
}
|
|
count := countWorkspaces(tmpl.Workspaces)
|
|
templates = append(templates, map[string]interface{}{
|
|
"dir": e.Name(),
|
|
"name": tmpl.Name,
|
|
"description": tmpl.Description,
|
|
"workspaces": count,
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, templates)
|
|
}
|
|
|
|
// Import handles POST /org/import — creates an entire org from a template.
|
|
func (h *OrgHandler) Import(c *gin.Context) {
|
|
var body struct {
|
|
Dir string `json:"dir"` // org template directory name
|
|
Template OrgTemplate `json:"template"` // or inline template
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
var tmpl OrgTemplate
|
|
var orgBaseDir string // base directory for files_dir resolution
|
|
|
|
if body.Dir != "" {
|
|
// Reject traversal attempts — `dir` must resolve inside h.orgDir.
|
|
// Without this, `dir: "../../../etc"` gets joined into h.orgDir and
|
|
// filepath.Join's lexical cleanup resolves it outside the root,
|
|
// letting an unauthenticated caller probe arbitrary filesystem paths.
|
|
resolved, err := resolveInsideRoot(h.orgDir, body.Dir)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid dir: %v", err)})
|
|
return
|
|
}
|
|
orgBaseDir = resolved
|
|
orgFile := filepath.Join(orgBaseDir, "org.yaml")
|
|
data, err := os.ReadFile(orgFile)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("org template not found: %s", body.Dir)})
|
|
return
|
|
}
|
|
// Expand !include directives before unmarshal. Splits org.yaml
|
|
// into per-team or per-role files; Phase 3 of the scalability
|
|
// refactor. Fails loudly on missing / cyclic / escaping includes.
|
|
expanded, err := resolveYAMLIncludes(data, orgBaseDir)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("!include expansion failed: %v", err)})
|
|
return
|
|
}
|
|
if err := yaml.Unmarshal(expanded, &tmpl); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid YAML: %v", err)})
|
|
return
|
|
}
|
|
} else if body.Template.Name != "" {
|
|
tmpl = body.Template
|
|
} else {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "provide 'dir' or 'template'"})
|
|
return
|
|
}
|
|
|
|
results := []map[string]interface{}{}
|
|
var createErr error
|
|
|
|
// Recursively create workspaces
|
|
for _, ws := range tmpl.Workspaces {
|
|
if err := h.createWorkspaceTree(ws, nil, tmpl.Defaults, orgBaseDir, &results); err != nil {
|
|
createErr = err
|
|
break
|
|
}
|
|
}
|
|
|
|
// Hot-reload channel manager once after all channels are inserted
|
|
// (instead of per-workspace, avoiding N redundant DB queries + diffs).
|
|
if h.channelMgr != nil {
|
|
hasAnyChannels := false
|
|
for _, r := range results {
|
|
if _, ok := r["channels"]; ok {
|
|
hasAnyChannels = true
|
|
break
|
|
}
|
|
}
|
|
if hasAnyChannels {
|
|
h.channelMgr.Reload(context.Background())
|
|
}
|
|
}
|
|
|
|
status := http.StatusCreated
|
|
resp := gin.H{
|
|
"org": tmpl.Name,
|
|
"workspaces": results,
|
|
"count": len(results),
|
|
}
|
|
if createErr != nil {
|
|
status = http.StatusMultiStatus
|
|
resp["error"] = createErr.Error()
|
|
}
|
|
|
|
log.Printf("Org import: %s — %d workspaces created", tmpl.Name, len(results))
|
|
c.JSON(status, resp)
|
|
}
|
|
|
|
// createWorkspaceTree recursively creates a workspace and its children.
|
|
func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, defaults OrgDefaults, orgBaseDir string, results *[]map[string]interface{}) error {
|
|
// Apply defaults
|
|
runtime := ws.Runtime
|
|
if runtime == "" {
|
|
runtime = defaults.Runtime
|
|
}
|
|
if runtime == "" {
|
|
runtime = "langgraph"
|
|
}
|
|
model := ws.Model
|
|
if model == "" {
|
|
model = defaults.Model
|
|
}
|
|
if model == "" {
|
|
if runtime == "claude-code" {
|
|
model = "sonnet"
|
|
} else {
|
|
model = "anthropic:claude-opus-4-7"
|
|
}
|
|
}
|
|
tier := ws.Tier
|
|
if tier == 0 {
|
|
tier = defaults.Tier
|
|
}
|
|
if tier == 0 {
|
|
tier = 2
|
|
}
|
|
|
|
id := uuid.New().String()
|
|
awarenessNS := workspaceAwarenessNamespace(id)
|
|
|
|
var role interface{}
|
|
if ws.Role != "" {
|
|
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 != "" {
|
|
if err := validateWorkspaceDir(ws.WorkspaceDir); err != nil {
|
|
return fmt.Errorf("workspace %s: %w", ws.Name, err)
|
|
}
|
|
workspaceDir = ws.WorkspaceDir
|
|
}
|
|
|
|
// #65: validate workspace_access (defaults to "none" when empty).
|
|
workspaceAccess := ws.WorkspaceAccess
|
|
if workspaceAccess == "" {
|
|
workspaceAccess = provisioner.WorkspaceAccessNone
|
|
}
|
|
if err := provisioner.ValidateWorkspaceAccess(workspaceAccess, ws.WorkspaceDir); err != nil {
|
|
return fmt.Errorf("workspace %s: %w", ws.Name, err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Insert workspace
|
|
_, err := db.DB.ExecContext(ctx, `
|
|
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
`, id, ws.Name, role, tier, runtime, awarenessNS, "provisioning", parentID, workspaceDir, workspaceAccess)
|
|
if err != nil {
|
|
log.Printf("Org import: failed to create %s: %v", ws.Name, err)
|
|
return fmt.Errorf("failed to create %s: %w", ws.Name, err)
|
|
}
|
|
|
|
// Canvas layout with coordinates from YAML
|
|
if _, err := db.DB.ExecContext(ctx, `INSERT INTO canvas_layouts (workspace_id, x, y) VALUES ($1, $2, $3)`, id, ws.Canvas.X, ws.Canvas.Y); err != nil {
|
|
log.Printf("Org import: canvas layout insert failed for %s: %v", ws.Name, err)
|
|
}
|
|
|
|
// Broadcast
|
|
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", id, map[string]interface{}{
|
|
"name": ws.Name, "tier": tier,
|
|
})
|
|
|
|
// Handle external workspaces
|
|
if ws.External {
|
|
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'online', url = $1 WHERE id = $2`, ws.URL, id); err != nil {
|
|
log.Printf("Org import: external workspace status update failed for %s: %v", ws.Name, err)
|
|
}
|
|
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", id, map[string]interface{}{
|
|
"name": ws.Name, "external": true,
|
|
})
|
|
} else if h.provisioner != nil {
|
|
// Provision container
|
|
payload := models.CreateWorkspacePayload{
|
|
Name: ws.Name, Tier: tier, Runtime: runtime, Model: model,
|
|
WorkspaceDir: ws.WorkspaceDir,
|
|
WorkspaceAccess: workspaceAccess,
|
|
}
|
|
templatePath := ""
|
|
if ws.Template != "" {
|
|
// `template` comes from the uploaded YAML — treat as untrusted.
|
|
// Only accept paths that stay inside h.configsDir.
|
|
if tp, err := resolveInsideRoot(h.configsDir, ws.Template); err == nil {
|
|
if _, statErr := os.Stat(tp); statErr == nil {
|
|
templatePath = tp
|
|
}
|
|
}
|
|
}
|
|
if templatePath == "" {
|
|
// #241: sanitizeRuntime() allowlists the runtime string so a
|
|
// crafted org.yaml cannot use it as a path-traversal oracle.
|
|
safeRuntime := sanitizeRuntime(runtime)
|
|
runtimeDefault := filepath.Join(h.configsDir, safeRuntime+"-default")
|
|
if _, err := os.Stat(runtimeDefault); err == nil {
|
|
templatePath = runtimeDefault
|
|
}
|
|
}
|
|
|
|
// Always generate default config.yaml (runtime, model, tier, etc.)
|
|
configFiles := h.workspace.ensureDefaultConfig(id, payload)
|
|
|
|
// Copy files_dir contents on top (system-prompt.md, CLAUDE.md, skills/, etc.)
|
|
// Uses templatePath for CopyTemplateToContainer — runs AFTER configFiles are written
|
|
if ws.FilesDir != "" && orgBaseDir != "" {
|
|
// `files_dir` also comes from untrusted YAML. Join inside orgBaseDir
|
|
// (already validated above) and reject anything that escapes.
|
|
if filesPath, err := resolveInsideRoot(orgBaseDir, ws.FilesDir); err == nil {
|
|
if info, statErr := os.Stat(filesPath); statErr == nil && info.IsDir() {
|
|
templatePath = filesPath
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pre-install plugins: copy from registry into configFiles as plugins/<name>/*.
|
|
// Per-workspace plugins UNION with defaults.plugins (issue #68).
|
|
// A leading "!" or "-" on a per-workspace entry opts that plugin out.
|
|
plugins := mergePlugins(defaults.Plugins, ws.Plugins)
|
|
if len(plugins) > 0 {
|
|
if configFiles == nil {
|
|
configFiles = map[string][]byte{}
|
|
}
|
|
pluginsBase, _ := filepath.Abs(filepath.Join(h.configsDir, "..", "plugins"))
|
|
for _, pluginName := range plugins {
|
|
pluginSrc := filepath.Join(pluginsBase, pluginName)
|
|
if info, err := os.Stat(pluginSrc); err != nil || !info.IsDir() {
|
|
log.Printf("Org import: plugin %s not found at %s, skipping", pluginName, pluginSrc)
|
|
continue
|
|
}
|
|
filepath.Walk(pluginSrc, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(pluginSrc, path)
|
|
data, readErr := os.ReadFile(path)
|
|
if readErr == nil {
|
|
configFiles["plugins/"+pluginName+"/"+rel] = data
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
}
|
|
|
|
// Render category_routing into config.yaml so the agent can read its routing
|
|
// table at runtime without hardcoded role names in prompts (issue #51).
|
|
// Per-workspace keys replace defaults per-key (empty list drops the key);
|
|
// see mergeCategoryRouting for exact semantics.
|
|
routing := mergeCategoryRouting(defaults.CategoryRouting, ws.CategoryRouting)
|
|
if len(routing) > 0 {
|
|
if configFiles == nil {
|
|
configFiles = map[string][]byte{}
|
|
}
|
|
block, err := renderCategoryRoutingYAML(routing)
|
|
if err != nil {
|
|
log.Printf("Org import: failed to render category_routing for %s: %v", ws.Name, err)
|
|
} else {
|
|
configFiles["config.yaml"] = appendYAMLBlock(configFiles["config.yaml"], block)
|
|
}
|
|
}
|
|
|
|
// Resolve initial_prompt — inline wins, then file-ref, then defaults
|
|
// (inline → file → defaults.inline → defaults.file). File refs are
|
|
// rooted at <orgBaseDir>/<files_dir>/ per resolvePromptRef semantics.
|
|
initialPrompt, err := resolvePromptRef(ws.InitialPrompt, ws.InitialPromptFile, orgBaseDir, ws.FilesDir)
|
|
if err != nil {
|
|
log.Printf("Org import: failed to resolve initial_prompt for %s: %v", ws.Name, err)
|
|
}
|
|
if initialPrompt == "" {
|
|
// Fall back to defaults. Defaults live at the org root, so they
|
|
// resolve with empty filesDir (relative to orgBaseDir).
|
|
var defaultErr error
|
|
initialPrompt, defaultErr = resolvePromptRef(defaults.InitialPrompt, defaults.InitialPromptFile, orgBaseDir, "")
|
|
if defaultErr != nil {
|
|
log.Printf("Org import: failed to resolve defaults.initial_prompt for %s: %v", ws.Name, defaultErr)
|
|
}
|
|
}
|
|
if initialPrompt != "" {
|
|
if configFiles == nil {
|
|
configFiles = map[string][]byte{}
|
|
}
|
|
// Append initial_prompt to config.yaml using YAML block scalar.
|
|
// Trim each line to avoid trailing whitespace issues.
|
|
trimmed := strings.TrimSpace(initialPrompt)
|
|
lines := strings.Split(trimmed, "\n")
|
|
for i, line := range lines {
|
|
lines[i] = strings.TrimRight(line, " \t")
|
|
}
|
|
indented := strings.Join(lines, "\n ")
|
|
configFiles["config.yaml"] = appendYAMLBlock(configFiles["config.yaml"], fmt.Sprintf("initial_prompt: |\n %s\n", indented))
|
|
log.Printf("Org import: injected initial_prompt (%d chars) into config.yaml for %s", len(trimmed), ws.Name)
|
|
}
|
|
|
|
// Resolve idle_prompt — same precedence (ws inline → ws file → defaults).
|
|
// Inject into config.yaml alongside idle_interval_seconds so the
|
|
// workspace's heartbeat loop picks up the idle-reflection cadence on
|
|
// boot (see workspace/heartbeat.py + config.py).
|
|
idlePrompt, err := resolvePromptRef(ws.IdlePrompt, ws.IdlePromptFile, orgBaseDir, ws.FilesDir)
|
|
if err != nil {
|
|
log.Printf("Org import: failed to resolve idle_prompt for %s: %v", ws.Name, err)
|
|
}
|
|
if idlePrompt == "" {
|
|
var defaultErr error
|
|
idlePrompt, defaultErr = resolvePromptRef(defaults.IdlePrompt, defaults.IdlePromptFile, orgBaseDir, "")
|
|
if defaultErr != nil {
|
|
log.Printf("Org import: failed to resolve defaults.idle_prompt for %s: %v", ws.Name, defaultErr)
|
|
}
|
|
}
|
|
idleInterval := ws.IdleIntervalSeconds
|
|
if idleInterval == 0 {
|
|
idleInterval = defaults.IdleIntervalSeconds
|
|
}
|
|
if idlePrompt != "" {
|
|
if configFiles == nil {
|
|
configFiles = map[string][]byte{}
|
|
}
|
|
trimmed := strings.TrimSpace(idlePrompt)
|
|
lines := strings.Split(trimmed, "\n")
|
|
for i, line := range lines {
|
|
lines[i] = strings.TrimRight(line, " \t")
|
|
}
|
|
indented := strings.Join(lines, "\n ")
|
|
// idle_interval_seconds belongs with idle_prompt — empty idle_prompt
|
|
// means the idle loop never fires regardless of interval, so we
|
|
// only emit interval when there's a body to go with it.
|
|
if idleInterval <= 0 {
|
|
idleInterval = 600 // same default as workspace/config.py
|
|
}
|
|
block := fmt.Sprintf("idle_interval_seconds: %d\nidle_prompt: |\n %s\n", idleInterval, indented)
|
|
configFiles["config.yaml"] = appendYAMLBlock(configFiles["config.yaml"], block)
|
|
log.Printf("Org import: injected idle_prompt (%d chars, interval=%ds) into config.yaml for %s", len(trimmed), idleInterval, ws.Name)
|
|
}
|
|
|
|
// Inline system_prompt (only if no files_dir provides one)
|
|
if ws.SystemPrompt != "" {
|
|
if configFiles == nil {
|
|
configFiles = map[string][]byte{}
|
|
}
|
|
configFiles["system-prompt.md"] = []byte(ws.SystemPrompt)
|
|
}
|
|
|
|
// Inject secrets from .env files as workspace secrets.
|
|
// Resolution: workspace .env → org root .env (workspace overrides org root).
|
|
// Each line: KEY=VALUE → stored as encrypted workspace secret.
|
|
envVars := map[string]string{}
|
|
if orgBaseDir != "" {
|
|
// 1. Org root .env (shared defaults)
|
|
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
|
|
// 2. Workspace-specific .env (overrides)
|
|
if ws.FilesDir != "" {
|
|
parseEnvFile(filepath.Join(orgBaseDir, ws.FilesDir, ".env"), envVars)
|
|
}
|
|
}
|
|
// Store as workspace secrets via DB (encrypted if key is set, raw otherwise)
|
|
for key, value := range envVars {
|
|
var encrypted []byte
|
|
if crypto.IsEnabled() {
|
|
var err error
|
|
encrypted, err = crypto.Encrypt([]byte(value))
|
|
if err != nil {
|
|
log.Printf("Org import: failed to encrypt secret %s for %s: %v", key, ws.Name, err)
|
|
continue
|
|
}
|
|
} else {
|
|
encrypted = []byte(value) // store raw when encryption disabled
|
|
}
|
|
if _, err := db.DB.ExecContext(ctx, `
|
|
INSERT INTO workspace_secrets (workspace_id, key, encrypted_value)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (workspace_id, key) DO UPDATE SET encrypted_value = $3, updated_at = now()
|
|
`, id, key, encrypted); err != nil {
|
|
log.Printf("Org import: failed to insert secret %s for %s: %v", key, ws.Name, err)
|
|
}
|
|
}
|
|
|
|
go h.workspace.provisionWorkspace(id, templatePath, configFiles, payload)
|
|
}
|
|
|
|
// Insert schedules if defined. Resolve each schedule's prompt body from
|
|
// either inline `prompt:` or `prompt_file:` (file ref relative to the
|
|
// workspace's files_dir). Inline wins; empty prompt after resolution is
|
|
// a configuration error (cron with no body would never do anything).
|
|
for _, sched := range ws.Schedules {
|
|
tz := sched.Timezone
|
|
if tz == "" {
|
|
tz = "UTC"
|
|
}
|
|
enabled := true
|
|
if sched.Enabled != nil {
|
|
enabled = *sched.Enabled
|
|
}
|
|
prompt, promptErr := resolvePromptRef(sched.Prompt, sched.PromptFile, orgBaseDir, ws.FilesDir)
|
|
if promptErr != nil {
|
|
log.Printf("Org import: failed to resolve prompt for schedule '%s' on %s: %v — skipping insert", sched.Name, ws.Name, promptErr)
|
|
continue
|
|
}
|
|
if prompt == "" {
|
|
log.Printf("Org import: schedule '%s' on %s has empty prompt (neither prompt nor prompt_file set) — skipping insert", sched.Name, ws.Name)
|
|
continue
|
|
}
|
|
// #722: surface the error rather than silently using time.Time{} (zero)
|
|
// which lib/pq stores as 0001-01-01 and may confuse the fire query.
|
|
nextRun, nextRunErr := scheduler.ComputeNextRun(sched.CronExpr, tz, time.Now())
|
|
if nextRunErr != nil {
|
|
log.Printf("Org import: invalid cron expression for schedule '%s' on %s: %v — skipping insert",
|
|
sched.Name, ws.Name, nextRunErr)
|
|
continue
|
|
}
|
|
if _, err := db.DB.ExecContext(context.Background(), orgImportScheduleSQL,
|
|
id, sched.Name, sched.CronExpr, tz, prompt, enabled, nextRun); err != nil {
|
|
log.Printf("Org import: failed to upsert schedule '%s' for %s: %v", sched.Name, ws.Name, err)
|
|
} else {
|
|
log.Printf("Org import: schedule '%s' (%s, %d chars) upserted for %s (source=template)", sched.Name, sched.CronExpr, len(prompt), ws.Name)
|
|
}
|
|
}
|
|
|
|
// Insert channels if defined (Telegram, Slack, etc.). Config values
|
|
// support ${VAR} expansion from .env files. The manager is reloaded
|
|
// once at the end of org import (in Import), not per-workspace.
|
|
channelEnv := loadWorkspaceEnv(orgBaseDir, ws.FilesDir)
|
|
wsChannelsCreated := []string{}
|
|
wsChannelsSkipped := []map[string]string{}
|
|
// skipChannel records a skipped channel with consistent shape across all reasons.
|
|
skipChannel := func(channelType, reason string) {
|
|
wsChannelsSkipped = append(wsChannelsSkipped, map[string]string{
|
|
"workspace": ws.Name,
|
|
"type": channelType, // empty string when type field was missing
|
|
"reason": reason,
|
|
})
|
|
}
|
|
|
|
for _, ch := range ws.Channels {
|
|
if ch.Type == "" {
|
|
skipChannel("", "empty type")
|
|
log.Printf("Org import: skipping channel with empty type for %s", ws.Name)
|
|
continue
|
|
}
|
|
// Validate adapter exists upfront — fail fast instead of inserting orphan rows
|
|
adapter, ok := channels.GetAdapter(ch.Type)
|
|
if !ok {
|
|
skipChannel(ch.Type, "unknown adapter")
|
|
log.Printf("Org import: skipping %s channel for %s — no adapter registered", ch.Type, ws.Name)
|
|
continue
|
|
}
|
|
|
|
expandedConfig := make(map[string]interface{}, len(ch.Config))
|
|
missing := []string{}
|
|
for k, v := range ch.Config {
|
|
expanded := expandWithEnv(v, channelEnv)
|
|
if hasUnresolvedVarRef(v, expanded) {
|
|
missing = append(missing, v)
|
|
}
|
|
expandedConfig[k] = expanded
|
|
}
|
|
if len(missing) > 0 {
|
|
skipChannel(ch.Type, fmt.Sprintf("missing env: %v", missing))
|
|
log.Printf("Org import: skipping %s channel for %s — env vars not set: %v", ch.Type, ws.Name, missing)
|
|
continue
|
|
}
|
|
|
|
// Adapter-level config validation
|
|
if err := adapter.ValidateConfig(expandedConfig); err != nil {
|
|
skipChannel(ch.Type, err.Error())
|
|
log.Printf("Org import: skipping %s channel for %s — invalid config: %v", ch.Type, ws.Name, err)
|
|
continue
|
|
}
|
|
|
|
configJSON, err := json.Marshal(expandedConfig)
|
|
if err != nil {
|
|
log.Printf("Org import: failed to marshal config for %s channel: %v", ch.Type, err)
|
|
continue
|
|
}
|
|
allowedJSON, err := json.Marshal(ch.AllowedUsers)
|
|
if err != nil {
|
|
log.Printf("Org import: failed to marshal allowed_users for %s channel: %v", ch.Type, err)
|
|
continue
|
|
}
|
|
enabled := true
|
|
if ch.Enabled != nil {
|
|
enabled = *ch.Enabled
|
|
}
|
|
// Idempotent insert — if same workspace+type already exists, update config
|
|
if _, err := db.DB.ExecContext(context.Background(), `
|
|
INSERT INTO workspace_channels (workspace_id, channel_type, channel_config, enabled, allowed_users)
|
|
VALUES ($1, $2, $3::jsonb, $4, $5::jsonb)
|
|
ON CONFLICT (workspace_id, channel_type) DO UPDATE
|
|
SET channel_config = EXCLUDED.channel_config,
|
|
enabled = EXCLUDED.enabled,
|
|
allowed_users = EXCLUDED.allowed_users,
|
|
updated_at = now()
|
|
`, id, ch.Type, string(configJSON), enabled, string(allowedJSON)); err != nil {
|
|
log.Printf("Org import: failed to create %s channel for %s: %v", ch.Type, ws.Name, err)
|
|
} else {
|
|
wsChannelsCreated = append(wsChannelsCreated, ch.Type)
|
|
log.Printf("Org import: %s channel created for %s", ch.Type, ws.Name)
|
|
}
|
|
}
|
|
|
|
resultEntry := map[string]interface{}{
|
|
"id": id,
|
|
"name": ws.Name,
|
|
"tier": tier,
|
|
}
|
|
if len(wsChannelsCreated) > 0 {
|
|
resultEntry["channels"] = wsChannelsCreated
|
|
}
|
|
if len(wsChannelsSkipped) > 0 {
|
|
resultEntry["channels_skipped"] = wsChannelsSkipped
|
|
}
|
|
*results = append(*results, resultEntry)
|
|
|
|
// Recurse into children. Brief pacing avoids overwhelming Docker when
|
|
// creating many containers in sequence; container provisioning runs in
|
|
// goroutines so the main createWorkspaceTree returns quickly.
|
|
for _, child := range ws.Children {
|
|
if err := h.createWorkspaceTree(child, &id, defaults, orgBaseDir, results); err != nil {
|
|
return err
|
|
}
|
|
time.Sleep(workspaceCreatePacingMs * time.Millisecond)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func countWorkspaces(workspaces []OrgWorkspace) int {
|
|
count := len(workspaces)
|
|
for _, ws := range workspaces {
|
|
count += countWorkspaces(ws.Children)
|
|
}
|
|
return count
|
|
}
|
|
|
|
// resolvePromptRef reads a prompt body from either an inline string or a
|
|
// file ref relative to the workspace's files_dir. Inline always wins when
|
|
// both are non-empty (caller-provided inline is more authoritative than a
|
|
// file path that may not exist yet during dev loops).
|
|
//
|
|
// File resolution:
|
|
// - `<orgBaseDir>/<filesDir>/<fileRef>` when filesDir is non-empty
|
|
// - `<orgBaseDir>/<fileRef>` when filesDir is empty (defaults-level refs)
|
|
//
|
|
// Both paths go through resolveInsideRoot so a crafted fileRef can't escape
|
|
// the org template directory via traversal (same defense the files_dir
|
|
// copy-step uses).
|
|
//
|
|
// Returns (resolved body, error). If both inline and fileRef are empty,
|
|
// returns ("", nil) — caller decides whether that's a problem.
|
|
func resolvePromptRef(inline, fileRef, orgBaseDir, filesDir string) (string, error) {
|
|
if inline != "" {
|
|
return inline, nil
|
|
}
|
|
if fileRef == "" {
|
|
return "", nil
|
|
}
|
|
if orgBaseDir == "" {
|
|
// Inline-only template (POST /org/import with a raw Template in the
|
|
// JSON body, not a dir). File refs can't be resolved — surface the
|
|
// problem rather than silently returning empty.
|
|
return "", fmt.Errorf("prompt_file %q requires a dir-based org template (no orgBaseDir in inline-template mode)", fileRef)
|
|
}
|
|
searchRoot := orgBaseDir
|
|
if filesDir != "" {
|
|
p, err := resolveInsideRoot(orgBaseDir, filesDir)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid files_dir %q: %w", filesDir, err)
|
|
}
|
|
searchRoot = p
|
|
}
|
|
abs, err := resolveInsideRoot(searchRoot, fileRef)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid prompt_file %q: %w", fileRef, err)
|
|
}
|
|
data, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read prompt_file %q: %w", fileRef, err)
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
// envVarRefPattern matches actual ${VAR} or $VAR references (not literal $).
|
|
// Used to detect unresolved placeholders without false positives like "$5".
|
|
var envVarRefPattern = regexp.MustCompile(`\$\{?[A-Za-z_][A-Za-z0-9_]*\}?`)
|
|
|
|
// hasUnresolvedVarRef returns true if the original string had a ${VAR} or $VAR
|
|
// reference that the expanded string didn't fully replace (i.e. the var was unset).
|
|
func hasUnresolvedVarRef(original, expanded string) bool {
|
|
if !envVarRefPattern.MatchString(original) {
|
|
return false // no var refs to resolve
|
|
}
|
|
// If expansion produced the same string and that string still has refs, unresolved.
|
|
// If expansion stripped them to "", also unresolved.
|
|
return expanded == "" || envVarRefPattern.MatchString(expanded)
|
|
}
|
|
|
|
// expandWithEnv expands ${VAR} and $VAR references in s using the env map.
|
|
// Falls back to the platform process env if a var isn't in the map.
|
|
func expandWithEnv(s string, env map[string]string) string {
|
|
return os.Expand(s, func(key string) string {
|
|
if v, ok := env[key]; ok {
|
|
return v
|
|
}
|
|
return os.Getenv(key)
|
|
})
|
|
}
|
|
|
|
// loadWorkspaceEnv reads the org root .env and the workspace-specific .env
|
|
// (workspace overrides org root). Used by both secret injection and channel
|
|
// config expansion.
|
|
func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string {
|
|
envVars := map[string]string{}
|
|
if orgBaseDir == "" {
|
|
return envVars
|
|
}
|
|
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
|
|
if filesDir != "" {
|
|
parseEnvFile(filepath.Join(orgBaseDir, filesDir, ".env"), envVars)
|
|
}
|
|
return envVars
|
|
}
|
|
|
|
// parseEnvFile reads a .env file and adds KEY=VALUE pairs to the map.
|
|
// Skips comments (#) and empty lines. Values can be quoted.
|
|
func parseEnvFile(path string, out map[string]string) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(parts[0])
|
|
value := strings.TrimSpace(parts[1])
|
|
// Strip surrounding quotes
|
|
if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) {
|
|
value = value[1 : len(value)-1]
|
|
}
|
|
if key != "" && value != "" {
|
|
out[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
// mergeCategoryRouting unions defaults.category_routing with per-workspace
|
|
// category_routing. Workspace-level keys override the default's value for that
|
|
// key (the role list is replaced wholesale, not unioned per-key, so a workspace
|
|
// can narrow a category — e.g. "infra: [DevOps Only]"). Empty role lists drop
|
|
// the category entirely. See issue #51.
|
|
func mergeCategoryRouting(defaultRouting, wsRouting map[string][]string) map[string][]string {
|
|
out := map[string][]string{}
|
|
for k, v := range defaultRouting {
|
|
if k == "" || len(v) == 0 {
|
|
continue
|
|
}
|
|
cp := make([]string, len(v))
|
|
copy(cp, v)
|
|
out[k] = cp
|
|
}
|
|
for k, v := range wsRouting {
|
|
if k == "" {
|
|
continue
|
|
}
|
|
if len(v) == 0 {
|
|
// Empty list = explicit "drop this category for this workspace"
|
|
delete(out, k)
|
|
continue
|
|
}
|
|
cp := make([]string, len(v))
|
|
copy(cp, v)
|
|
out[k] = cp
|
|
}
|
|
return out
|
|
}
|
|
|
|
// renderCategoryRoutingYAML emits a deterministic YAML block of the form:
|
|
//
|
|
// category_routing:
|
|
// security: [Backend Engineer, DevOps]
|
|
// ui: [Frontend Engineer]
|
|
//
|
|
// Keys are sorted for stable, test-friendly output. Uses yaml.Node + yaml.Marshal
|
|
// so role names containing YAML-reserved characters (colons, quotes, unicode line
|
|
// separators, etc.) are escaped by the YAML library — no ad-hoc quoting.
|
|
func renderCategoryRoutingYAML(routing map[string][]string) (string, error) {
|
|
if len(routing) == 0 {
|
|
return "", nil
|
|
}
|
|
keys := make([]string, 0, len(routing))
|
|
for k := range routing {
|
|
if k == "" {
|
|
continue
|
|
}
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
inner := &yaml.Node{Kind: yaml.MappingNode}
|
|
for _, k := range keys {
|
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: k}
|
|
valNode := &yaml.Node{Kind: yaml.SequenceNode, Style: yaml.FlowStyle}
|
|
for _, role := range routing[k] {
|
|
valNode.Content = append(valNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: role})
|
|
}
|
|
inner.Content = append(inner.Content, keyNode, valNode)
|
|
}
|
|
doc := &yaml.Node{Kind: yaml.MappingNode}
|
|
doc.Content = []*yaml.Node{
|
|
{Kind: yaml.ScalarNode, Value: "category_routing"},
|
|
inner,
|
|
}
|
|
out, err := yaml.Marshal(doc)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
// appendYAMLBlock concatenates a YAML fragment to an existing buffer, guaranteeing
|
|
// a newline boundary between them. Upstream code writes config.yaml in fragments
|
|
// (base template → category_routing → initial_prompt) and the base isn't
|
|
// guaranteed to end in \n, which would merge the last line into the next block.
|
|
func appendYAMLBlock(existing []byte, block string) []byte {
|
|
if len(existing) > 0 && existing[len(existing)-1] != '\n' {
|
|
existing = append(existing, '\n')
|
|
}
|
|
return append(existing, []byte(block)...)
|
|
}
|
|
|
|
// mergePlugins returns the union of defaults and per-workspace plugin lists
|
|
// (deduplicated, defaults first). A per-workspace entry starting with "!" or
|
|
// "-" opts that plugin OUT of the union. See issue #68.
|
|
func mergePlugins(defaultPlugins, wsPlugins []string) []string {
|
|
seen := map[string]bool{}
|
|
out := make([]string, 0, len(defaultPlugins)+len(wsPlugins))
|
|
for _, p := range defaultPlugins {
|
|
if p == "" || seen[p] {
|
|
continue
|
|
}
|
|
seen[p] = true
|
|
out = append(out, p)
|
|
}
|
|
for _, p := range wsPlugins {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(p, "!") || strings.HasPrefix(p, "-") {
|
|
target := strings.TrimLeft(p, "!-")
|
|
if target == "" {
|
|
continue
|
|
}
|
|
if seen[target] {
|
|
delete(seen, target)
|
|
filtered := out[:0]
|
|
for _, existing := range out {
|
|
if existing != target {
|
|
filtered = append(filtered, existing)
|
|
}
|
|
}
|
|
out = filtered
|
|
}
|
|
continue
|
|
}
|
|
if !seen[p] {
|
|
seen[p] = true
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// resolveInsideRoot joins `userPath` onto `root` and ensures the lexically
|
|
// cleaned result stays inside root. Rejects absolute paths outright and
|
|
// anything containing ".." that would escape the root.
|
|
//
|
|
// Both arguments are resolved to absolute paths via filepath.Abs before the
|
|
// prefix check so a root passed as a relative path still works correctly.
|
|
// Follows Go's standard pattern for SSRF-class path sanitization; using
|
|
// strings.HasPrefix on an absolute-path pair plus the separator guard rejects
|
|
// sibling directories that share a prefix (e.g. "/foo" vs "/foobar").
|
|
func resolveInsideRoot(root, userPath string) (string, error) {
|
|
if userPath == "" {
|
|
return "", fmt.Errorf("path is empty")
|
|
}
|
|
if filepath.IsAbs(userPath) {
|
|
return "", fmt.Errorf("absolute paths are not allowed")
|
|
}
|
|
absRoot, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return "", fmt.Errorf("root abs: %w", err)
|
|
}
|
|
joined := filepath.Join(absRoot, userPath)
|
|
absJoined, err := filepath.Abs(joined)
|
|
if err != nil {
|
|
return "", fmt.Errorf("joined abs: %w", err)
|
|
}
|
|
// Allow exact-root match (rare but valid) and any descendant.
|
|
if absJoined != absRoot && !strings.HasPrefix(absJoined, absRoot+string(filepath.Separator)) {
|
|
return "", fmt.Errorf("path escapes root")
|
|
}
|
|
return absJoined, nil
|
|
}
|