molecule-core/workspace-server/internal/handlers/templates.go
Hongming Wang 9c7b34cb7f fix(workspace files API): GET ReadFile via SSH-EIC for SaaS workspaces
Pre-fix WriteFile (templates.go:436) had an `instance_id != ""` branch
that dispatched to writeFileViaEIC (SSH through EC2 Instance Connect),
but ReadFile (templates.go:362) skipped that branch entirely. ReadFile
always tried `findContainer` (which only works for local-Docker
workspaces, not SaaS EC2-per-workspace ones) and fell through to
`resolveTemplateDir` (which returns the seed template, not the
persisted workspace state).

Net effect on production: every Canvas Config tab open against a
SaaS workspace returned 404 "No config.yaml found" because GET
couldn't see what PUT had written. Visible to users after PR #2781
("show-misconfigured-state") surfaced the 404 as an error UX.

Caught by the synth-E2E 7c gate's GET-back assertion, but
misdiagnosed as a "test bug" and the GET assertion was dropped in
PR #2783 (rather than fixed at the source). This PR closes the loop:

1. New `readFileViaEIC` helper in template_files_eic.go that mirrors
   writeFileViaEIC's SSH-via-EIC dance and runs `sudo -n cat <path>`.
   Returns os.ErrNotExist on missing file (cat exits 1 with empty
   stdout under `2>/dev/null`) so the handler maps it cleanly to 404.

2. ReadFile dispatch now mirrors WriteFile's: when `instance_id` is
   non-empty, use readFileViaEIC; otherwise fall through to the
   local-Docker / template-dir path.

3. ReadFile's DB query expanded to also select instance_id + runtime
   (was just name). Three sqlmock-based tests updated to match the
   new column shape; the existing local-Docker fallback path stays
   green by passing instance_id="" in the mock rows.

Follow-up (separate PR): the synth-E2E 7c gate should restore the
GET-back marker assertion now that the read/write paths are unified.
That'll also catch any future Files API regression in the round-trip.
This PR doesn't touch the gate to keep the scope tight.

Verification:
- go build ./... clean
- full handlers test suite green (0.4s for ReadFile subset; 5.8s
  full)
- The 3 ReadFile sqlmock tests still cover the local-Docker fallback
  (instance_id=""); SaaS EIC dispatch is covered by the upcoming
  re-enabled synth-E2E 7c GET assertion (deferred to follow-up)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:02:26 -07:00

638 lines
23 KiB
Go

package handlers
import (
"errors"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/docker/docker/client"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
// allowedRoots are the container paths that the Files API can browse.
var allowedRoots = map[string]bool{
"/configs": true,
"/workspace": true,
"/home": true,
"/plugins": true,
}
// maxUploadFiles limits the number of files in a single import/replace.
const maxUploadFiles = 200
type TemplatesHandler struct {
configsDir string
docker *client.Client
}
func NewTemplatesHandler(configsDir string, dockerCli *client.Client) *TemplatesHandler {
return &TemplatesHandler{configsDir: configsDir, docker: dockerCli}
}
// modelSpec describes a single supported model on a template: its id (sent
// to the runtime), a human-readable label, and the env vars that must be
// present for that model to work (e.g. API keys).
type modelSpec struct {
ID string `json:"id" yaml:"id"`
Name string `json:"name,omitempty" yaml:"name"`
RequiredEnv []string `json:"required_env,omitempty" yaml:"required_env"`
}
// providerRegistryEntry mirrors a row from a template's top-level
// `providers:` registry block (claude-code, hermes, etc.). Each entry
// fully describes one provider: its name, auth flow, the model id
// prefixes/aliases that route to it, an optional base_url override, and
// the env vars required to authenticate.
//
// This is the structured taxonomy the canvas's ProviderModelSelector
// comment anticipates ("Templates that ship explicit vendor metadata
// (future) should override the heuristic.") — surfacing it here lets
// the canvas drop its prefix-inference fallback for templates that ship
// an explicit registry. Templates without the block omit the field
// (omitempty); the canvas falls back to its current per-model
// required_env derivation.
type providerRegistryEntry struct {
Name string `json:"name" yaml:"name"`
AuthMode string `json:"auth_mode,omitempty" yaml:"auth_mode"`
ModelPrefixes []string `json:"model_prefixes,omitempty" yaml:"model_prefixes"`
ModelAliases []string `json:"model_aliases,omitempty" yaml:"model_aliases"`
BaseURL string `json:"base_url,omitempty" yaml:"base_url"`
AuthEnv []string `json:"auth_env,omitempty" yaml:"auth_env"`
}
type templateSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Tier int `json:"tier"`
Runtime string `json:"runtime"`
Model string `json:"model"`
Models []modelSpec `json:"models,omitempty"`
// RequiredEnv mirrors runtime_config.required_env from the template's
// config.yaml — the AND-required env vars the template declares at the
// runtime level (separate from per-model required_env). The canvas
// preflight uses this as the fallback provider when `models` is empty
// so provider picker stays data-driven instead of hardcoded in the UI.
RequiredEnv []string `json:"required_env,omitempty"`
// Providers is the runtime's own list of supported provider slugs,
// sourced from runtime_config.providers in the template's config.yaml.
// The canvas Config tab surfaces this as the Provider override
// dropdown (Option B PR-5). Data-driven so each runtime owns its own
// taxonomy — hermes-agent supports 20+ providers; claude-code only
// "anthropic"; gemini-cli only "gemini" — and a future runtime with
// a different vendor list doesn't need a canvas edit. Empty list →
// canvas falls back to deriving suggestions from `models[].id` slug
// prefixes (still adapter-driven, just inferred).
Providers []string `json:"providers,omitempty"`
// ProviderRegistry is the structured provider taxonomy from the
// template's TOP-LEVEL `providers:` block (separate from the
// runtime_config.providers slug list above). Each entry carries
// auth_env / model_prefixes / model_aliases / base_url so the canvas
// can render an authoritative Provider→Model cascade without
// re-deriving vendor metadata from per-model required_env tuples.
//
// Closes #235 (server-side enrichment): the `Providers []string`
// field shipped a name list but never the structured payload the
// canvas's ProviderModelSelector comment block anticipates as the
// override for its prefix-inference heuristic. Pre-existing
// templates without the top-level block omit the field
// (omitempty); the canvas's existing per-model fallback continues
// to work for them.
ProviderRegistry []providerRegistryEntry `json:"provider_registry,omitempty"`
Skills []string `json:"skills"`
SkillCount int `json:"skill_count"`
// ProvisionTimeoutSeconds lets a slow runtime declare its expected
// cold-boot duration in its template manifest. Canvas's
// ProvisioningTimeout banner respects this per-workspace via the
// `provision_timeout_ms` field in the workspace API response (#2054).
// 0 = template hasn't declared one, falls through to canvas's
// runtime-profile default.
ProvisionTimeoutSeconds int `json:"provision_timeout_seconds,omitempty"`
}
// resolveTemplateDir finds the template directory for a workspace on the host.
// Only resolves to actual templates (not ws-* dirs since those are now Docker volumes).
// Returns empty string if no matching template is found.
func (h *TemplatesHandler) resolveTemplateDir(wsName string) string {
nameDir := filepath.Join(h.configsDir, normalizeName(wsName))
if _, err := os.Stat(nameDir); err == nil {
return nameDir
}
// Search templates by config.yaml name field (e.g., org-pm has name: "PM")
if tmpl := findTemplateByName(h.configsDir, wsName); tmpl != "" {
return filepath.Join(h.configsDir, tmpl)
}
return ""
}
// List handles GET /templates
func (h *TemplatesHandler) List(c *gin.Context) {
templates := make([]templateSummary, 0)
walkTemplateConfigs(h.configsDir, func(id string, data []byte) {
var raw struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Tier int `yaml:"tier"`
Runtime string `yaml:"runtime"`
Model string `yaml:"model"`
Skills []string `yaml:"skills"`
// Top-level `providers:` block — structured registry. Distinct
// from runtime_config.providers (slug list) below. Both shapes
// coexist in production: claude-code ships the structured
// registry, hermes still uses the slug list. /templates surfaces
// both verbatim so each runtime owns its taxonomy.
Providers []providerRegistryEntry `yaml:"providers"`
RuntimeConfig struct {
Model string `yaml:"model"`
Models []modelSpec `yaml:"models"`
RequiredEnv []string `yaml:"required_env"`
Providers []string `yaml:"providers"`
ProvisionTimeoutSeconds int `yaml:"provision_timeout_seconds"`
} `yaml:"runtime_config"`
}
if err := yaml.Unmarshal(data, &raw); err != nil {
// Without this log a malformed config.yaml causes the
// template to silently disappear from /templates with no
// trace — the operator can't tell "excluded due to parse
// error" from "never existed." That matters more now that
// templates ship richer YAML shapes (top-level providers
// registry, models[] with required_env, etc.) where a
// type-shape mismatch on one field drops the whole entry.
log.Printf("templates list: skip %s: yaml.Unmarshal: %v", id, err)
return
}
// Model comes from either top-level (legacy) or runtime_config.model (current).
model := raw.Model
if model == "" {
model = raw.RuntimeConfig.Model
}
templates = append(templates, templateSummary{
ID: id,
Name: raw.Name,
Description: raw.Description,
Tier: raw.Tier,
Runtime: raw.Runtime,
Model: model,
Models: raw.RuntimeConfig.Models,
RequiredEnv: raw.RuntimeConfig.RequiredEnv,
Providers: raw.RuntimeConfig.Providers,
ProviderRegistry: raw.Providers,
Skills: raw.Skills,
SkillCount: len(raw.Skills),
ProvisionTimeoutSeconds: raw.RuntimeConfig.ProvisionTimeoutSeconds,
})
})
c.JSON(http.StatusOK, templates)
}
// ListFiles handles GET /workspaces/:id/files
// Lists files inside the running container's /configs directory (or /workspace, etc.).
// Falls back to host-side config templates directory when container isn't running.
func (h *TemplatesHandler) ListFiles(c *gin.Context) {
workspaceID := c.Param("id")
ctx := c.Request.Context()
// Query params:
// ?root= — base path in container (default: /configs)
// ?path= — subdirectory to list (relative to root, default: "")
// ?depth= — max depth to recurse (default: 1, max: 5)
rootPath := c.DefaultQuery("root", "/configs")
if !allowedRoots[rootPath] {
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
return
}
subPath := c.DefaultQuery("path", "")
if subPath != "" {
if err := validateRelPath(subPath); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
}
depth := 1
if d := c.Query("depth"); d != "" {
n, err := strconv.Atoi(d)
if err != nil || n < 1 || n > 5 {
c.JSON(http.StatusBadRequest, gin.H{"error": "depth must be 1-5"})
return
}
depth = n
}
listPath := rootPath
if subPath != "" {
listPath = rootPath + "/" + subPath
}
var wsName string
if err := db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
type fileEntry struct {
Path string `json:"path"`
Size int64 `json:"size"`
Dir bool `json:"dir"`
}
// Try container filesystem first
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
// Portable file listing: works on both GNU and BusyBox/Alpine.
// Uses find + sh -c stat to output TYPE|SIZE|PATH per line.
output, err := h.execInContainer(ctx, containerName, []string{
"sh", "-c",
fmt.Sprintf(`find '%s' -maxdepth %d -not -path '*/.git/*' -not -path '*/__pycache__/*' -not -path '*/node_modules/*' -not -name .DS_Store | while IFS= read -r f; do
rel="${f#'%s'/}"; [ "$rel" = '%s' ] && continue; [ -z "$rel" ] && continue
if [ -d "$f" ]; then echo "d|0|$rel"; else s=$(stat -c %%s "$f" 2>/dev/null || stat -f %%z "$f" 2>/dev/null || echo 0); echo "f|$s|$rel"; fi
done`, listPath, depth, listPath, listPath),
})
if err != nil {
log.Printf("Container file list failed, falling back to host: %v", err)
} else {
var files []fileEntry
for _, line := range strings.Split(output, "\n") {
parts := strings.SplitN(line, "|", 3)
if len(parts) != 3 || parts[2] == "" {
continue
}
size, _ := strconv.ParseInt(parts[1], 10, 64)
files = append(files, fileEntry{
Path: parts[2],
Size: size,
Dir: parts[0] == "d",
})
}
if files == nil {
files = []fileEntry{}
}
c.JSON(http.StatusOK, files)
return
}
}
// Fallback: host-side template dir (only for templates, not ws-* workspace volumes)
configDir := h.resolveTemplateDir(wsName)
if configDir == "" {
c.JSON(http.StatusOK, []fileEntry{})
return
}
walkRoot := configDir
if subPath != "" {
walkRoot = filepath.Join(configDir, subPath)
}
if _, err := os.Stat(walkRoot); os.IsNotExist(err) {
c.JSON(http.StatusOK, []fileEntry{})
return
}
var files []fileEntry
filepath.Walk(walkRoot, func(path string, info os.FileInfo, err error) error {
if err != nil || path == walkRoot {
return nil
}
rel, _ := filepath.Rel(walkRoot, path)
// Enforce depth limit
if strings.Count(rel, string(filepath.Separator))+1 > depth {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
base := filepath.Base(rel)
if base == ".git" || base == ".DS_Store" || base == "__pycache__" || base == "node_modules" {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
files = append(files, fileEntry{
Path: rel,
Size: info.Size(),
Dir: info.IsDir(),
})
return nil
})
if files == nil {
files = []fileEntry{}
}
c.JSON(http.StatusOK, files)
}
// ReadFile handles GET /workspaces/:id/files/*path
func (h *TemplatesHandler) ReadFile(c *gin.Context) {
workspaceID := c.Param("id")
filePath := c.Param("path")
if strings.HasPrefix(filePath, "/") {
filePath = filePath[1:]
}
if err := validateRelPath(filePath); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
ctx := c.Request.Context()
rootPath := c.DefaultQuery("root", "/configs")
if !allowedRoots[rootPath] {
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
return
}
var wsName, instanceID, runtime string
if err := db.DB.QueryRowContext(ctx,
`SELECT name, COALESCE(instance_id, ''), COALESCE(runtime, '') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&wsName, &instanceID, &runtime); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
// SaaS workspace (EC2-per-workspace) — no Docker on this tenant. Read
// via SSH through the EIC endpoint, mirroring WriteFile's dispatch
// in this same file. Pre-fix this branch was missing and SaaS
// workspaces always fell through to the local-Docker container check
// (finds nothing on a SaaS tenant) + template-dir fallback (returns
// the seed template, not the persisted state). Net effect: the
// canvas Config tab always 404'd for SaaS workspaces — visible to
// users after #2781 added the "no config.yaml" error UX.
//
// The ?root= query param is intentionally ignored on the SaaS path —
// it's a local-Docker concept (arbitrary container roots). The
// runtime → base-path map (workspaceFilePathPrefix in
// template_files_eic.go) is the SaaS source of truth.
if instanceID != "" {
content, err := readFileViaEIC(ctx, instanceID, runtime, filePath)
if err == nil {
c.JSON(http.StatusOK, gin.H{
"path": filePath,
"content": string(content),
"size": len(content),
})
return
}
if errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found on workspace"})
return
}
log.Printf("ReadFile EIC for %s path=%s: %v", workspaceID, filePath, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
return
}
// Local Docker path: try the workspace container first. `cat` wants a
// single path argument — passing rootPath and filePath as two args
// would make `cat` try to read the rootPath directory (error) and
// then resolve filePath relative to the container's cwd, which
// isn't guaranteed to equal rootPath.
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
fullPath := strings.TrimRight(rootPath, "/") + "/" + filePath
content, err := h.execInContainer(ctx, containerName, []string{"cat", fullPath})
if err == nil {
c.JSON(http.StatusOK, gin.H{
"path": filePath,
"content": content,
"size": len(content),
})
return
}
}
// Fallback: host-side template dir
templateDir := h.resolveTemplateDir(wsName)
if templateDir == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found (container offline, no template)"})
return
}
// validateRelPath is already called above (line 260) for the container path,
// but the fallback below uses filePath directly in filepath.Join without
// any sanitization. Re-validate before the host-side read to close the gap.
if err := validateRelPath(filePath); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
fullPath := filepath.Join(templateDir, filePath)
data, err := os.ReadFile(fullPath)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"path": filePath,
"content": string(data),
"size": len(data),
})
}
// WriteFile handles PUT /workspaces/:id/files/*path
func (h *TemplatesHandler) WriteFile(c *gin.Context) {
workspaceID := c.Param("id")
filePath := c.Param("path")
if strings.HasPrefix(filePath, "/") {
filePath = filePath[1:]
}
if err := validateRelPath(filePath); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
var body struct {
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
ctx := c.Request.Context()
var wsName, instanceID, runtime string
if err := db.DB.QueryRowContext(ctx,
`SELECT name, COALESCE(instance_id, ''), COALESCE(runtime, '') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&wsName, &instanceID, &runtime); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
// SaaS workspace (EC2-per-workspace) — no Docker on this tenant. Write
// via SSH through the EIC endpoint to the runtime-specific path.
if instanceID != "" {
if err := writeFileViaEIC(ctx, instanceID, runtime, filePath, []byte(body.Content)); err != nil {
log.Printf("WriteFile EIC for %s path=%s: %v", workspaceID, filePath, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"status": "saved", "path": filePath})
return
}
// Local Docker path — write via CopyToContainer when container is running
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
singleFile := map[string]string{filePath: body.Content}
if err := h.copyFilesToContainer(ctx, containerName, "/configs", singleFile); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"status": "saved", "path": filePath})
return
}
// Container offline — write via ephemeral container mounting the config volume
volName := provisioner.ConfigVolumeName(workspaceID)
singleFile := map[string]string{filePath: body.Content}
if err := h.writeViaEphemeral(ctx, volName, singleFile); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"status": "saved", "path": filePath})
}
// DeleteFile handles DELETE /workspaces/:id/files/*path
func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
workspaceID := c.Param("id")
filePath := c.Param("path")
// Reject absolute paths before stripping the leading slash — this check
// must come before the strip so that "/etc/passwd" is not silently accepted.
if filepath.IsAbs(filePath) {
c.JSON(http.StatusBadRequest, gin.H{"error": "absolute paths not permitted"})
return
}
filePath = strings.TrimPrefix(filePath, "/")
if err := validateRelPath(filePath); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
ctx := c.Request.Context()
var wsName string
if err := db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
// Delete via docker exec when container is running
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
// CWE-78: use filepath.Join instead of string concat to prevent path
// injection into the exec argument. validateRelPath above is the primary
// guard; filepath.Join is defence-in-depth. Use -f (not -rf) to avoid
// recursive deletion of an entire directory via traversal.
_, err := h.execInContainer(ctx, containerName, []string{"rm", "-f", filepath.Join("/configs", filePath)})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted", "path": filePath})
return
}
// Container offline — delete via ephemeral container
volName := provisioner.ConfigVolumeName(workspaceID)
if err := h.deleteViaEphemeral(ctx, volName, filePath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted", "path": filePath})
}
// SharedContext handles GET /workspaces/:id/shared-context
// Returns the files listed in the workspace's config.yaml shared_context field.
func (h *TemplatesHandler) SharedContext(c *gin.Context) {
workspaceID := c.Param("id")
ctx := c.Request.Context()
var wsName string
if err := db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
type contextFile struct {
Path string `json:"path"`
Content string `json:"content"`
}
// Try reading from running container first
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
configData, err := h.execInContainer(ctx, containerName, []string{"cat", "/configs/config.yaml"})
if err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
var cfg struct {
SharedContext []string `yaml:"shared_context"`
}
if err := yaml.Unmarshal([]byte(configData), &cfg); err != nil || len(cfg.SharedContext) == 0 {
c.JSON(http.StatusOK, []interface{}{})
return
}
files := make([]contextFile, 0, len(cfg.SharedContext))
for _, relPath := range cfg.SharedContext {
if err := validateRelPath(relPath); err != nil {
continue
}
// CWE-78: pass path components as separate exec args instead of
// concatenating into a single string. validateRelPath above is the
// primary guard; separate args is defence-in-depth (no shell
// interpolation possible in exec form).
content, err := h.execInContainer(ctx, containerName, []string{"cat", "/configs", relPath})
if err != nil {
continue
}
files = append(files, contextFile{Path: relPath, Content: content})
}
c.JSON(http.StatusOK, files)
return
}
// Fallback to host-side template dir
configDir := h.resolveTemplateDir(wsName)
if configDir == "" {
c.JSON(http.StatusOK, []interface{}{})
return
}
configData, err := os.ReadFile(filepath.Join(configDir, "config.yaml"))
if err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
var cfg struct {
SharedContext []string `yaml:"shared_context"`
}
if err := yaml.Unmarshal(configData, &cfg); err != nil || len(cfg.SharedContext) == 0 {
c.JSON(http.StatusOK, []interface{}{})
return
}
files := make([]contextFile, 0, len(cfg.SharedContext))
for _, relPath := range cfg.SharedContext {
if err := validateRelPath(relPath); err != nil {
continue
}
data, err := os.ReadFile(filepath.Join(configDir, relPath))
if err != nil {
continue
}
files = append(files, contextFile{Path: relPath, Content: string(data)})
}
c.JSON(http.StatusOK, files)
}