picoclaw/pkg/channels/magicform/magicform.go
admin-mf 93fb7b8794 Merge upstream/main into sync/upstream-2026-05-07
Sync with sipeed/picoclaw upstream/main (~1095 commits since last merge),
including major restructures:

- Launcher migrated to web/backend (#1275): cmd/picoclaw-launcher/* removed.
  Our launcher is dropped (MagicForm doesn't use it; web/backend is the
  upstream replacement when needed).
- Agent loop split (#2585, 12d5421c): pkg/agent/loop.go deleted, replaced by
  pkg/agent/agent_*.go files. Multi-tenancy customizations to be forward-ported
  in follow-up commits on this branch.
- Tool packages reorganized (4c133dc2): pkg/tools/{filesystem,web}.go moved
  to pkg/tools/{fs,integration}/. Customizations to be forward-ported.
- Channels switched to map+Settings model: MagicFormConfig → MagicFormSettings
  registered in config_channel.go alongside other channels; channel constructor
  updated to (channelName, channelType, *Config, *MessageBus) factory pattern.
- Config schema updates: removed deprecated AgentDefaults.Model field;
  removed Config.Bindings (legacy migration in upstream); SessionConfig now
  uses Dimensions instead of DMScope; ExecConfig adds AllowRemote.

Conflicts resolved:
- pkg/agent/loop.go + loop_test.go: accepted upstream deletion (split)
- pkg/tools/{filesystem,web}.go: accepted upstream deletion (moved)
- cmd/picoclaw-launcher/*: dropped (deps deleted upstream, unused by MagicForm)
- cmd/picoclaw/internal/gateway/helpers.go: accepted upstream deletion
- pkg/channels/manager.go: switched to upstream's map-driven channel init
- pkg/config/config.go: merged AgentDefaults fields, kept WorkspaceRoot,
  removed deprecated Model
- pkg/bus/types.go: merged OutboundMessage with upstream's new fields
  (Context, AgentID, SessionKey, Scope, ContextUsage) plus our magicform
  callback fields (Type, Metrics, Progress, Escalation)
- pkg/tools/{cron,shell}.go: combined our customizations with upstream changes
- pkg/skills/installer.go: dropped our LimitReader path (upstream now uses
  chunked DownloadToFile which is size-bounded)

The magicform channel was rebuilt to match upstream's new API:
- *config.MagicFormSettings (SecureString token) instead of MagicFormConfig
- Send returns ([]string, error) per upstream Channel interface
- InboundMessage built with Context (with Raw map for tenancy hints) instead
  of bus.Peer + Metadata field which were removed.

Multi-tenancy hints (workspace_override, config_dir, allowed_tools,
allowed_skills) are still flowing via Context.Raw — the agent loop side of
the integration is forward-ported in a follow-up commit.

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

493 lines
15 KiB
Go

package magicform
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/pathutil"
)
// WebhookPayload is the inbound payload from MagicForm.
type WebhookPayload struct {
StackID string `json:"stackId"`
ConversationID string `json:"conversationId"`
UserID string `json:"userId"`
Message string `json:"message"`
Workspace string `json:"workspace"` // e.g. "s1/c1" — agent working directory (relative to workspace_root)
ConfigDir string `json:"configDir,omitempty"` // e.g. "s1/config" — pre-provisioned config directory (relative to workspace_root)
CallbackURL string `json:"callbackUrl"`
// Tool/skill filtering
AllowedTools []string `json:"allowedTools,omitempty"` // Tool allowlist (empty = all)
AllowedSkills []string `json:"allowedSkills,omitempty"` // Skill filter (empty = all)
}
// CallbackPayload is the outbound payload sent back to MagicForm.
type CallbackPayload struct {
StackID string `json:"stackId"`
ConversationID string `json:"conversationId"`
TaskID string `json:"taskId,omitempty"`
Type string `json:"type"` // "final", "progress", "escalation"
Status string `json:"status"` // "success", "error"
Response string `json:"response"`
Error string `json:"error,omitempty"`
Runtime string `json:"runtime"`
DurationMs int64 `json:"durationMs,omitempty"`
TokenUsage *bus.TokenUsage `json:"tokenUsage,omitempty"`
ToolCalls int `json:"toolCalls,omitempty"`
Progress *ProgressPayload `json:"progress"` // null unless type=progress
Escalation *EscalationPayload `json:"escalation"` // null unless type=escalation
}
// ProgressPayload is populated for type="progress" callbacks.
type ProgressPayload struct {
Status string `json:"status"` // e.g. "thinking"
ToolName string `json:"toolName,omitempty"`
StepNumber int `json:"stepNumber,omitempty"`
Message string `json:"message,omitempty"`
}
// EscalationPayload is populated for type="escalation" callbacks.
type EscalationPayload struct {
Reason string `json:"reason"`
Notes string `json:"notes,omitempty"`
}
// requestContext stores per-request state so Send() can resolve callback info.
type requestContext struct {
stackID string
conversationID string
userID string
callbackURL string
createdAt time.Time
}
// MagicFormChannel implements the MagicForm channel plugin.
type MagicFormChannel struct {
*channels.BaseChannel
settings *config.MagicFormSettings
workspaceRoot string // effective root: channel-level fallback to global
httpClient *http.Client
requests sync.Map // chatID → *requestContext
ctx context.Context
cancel context.CancelFunc
}
// NewMagicFormChannel creates a new MagicForm channel.
// globalWorkspaceRoot is the agents.defaults.workspace_root from the base config.
// The channel uses its own settings.WorkspaceRoot if set, otherwise falls back to
// globalWorkspaceRoot. If neither is configured, the constructor returns an error
// because workspace overrides cannot be validated without a root boundary.
func NewMagicFormChannel(
bc *config.Channel,
settings *config.MagicFormSettings,
globalWorkspaceRoot string,
msgBus *bus.MessageBus,
) (*MagicFormChannel, error) {
base := channels.NewBaseChannel(
"magicform",
settings,
msgBus,
bc.AllowFrom,
)
effectiveRoot := settings.WorkspaceRoot
if effectiveRoot == "" {
effectiveRoot = globalWorkspaceRoot
}
if effectiveRoot == "" {
return nil, fmt.Errorf("magicform channel requires workspace_root to be configured " +
"(set channels.magicform.workspace_root or agents.defaults.workspace_root)")
}
ctx, cancel := context.WithCancel(context.Background())
ch := &MagicFormChannel{
BaseChannel: base,
settings: settings,
workspaceRoot: effectiveRoot,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
ctx: ctx,
cancel: cancel,
}
base.SetOwner(ch)
return ch, nil
}
// Start begins the channel and starts the TTL cleanup goroutine.
func (c *MagicFormChannel) Start(_ context.Context) error {
c.SetRunning(true)
// Background goroutine to clean up stale request contexts
go c.cleanupLoop()
logger.InfoCF("magicform", "MagicForm channel started", nil)
return nil
}
// Stop shuts down the channel.
func (c *MagicFormChannel) Stop(_ context.Context) error {
c.cancel()
c.SetRunning(false)
logger.InfoCF("magicform", "MagicForm channel stopped", nil)
return nil
}
// WebhookPath returns the HTTP path for the inbound webhook.
func (c *MagicFormChannel) WebhookPath() string {
if c.settings.WebhookPath != "" {
return c.settings.WebhookPath
}
return "/hooks/magicform"
}
// HealthPath returns the HTTP path for the health check endpoint.
func (c *MagicFormChannel) HealthPath() string {
return "/health/magicform"
}
// HealthHandler handles the health check HTTP request.
func (c *MagicFormChannel) HealthHandler(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"channel": "magicform",
})
}
// maxWebhookBodySize is the maximum allowed size for inbound webhook payloads (1 MB).
const maxWebhookBodySize = 1 << 20
// ServeHTTP handles inbound webhook requests from MagicForm.
func (c *MagicFormChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Limit request body size to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodySize)
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
return
}
defer r.Body.Close()
// Verify Bearer token
if !c.verifyToken(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var payload WebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Validate required fields
if payload.StackID == "" || payload.ConversationID == "" || payload.Message == "" {
http.Error(w, "Missing required fields: stackId, conversationId, message", http.StatusBadRequest)
return
}
// Validate workspace path: must be relative and resolve under workspace_root
if payload.Workspace != "" {
resolved, err := c.resolveWorkspace(payload.Workspace)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid workspace: %v", err), http.StatusBadRequest)
return
}
payload.Workspace = resolved
}
// Validate configDir path against workspace_root
if payload.ConfigDir != "" {
resolved, err := c.resolveWorkspace(payload.ConfigDir)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid configDir: %v", err), http.StatusBadRequest)
return
}
payload.ConfigDir = resolved
}
// Return 200 immediately, process asynchronously
w.WriteHeader(http.StatusOK)
go c.processWebhook(c.ctx, payload)
}
// resolveWorkspace validates and resolves the workspace path using the shared
// pathutil.ResolveWorkspacePath boundary check. The effective workspace root is
// determined at construction time (channel-level config with global fallback).
func (c *MagicFormChannel) resolveWorkspace(workspace string) (string, error) {
return pathutil.ResolveWorkspacePath(c.workspaceRoot, workspace)
}
// verifyToken checks the Authorization Bearer token using constant-time comparison.
func (c *MagicFormChannel) verifyToken(r *http.Request) bool {
configured := c.settings.Token.String()
if configured == "" {
return true // No token configured = allow all (dev mode)
}
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return false
}
token := strings.TrimPrefix(auth, "Bearer ")
return subtle.ConstantTimeCompare([]byte(token), []byte(configured)) == 1
}
// processWebhook handles an inbound webhook payload asynchronously.
func (c *MagicFormChannel) processWebhook(ctx context.Context, p WebhookPayload) {
// Check for shutdown before doing any work
if ctx.Err() != nil {
logger.WarnCF("magicform", "Skipping webhook processing: channel shutting down",
map[string]any{"stack_id": p.StackID, "conversation_id": p.ConversationID})
return
}
chatID := "magicform:" + p.ConversationID
senderID := p.UserID
if senderID == "" {
senderID = "anonymous"
}
// Store request context for Send() to look up later
c.requests.Store(chatID, &requestContext{
stackID: p.StackID,
conversationID: p.ConversationID,
userID: p.UserID,
callbackURL: p.CallbackURL,
createdAt: time.Now(),
})
sender := bus.SenderInfo{
Platform: "magicform",
PlatformID: senderID,
CanonicalID: "magicform:" + senderID,
}
// Session key: per-stack per-conversation isolation
sessionKey := fmt.Sprintf("agent:main:magicform:%s:%s", p.StackID, p.ConversationID)
// Stash tenant routing + multi-tenancy hints in Context.Raw — the agent loop
// reads workspace_override / config_dir / allowed_tools / allowed_skills from here.
raw := map[string]string{
"platform": "magicform",
"stack_id": p.StackID,
"conversation_id": p.ConversationID,
}
if p.CallbackURL != "" {
raw["callback_url"] = p.CallbackURL
}
if p.Workspace != "" {
raw["workspace_override"] = p.Workspace
}
if p.ConfigDir != "" {
raw["config_dir"] = p.ConfigDir
}
if len(p.AllowedTools) > 0 {
raw["allowed_tools"] = strings.Join(trimSlice(p.AllowedTools), ",")
}
if len(p.AllowedSkills) > 0 {
raw["allowed_skills"] = strings.Join(trimSlice(p.AllowedSkills), ",")
}
messageID := fmt.Sprintf("mf-%s-%d", p.ConversationID, time.Now().UnixMilli())
inboundCtx := bus.InboundContext{
Channel: "magicform",
ChatID: chatID,
ChatType: "direct",
SpaceID: p.StackID,
SpaceType: "tenant",
SenderID: sender.CanonicalID,
MessageID: messageID,
Raw: raw,
}
// Build InboundMessage directly (not via HandleMessage) to set SessionKey.
// MagicForm is API-to-API, so typing/reaction/placeholder don't apply.
msg := bus.InboundMessage{
Context: inboundCtx,
Sender: sender,
Content: p.Message,
SessionKey: sessionKey,
Channel: "magicform",
SenderID: sender.CanonicalID,
ChatID: chatID,
MessageID: messageID,
}
if err := c.Bus().PublishInbound(ctx, msg); err != nil {
logger.ErrorCF("magicform", "Failed to publish inbound message",
map[string]any{
"chat_id": chatID,
"stack_id": p.StackID,
"conversation_id": p.ConversationID,
"error": err.Error(),
})
}
}
// trimSlice trims whitespace from each element in the slice.
func trimSlice(s []string) []string {
out := make([]string, len(s))
for i, v := range s {
out[i] = strings.TrimSpace(v)
}
return out
}
// Send delivers the agent response back to MagicForm via HTTP callback.
func (c *MagicFormChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
return nil, channels.ErrNotRunning
}
// For progress/escalation messages, Load (keep) the context since the final
// message is still coming. For final messages, LoadAndDelete to clean up.
isFinal := msg.Type == bus.MessageTypeFinal
var reqCtx *requestContext
if isFinal {
val, ok := c.requests.LoadAndDelete(msg.ChatID)
if !ok {
return nil, fmt.Errorf("%w: no request context for chatID %s", channels.ErrSendFailed, msg.ChatID)
}
reqCtx = val.(*requestContext)
} else {
val, ok := c.requests.Load(msg.ChatID)
if !ok {
return nil, fmt.Errorf("%w: no request context for chatID %s", channels.ErrSendFailed, msg.ChatID)
}
reqCtx = val.(*requestContext)
}
// Resolve callback URL
callbackURL := reqCtx.callbackURL
if callbackURL == "" {
callbackURL = c.settings.BackendURL + "/claw-agent/callback"
}
if callbackURL == "" {
return nil, fmt.Errorf("%w: no callback URL available", channels.ErrSendFailed)
}
// Build callback payload
taskID := fmt.Sprintf("claw_task_%s_%d", reqCtx.conversationID, reqCtx.createdAt.UnixMilli())
payload := CallbackPayload{
StackID: reqCtx.stackID,
ConversationID: reqCtx.conversationID,
TaskID: taskID,
Runtime: "picoclaw",
Response: msg.Content,
}
switch msg.Type {
case bus.MessageTypeProgress:
payload.Type = "progress"
payload.Status = "success"
if msg.Progress != nil {
payload.Progress = &ProgressPayload{
Status: msg.Progress.Status,
ToolName: msg.Progress.ToolName,
StepNumber: msg.Progress.StepNumber,
Message: msg.Progress.Message,
}
}
case bus.MessageTypeEscalation:
payload.Type = "escalation"
payload.Status = "success"
if msg.Escalation != nil {
payload.Escalation = &EscalationPayload{
Reason: msg.Escalation.Reason,
Notes: msg.Escalation.Notes,
}
}
default: // final
payload.Type = "final"
payload.Status = "success"
if m := msg.Metrics; m != nil {
payload.DurationMs = m.DurationMs
payload.ToolCalls = m.ToolCalls
payload.TokenUsage = m.TokenUsage
}
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("%w: marshal callback payload: %v", channels.ErrSendFailed, err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, callbackURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("%w: create callback request: %v", channels.ErrSendFailed, err)
}
req.Header.Set("Content-Type", "application/json")
if tok := c.settings.Token.String(); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, channels.ClassifyNetError(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(resp.Body)
return nil, channels.ClassifySendError(resp.StatusCode, fmt.Errorf("callback error: %s", respBody))
}
logger.InfoCF("magicform", "Callback sent",
map[string]any{
"conversation_id": reqCtx.conversationID,
"type": payload.Type,
"status": resp.StatusCode,
})
return nil, nil
}
// cleanupLoop periodically removes stale request contexts.
func (c *MagicFormChannel) cleanupLoop() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
case <-c.ctx.Done():
return
case <-ticker.C:
c.requests.Range(func(key, value any) bool {
rc := value.(*requestContext)
if time.Since(rc.createdAt) > 10*time.Minute {
c.requests.Delete(key)
logger.DebugCF("magicform", "Cleaned up stale request context",
map[string]any{"chat_id": key})
}
return true
})
}
}
}