diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 91e02562..5d85f846 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -240,6 +240,10 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { workspace = config.TenantWorkspace(deps.Workspace, ag.TenantID, tenantSlug) } } + // Fallback to global workspace if per-agent workspace is empty + if workspace == "" && deps.Workspace != "" { + workspace = deps.Workspace + } if workspace != "" { if err := os.MkdirAll(workspace, 0755); err != nil { slog.Warn("failed to create agent workspace directory", "workspace", workspace, "agent", agentKey, "error", err) diff --git a/internal/bus/types.go b/internal/bus/types.go index 9619fcbb..e71c1f36 100644 --- a/internal/bus/types.go +++ b/internal/bus/types.go @@ -99,6 +99,7 @@ const ( TopicSystemConfigChanged = "system_config:changed" TopicPairingRevoked = "pairing:revoked" TopicAgentStatusChanged = "agent:status_changed" + TopicAgentDeleted = "agent:deleted" ) // EventPairingRevoked is the event name broadcast when a paired device is revoked. @@ -120,6 +121,13 @@ type AgentStatusChangedPayload struct { NewStatus string `json:"new_status"` } +// AgentDeletedPayload carries agent deletion info for async cleanup (e.g. orphaned provider removal). +type AgentDeletedPayload struct { + AgentKey string `json:"agent_key"` + Provider string `json:"provider,omitempty"` // provider name for orphan cleanup + TenantID uuid.UUID `json:"tenant_id,omitempty"` +} + // AuditEventPayload carries audit log data emitted by handlers. // A single subscriber persists these to the activity_logs table. type AuditEventPayload struct { diff --git a/internal/gateway/methods/agents_delete.go b/internal/gateway/methods/agents_delete.go index cd3f6f1b..e83e39e9 100644 --- a/internal/gateway/methods/agents_delete.go +++ b/internal/gateway/methods/agents_delete.go @@ -6,6 +6,7 @@ import ( "fmt" "os" + "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/i18n" @@ -54,6 +55,18 @@ func (m *AgentsMethods) handleDelete(ctx context.Context, client *gateway.Client m.agents.InvalidateAgent(params.AgentID) m.agents.Remove(params.AgentID) + // Emit agent:deleted event for async cleanup (e.g. orphaned provider removal) + if m.eventBus != nil { + m.eventBus.Broadcast(bus.Event{ + Name: bus.TopicAgentDeleted, + Payload: bus.AgentDeletedPayload{ + AgentKey: params.AgentID, + Provider: ag.Provider, + TenantID: store.TenantIDFromContext(ctx), + }, + }) + } + // Best-effort delete workspace if params.DeleteFiles && ag.Workspace != "" { os.RemoveAll(ag.Workspace) diff --git a/internal/http/validate.go b/internal/http/validate.go index a18c9e29..ca9f5a0f 100644 --- a/internal/http/validate.go +++ b/internal/http/validate.go @@ -64,7 +64,7 @@ var mcpServerAllowedFields = map[string]bool{ } var channelInstanceAllowedFields = map[string]bool{ - "channel_type": true, "credentials": true, "agent_id": true, + "name": true, "channel_type": true, "credentials": true, "agent_id": true, "enabled": true, "group_policy": true, "allow_from": true, "metadata": true, "webhook_secret": true, "config": true, "display_name": true, diff --git a/internal/http/validate_test.go b/internal/http/validate_test.go index d40fc7cd..4bf1c0f5 100644 --- a/internal/http/validate_test.go +++ b/internal/http/validate_test.go @@ -4,6 +4,20 @@ import ( "testing" ) +func TestFilterAllowedKeys_ChannelInstance_IncludesName(t *testing.T) { + input := map[string]any{"name": "my-bot", "channel_type": "telegram", "evil_field": "hack"} + result := filterAllowedKeys(input, channelInstanceAllowedFields) + if result["name"] != "my-bot" { + t.Error("expected 'name' to be retained") + } + if _, ok := result["evil_field"]; ok { + t.Error("expected 'evil_field' to be stripped") + } + if result["channel_type"] != "telegram" { + t.Error("expected 'channel_type' to be retained") + } +} + func TestFilterAllowedKeys(t *testing.T) { allowed := map[string]bool{"name": true, "status": true} diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index e1211e34..bbf19bc8 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "log/slog" + "os" + "strings" "sync" "sync/atomic" "time" @@ -141,7 +143,7 @@ func (m *Manager) Start(ctx context.Context) error { continue } - if err := m.connectServer(ctx, name, cfg.Transport, cfg.Command, cfg.Args, cfg.Env, cfg.URL, cfg.Headers, cfg.ToolPrefix, cfg.TimeoutSec); err != nil { + if err := m.connectServer(ctx, name, cfg.Transport, cfg.Command, cfg.Args, cfg.Env, cfg.URL, resolveEnvVars(cfg.Headers), cfg.ToolPrefix, cfg.TimeoutSec); err != nil { slog.Warn("mcp.server.connect_failed", "server", name, "error", err) errs = append(errs, fmt.Sprintf("%s: %v", name, err)) } @@ -184,7 +186,7 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc args := jsonBytesToStringSlice(srv.Args) env := jsonBytesToStringMap(srv.Env) - headers := jsonBytesToStringMap(srv.Headers) + headers := resolveEnvVars(jsonBytesToStringMap(srv.Headers)) // Inject APIKey into headers if present (bug fix: was never passed to connections) if srv.APIKey != "" && headers["Authorization"] == "" { @@ -508,6 +510,19 @@ func (m *Manager) ServerStatus() []ServerStatus { return statuses } +// resolveEnvVars returns a copy of m with "env:VARNAME" values resolved to os.Getenv("VARNAME"). +func resolveEnvVars(m map[string]string) map[string]string { + out := make(map[string]string, len(m)) + for k, v := range m { + if after, ok := strings.CutPrefix(v, "env:"); ok { + out[k] = os.Getenv(after) + } else { + out[k] = v + } + } + return out +} + // requireUserCreds checks if an MCP server's settings mandate per-user credentials. func requireUserCreds(settings json.RawMessage) bool { if len(settings) == 0 { diff --git a/internal/mcp/manager_env_test.go b/internal/mcp/manager_env_test.go new file mode 100644 index 00000000..f591fbd8 --- /dev/null +++ b/internal/mcp/manager_env_test.go @@ -0,0 +1,41 @@ +package mcp + +import ( + "testing" +) + +func TestResolveEnvVars(t *testing.T) { + t.Setenv("TEST_MCP_TOKEN", "secret123") + + tests := []struct { + name string + input map[string]string + want map[string]string + }{ + { + "resolves env prefix", + map[string]string{"Authorization": "env:TEST_MCP_TOKEN", "X-Custom": "literal"}, + map[string]string{"Authorization": "secret123", "X-Custom": "literal"}, + }, + { + "missing env var resolves to empty", + map[string]string{"X-Missing": "env:NONEXISTENT_VAR_XYZ"}, + map[string]string{"X-Missing": ""}, + }, + { + "nil map", + nil, + map[string]string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveEnvVars(tt.input) + for k, v := range tt.want { + if got[k] != v { + t.Errorf("key %q: got %q, want %q", k, got[k], v) + } + } + }) + } +}