Files
multica/server/internal/handler/skill_refresh.go
e7742b052b MUL-7776: fix(api): stop returning raw error text in 500 responses (#8905)
* fix(api): stop returning raw error text in 500 responses

39 handlers answered a 500 with the message followed by err.Error(),
sending database and store errors (constraint names, SQL states, Redis
errors) to API clients, while the other 720 500 responses in the handler
package send a fixed message. These now send their fixed message too and
log the error with the request's attributes, as the handlers that already
logged it do.

* fix(api): drop raw error text from the task callbacks and skill import 500s

completeTask and failTask sent err.Error() as the whole 500 message, and
the structured skill import put it in the result's Reason on its three
500s. They now send fixed messages and log the error. The task callbacks
keep their 500, so the daemon still retries them: isTransientError reads
the status code, never the body.

* fix(api): close remaining raw error leaks

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Yushen <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-10 12:17:42 +08:00

205 lines
7.2 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/multica-ai/multica/server/internal/logger"
"github.com/multica-ai/multica/server/pkg/protocol"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// errSkillNotRefreshable marks a skill whose stored provenance cannot be
// re-fetched: manual skills, archive uploads (no origin), runtime-local
// copies, or a hand-edited origin whose source_url no longer matches its type.
var errSkillNotRefreshable = errors.New("this skill was not imported from a refreshable source (GitHub, skills.sh, or ClawHub)")
// skillOriginRef is the minimal provenance needed to re-run an import.
type skillOriginRef struct {
Type string
SourceURL string
}
// parseSkillOrigin extracts config.origin.{type,source_url} from a skill's
// JSONB config blob. ok is false when config has no decodable origin object.
func parseSkillOrigin(config []byte) (skillOriginRef, bool) {
if len(config) == 0 {
return skillOriginRef{}, false
}
var decoded struct {
Origin struct {
Type string `json:"type"`
SourceURL string `json:"source_url"`
} `json:"origin"`
}
if err := json.Unmarshal(config, &decoded); err != nil {
return skillOriginRef{}, false
}
if decoded.Origin.Type == "" {
return skillOriginRef{}, false
}
return skillOriginRef{
Type: decoded.Origin.Type,
SourceURL: strings.TrimSpace(decoded.Origin.SourceURL),
}, true
}
// refreshableOriginSource maps a stored origin type onto the importSource its
// source_url must resolve to. ok is false for origins that cannot be
// re-fetched (manual, runtime_local, archive uploads).
func refreshableOriginSource(originType string) (importSource, bool) {
switch originType {
case "github":
return sourceGitHub, true
case "skills_sh":
return sourceSkillsSh, true
case "clawhub":
return sourceClawHub, true
default:
return 0, false
}
}
// fetchImportedSkillFromOrigin re-runs the import fetch for a skill's stored
// provenance. Eligibility is validated before any network call: a
// non-refreshable origin type, an empty source_url, or a source_url that does
// not resolve to the origin's own source (hand-edited config) all return
// errSkillNotRefreshable without contacting the upstream.
func fetchImportedSkillFromOrigin(ctx context.Context, httpClient *http.Client, origin skillOriginRef) (*importedSkill, error) {
expected, ok := refreshableOriginSource(origin.Type)
if !ok || origin.SourceURL == "" {
return nil, errSkillNotRefreshable
}
source, normalized, err := detectImportSource(origin.SourceURL)
if err != nil || source != expected {
return nil, errSkillNotRefreshable
}
switch source {
case sourceClawHub:
return fetchFromClawHub(ctx, httpClient, normalized)
case sourceSkillsSh:
return fetchFromSkillsSh(ctx, httpClient, normalized)
case sourceGitHub:
return fetchFromGitHub(ctx, httpClient, normalized)
}
return nil, errSkillNotRefreshable
}
// mergeSkillConfigOrigin rewrites only the origin key of a skill's config,
// preserving any other keys a user may have set via the API or CLI. This is
// deliberately narrower than the import-overwrite path, which replaces config
// wholesale: refresh touches provenance, not user configuration.
func mergeSkillConfigOrigin(existingConfig []byte, origin map[string]any) map[string]any {
config := map[string]any{}
if len(existingConfig) > 0 {
var decoded map[string]any
if err := json.Unmarshal(existingConfig, &decoded); err == nil && decoded != nil {
config = decoded
}
}
if origin != nil {
config["origin"] = origin
}
return config
}
// RefreshSkill re-downloads a skill from its stored config.origin source and
// overwrites the skill in place. Identity (id, created_by, created_at) and
// agent_skill bindings are preserved; name, description, content, files, and
// config.origin are replaced with the fresh upstream state. Unlike the
// creator-only import-overwrite path, refresh follows the canManageSkill
// policy (workspace owner/admin or creator): the source URL is pinned on the
// skill itself, so a refreshing admin cannot inject arbitrary content.
func (h *Handler) RefreshSkill(w http.ResponseWriter, r *http.Request) {
skill, ok := h.loadSkillForUser(w, r, chi.URLParam(r, "id"))
if !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
// Permission before any upstream fetch, so a forbidden caller cannot burn
// the 45s fetch budget. Mirrors canManageSkill, keeping the member row to
// re-check permission inside the overwrite transaction.
workspaceID := uuidToString(skill.WorkspaceID)
member, ok := h.requireWorkspaceRole(w, r, workspaceID, "skill not found", "owner", "admin", "member")
if !ok {
return
}
isAdmin := roleAllowed(member.Role, "owner", "admin")
isCreator := skill.CreatedBy.Valid && uuidToString(skill.CreatedBy) == userID
if !isAdmin && !isCreator {
writeError(w, http.StatusForbidden, "only the skill creator or a workspace admin can update this skill from its source")
return
}
origin, ok := parseSkillOrigin(skill.Config)
if !ok {
writeError(w, http.StatusUnprocessableEntity, errSkillNotRefreshable.Error())
return
}
httpClient := &http.Client{Timeout: 30 * time.Second}
ctx, cancel := context.WithTimeout(r.Context(), importFetchTimeout)
defer cancel()
imported, err := fetchImportedSkillFromOrigin(ctx, httpClient, origin)
if err != nil {
if errors.Is(err, errSkillNotRefreshable) {
writeError(w, http.StatusUnprocessableEntity, err.Error())
return
}
status, msg := importFetchErrorResponse(ctx, err)
writeError(w, status, msg)
return
}
newName := sanitizeNullBytes(imported.name)
if newName != skill.Name {
slog.Info("skill refresh: adopting upstream rename",
"skill_id", uuidToString(skill.ID), "old_name", skill.Name, "new_name", newName)
}
resp, err := h.overwriteSkillWithFiles(r.Context(), skillOverwriteInput{
WorkspaceID: skill.WorkspaceID,
TargetSkillID: skill.ID,
UserID: userID,
NewName: newName,
AllowOverwrite: func(uid string, s db.Skill) bool {
return isAdmin || (s.CreatedBy.Valid && uuidToString(s.CreatedBy) == uid)
},
Description: imported.description,
Content: imported.content,
Config: mergeSkillConfigOrigin(skill.Config, imported.origin),
Files: importedSkillFileRequests(imported),
})
if err != nil {
switch {
case errors.Is(err, errSkillOverwriteNotFound):
writeError(w, http.StatusNotFound, "skill not found")
case errors.Is(err, errSkillOverwriteForbidden):
writeError(w, http.StatusForbidden, "only the skill creator or a workspace admin can update this skill from its source")
case errors.Is(err, errSkillOverwriteNameConflict):
writeError(w, http.StatusConflict, "a skill named \""+newName+"\" already exists in this workspace")
default:
slog.Warn("update skill from source failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to update skill from source")
}
return
}
actorType, actorID := h.resolveActor(r, userID, workspaceID)
h.publish(protocol.EventSkillUpdated, workspaceID, actorType, actorID, map[string]any{"skill": resp})
writeJSON(w, http.StatusOK, resp)
}