mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 01:19:42 +02:00
* fix(avatar): serve avatars through a signed endpoint on private buckets (MUL-5393) Avatar uploads persisted the raw storage object URL into `avatar_url`. On a deployment whose bucket is private and has no public CDN domain (S3 with Block Public Access, R2, MinIO) that URL is a guaranteed 403 in the browser: ATTACHMENT_DOWNLOAD_MODE only ever applied to the attachment download endpoint, so every user / agent / squad / workspace avatar rendered broken even though the upload itself succeeded. Resolve at read time instead of at upload time. What is persisted stays the durable object reference, so nothing with a TTL is ever written to the database and avatars saved by an older build are fixed without a backfill. What is served is `/api/avatars/<sig>/<key>`, a stable URL the server resolves per request through the deployment's existing storage download policy (presigned redirect, CloudFront-signed redirect, or proxied body). The endpoint is unauthenticated and the HMAC signature is the credential: the session cookie is SameSite=Strict, so an auth-gated URL cannot be a native <img src> from Desktop, a mobile webview, or a split-origin self-hosted web app. The signature covers the storage key and only image extensions resolve, so an avatar_url pointed at a private document cannot launder it into a publicly fetchable URL. Deployments that already work are untouched: a public CDN domain without per-request signing, and the local-disk backend whose /uploads/* route is public, both keep returning the raw URL. Fixes #6024 Co-authored-by: multica-agent <github@multica.ai> * fix(avatar): only publish avatar-class objects through the signed endpoint (MUL-5393) Review found that being able to name a storage object was treated as permission to publish it. `ownedStorageKey` proved only that a URL came from this deployment's storage, and every image-shaped key was then signed — while the avatar update endpoints accepted any raw storage URL. A caller who had seen a private image attachment's URL could submit it as their own avatar, and the unauthenticated endpoint would keep re-signing it indefinitely. A user avatar propagates to every workspace that user belongs to, so the leak crossed workspace boundaries. Add the missing authorization rule: an object is serveable as an avatar only when it is avatar-class — a standalone image upload not attached to an issue, comment, chat session, chat message, or task. The check resolves the backing attachment row from the id UploadFile embeds in the object filename, so it needs no lookup by URL and no new index. It is enforced on both sides. The write side rejects such a value with 403 before anything is stored; the read side re-checks per request, which is what makes the guarantee hold for rows written before this existed and revokes the URL if an object is later bound to a comment or chat. Scope is the `workspaces/` namespace — the only place that can hold content belonging to someone other than whoever is setting the avatar, covering both uploads and channel media ingest. Keys elsewhere (the per-user standalone namespace, or objects an operator placed in the bucket) stay usable, which keeps the documented "an explicit avatar_url is preserved" contract intact. Uploader identity is deliberately not part of the rule: duplicating an agent legitimately reuses the source agent's avatar object, which a different admin may have uploaded. Publishing someone else's unbound image would require knowing its URL, and unbound rows appear in no listing endpoint. Also clamp the 302's cache lifetime to half the signed URL's own TTL (0 -> no-store). ATTACHMENT_DOWNLOAD_URL_TTL takes any positive duration, so the fixed 60s could outlive the target it pointed at on a short-TTL deployment. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
211 lines
7.1 KiB
Go
211 lines
7.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/multica-ai/multica/server/internal/logger"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
const maxRuntimeSkillKeyLength = 512
|
|
|
|
// DisabledRuntimeSkill identifies one runtime-local skill that an agent must
|
|
// not inherit. RuntimeID scopes the choice to a single machine/runtime so a
|
|
// same-named skill on another runtime is unaffected.
|
|
type DisabledRuntimeSkill struct {
|
|
RuntimeID string `json:"runtime_id"`
|
|
Provider string `json:"provider"`
|
|
Root string `json:"root"`
|
|
Key string `json:"key"`
|
|
Name string `json:"name,omitempty"`
|
|
Plugin string `json:"plugin,omitempty"`
|
|
}
|
|
|
|
func decodeDisabledRuntimeSkills(raw []byte) []DisabledRuntimeSkill {
|
|
if len(raw) == 0 {
|
|
return []DisabledRuntimeSkill{}
|
|
}
|
|
var skills []DisabledRuntimeSkill
|
|
if err := json.Unmarshal(raw, &skills); err != nil || skills == nil {
|
|
return []DisabledRuntimeSkill{}
|
|
}
|
|
return skills
|
|
}
|
|
|
|
func disabledRuntimeSkillsFor(raw []byte, runtimeID, provider string) []DisabledRuntimeSkill {
|
|
all := decodeDisabledRuntimeSkills(raw)
|
|
result := make([]DisabledRuntimeSkill, 0, len(all))
|
|
for _, skill := range all {
|
|
if skill.RuntimeID == runtimeID && skill.Provider == provider {
|
|
result = append(result, skill)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func normalizeRuntimeSkillIdentity(root, key, plugin string) (string, string, string, bool) {
|
|
root = strings.TrimSpace(root)
|
|
key = strings.TrimSpace(key)
|
|
plugin = strings.TrimSpace(plugin)
|
|
if len(key) == 0 || len(key) > maxRuntimeSkillKeyLength {
|
|
return "", "", "", false
|
|
}
|
|
cleaned := filepath.ToSlash(filepath.Clean(filepath.FromSlash(key)))
|
|
if cleaned == "." || filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
|
|
return "", "", "", false
|
|
}
|
|
switch root {
|
|
case "provider", "universal":
|
|
plugin = ""
|
|
case "plugin":
|
|
if plugin == "" {
|
|
return "", "", "", false
|
|
}
|
|
default:
|
|
return "", "", "", false
|
|
}
|
|
return root, cleaned, plugin, true
|
|
}
|
|
|
|
func sameDisabledRuntimeSkill(a, b DisabledRuntimeSkill) bool {
|
|
return a.RuntimeID == b.RuntimeID && a.Provider == b.Provider &&
|
|
a.Root == b.Root && a.Key == b.Key && a.Plugin == b.Plugin
|
|
}
|
|
|
|
// SetAgentRuntimeSkillEnabled persists a per-agent override for a skill that
|
|
// is discovered from the agent's currently assigned local runtime.
|
|
func (h *Handler) SetAgentRuntimeSkillEnabled(w http.ResponseWriter, r *http.Request) {
|
|
agentID := chi.URLParam(r, "id")
|
|
agent, ok := h.loadAgentForUser(w, r, agentID)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !h.canManageAgent(w, r, agent) {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
RuntimeID string `json:"runtime_id"`
|
|
Root string `json:"root"`
|
|
Key string `json:"key"`
|
|
Name string `json:"name"`
|
|
Plugin string `json:"plugin"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Enabled == nil {
|
|
writeError(w, http.StatusBadRequest, "runtime_id, root, key, and enabled are required")
|
|
return
|
|
}
|
|
runtimeID, ok := parseUUIDOrBadRequest(w, req.RuntimeID, "runtime_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if !agent.RuntimeID.Valid || agent.RuntimeID != runtimeID {
|
|
writeError(w, http.StatusConflict, "agent is no longer assigned to this runtime")
|
|
return
|
|
}
|
|
rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeID)
|
|
if err != nil || rt.WorkspaceID != agent.WorkspaceID {
|
|
writeError(w, http.StatusNotFound, "runtime not found")
|
|
return
|
|
}
|
|
if rt.RuntimeMode != "local" || (rt.Provider != "codex" && rt.Provider != "claude") {
|
|
writeError(w, http.StatusBadRequest, "runtime skill controls are only supported for codex and claude")
|
|
return
|
|
}
|
|
root, key, plugin, valid := normalizeRuntimeSkillIdentity(req.Root, req.Key, req.Plugin)
|
|
if !valid || (root == "plugin" && rt.Provider != "claude") {
|
|
writeError(w, http.StatusBadRequest, "invalid runtime skill identity")
|
|
return
|
|
}
|
|
name := strings.TrimSpace(req.Name)
|
|
if len(name) > maxRuntimeSkillKeyLength {
|
|
writeError(w, http.StatusBadRequest, "invalid runtime skill name")
|
|
return
|
|
}
|
|
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to begin transaction")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
qtx := h.Queries.WithTx(tx)
|
|
locked, err := qtx.GetAgentForUpdate(r.Context(), agent.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load agent")
|
|
return
|
|
}
|
|
if !locked.RuntimeID.Valid || locked.RuntimeID != runtimeID {
|
|
writeError(w, http.StatusConflict, "agent is no longer assigned to this runtime")
|
|
return
|
|
}
|
|
|
|
target := DisabledRuntimeSkill{
|
|
RuntimeID: req.RuntimeID,
|
|
Provider: rt.Provider,
|
|
Root: root,
|
|
Key: key,
|
|
Name: name,
|
|
Plugin: plugin,
|
|
}
|
|
current := decodeDisabledRuntimeSkills(locked.DisabledRuntimeSkills)
|
|
next := make([]DisabledRuntimeSkill, 0, len(current)+1)
|
|
for _, skill := range current {
|
|
if sameDisabledRuntimeSkill(skill, target) {
|
|
continue
|
|
}
|
|
next = append(next, skill)
|
|
}
|
|
if !*req.Enabled {
|
|
next = append(next, target)
|
|
}
|
|
payload, err := json.Marshal(next)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to encode runtime skill settings")
|
|
return
|
|
}
|
|
if _, err := qtx.UpdateAgentDisabledRuntimeSkills(r.Context(), db.UpdateAgentDisabledRuntimeSkillsParams{
|
|
ID: locked.ID,
|
|
DisabledRuntimeSkills: payload,
|
|
}); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update runtime skill")
|
|
return
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to commit")
|
|
return
|
|
}
|
|
|
|
// Broadcast the updated agent so every other open web/desktop/mobile client
|
|
// invalidates its cached agent and picks up the new disabled_runtime_skills
|
|
// state. The workspace-skill toggle does the same through
|
|
// writeUpdatedAgentSkills (skill.go); the realtime layer keys off the
|
|
// "agent:status" event to invalidate workspaceKeys.agents. Without this only
|
|
// the initiating tab refreshes and other clients keep showing stale toggles.
|
|
locked.DisabledRuntimeSkills = payload
|
|
resp := h.agentToResponse(locked)
|
|
if err := h.enrichAgentResponseWithTargets(r.Context(), &resp, locked.ID); err != nil {
|
|
slog.Warn("runtime skill toggle: load invocation targets for broadcast failed",
|
|
append(logger.RequestAttrs(r), "error", err, "agent_id", agentID)...)
|
|
}
|
|
// agentToResponse initialises Skills as []; reload the junction-table
|
|
// bindings so the broadcast mirrors reality instead of signalling other
|
|
// clients that this agent's skills were cleared (#3459).
|
|
if err := h.attachAgentSkills(r.Context(), &resp, locked.ID); err != nil {
|
|
slog.Warn("runtime skill toggle: load agent skills for broadcast failed",
|
|
append(logger.RequestAttrs(r), "error", err, "agent_id", agentID)...)
|
|
}
|
|
actorType, actorID := h.resolveActor(r, requestUserID(r), uuidToString(locked.WorkspaceID))
|
|
h.publish(protocol.EventAgentStatus, uuidToString(locked.WorkspaceID), actorType, actorID,
|
|
map[string]any{"agent": broadcastAgentResponse(resp)})
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|