diff --git a/cmd/gateway.go b/cmd/gateway.go index 0ebb2a899c..59e67c8f5a 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -334,6 +334,9 @@ func runGateway() { exportTokenStore := httpapi.InitExportTokenStore() defer exportTokenStore.Stop() agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, secureCLIGrantH, mcpUserCredsH := wireHTTP(pgStores, cfg.Agents.Defaults.Workspace, dataDir, bundledSkillsDir, msgBus, toolsReg, providerRegistry, modelReg, permPE.IsOwner, gatewayAddr, mcpToolLister) + if skillsH != nil { + skillsH.SetMaxUploadSizeBytes(cfg.Skills.MaxUploadSizeBytes()) + } // Wire dependencies for system prompt preview parity. if agentsH != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 80ca1722be..0643090b93 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -47,6 +47,7 @@ type Config struct { Providers ProvidersConfig `json:"providers"` Gateway GatewayConfig `json:"gateway"` Tools ToolsConfig `json:"tools"` + Skills SkillsConfig `json:"skills"` Sessions SessionsConfig `json:"sessions"` Database DatabaseConfig `json:"database"` Tts TtsConfig `json:"tts"` @@ -97,7 +98,23 @@ type DatabaseConfig struct { // SkillsConfig configures the skills storage system. type SkillsConfig struct { - StorageDir string `json:"storage_dir,omitempty"` // directory for skill content (default: dataDir/skills-store/) + StorageDir string `json:"storage_dir,omitempty"` // directory for skill content (default: dataDir/skills-store/) + MaxUploadSizeMB int `json:"max_upload_size_mb,omitempty"` // per-file upload limit in MiB (default 20, clamped 1..500) +} + +// MaxUploadSizeBytes returns the effective per-file skill upload cap in bytes. +func (sc SkillsConfig) MaxUploadSizeBytes() int64 { + mb := sc.MaxUploadSizeMB + if mb <= 0 { + mb = DefaultSkillMaxUploadSizeMB + } + if mb < MinSkillMaxUploadSizeMB { + mb = MinSkillMaxUploadSizeMB + } + if mb > MaxSkillMaxUploadSizeMB { + mb = MaxSkillMaxUploadSizeMB + } + return int64(mb) << 20 } // AgentBinding maps a channel/peer pattern to a specific agent. @@ -447,6 +464,7 @@ func (c *Config) ReplaceFrom(src *Config) { c.Providers = src.Providers c.Gateway = src.Gateway c.Tools = src.Tools + c.Skills = src.Skills c.Sessions = src.Sessions c.Database = src.Database c.Tts = src.Tts diff --git a/internal/config/config_load.go b/internal/config/config_load.go index a844e1aeaf..cec3405588 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -56,6 +56,9 @@ func Default() *Config { }, RateLimitPerHour: 150, }, + Skills: SkillsConfig{ + MaxUploadSizeMB: DefaultSkillMaxUploadSizeMB, + }, Sessions: SessionsConfig{}, } } @@ -167,6 +170,11 @@ func (c *Config) applyEnvOverrides() { // Data directory, workspace & sessions envStr("GOCLAW_DATA_DIR", &c.DataDir) envStr("GOCLAW_WORKSPACE", &c.Agents.Defaults.Workspace) + if v := os.Getenv("GOCLAW_SKILLS_MAX_UPLOAD_SIZE_MB"); v != "" { + if mb, err := strconv.Atoi(v); err == nil { + c.Skills.MaxUploadSizeMB = mb + } + } // Gateway host/port envStr("GOCLAW_HOST", &c.Gateway.Host) @@ -277,7 +285,6 @@ func (c *Config) applyEnvOverrides() { } } - // Save writes the config to a JSON file. func Save(path string, cfg *Config) error { cfg.mu.RLock() diff --git a/internal/config/defaults.go b/internal/config/defaults.go index ee1a4f6bc5..8572d8a228 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -4,10 +4,13 @@ package config // These are the single source of truth — all fallback/default logic should reference these // instead of hardcoding numeric literals. const ( - DefaultContextWindow = 200000 - DefaultMaxTokens = 8192 - DefaultMaxMessageChars = 32000 - DefaultMaxIterations = 30 - DefaultTemperature = 0.7 - DefaultHistoryShare = 0.85 + DefaultContextWindow = 200000 + DefaultMaxTokens = 8192 + DefaultMaxMessageChars = 32000 + DefaultMaxIterations = 30 + DefaultTemperature = 0.7 + DefaultHistoryShare = 0.85 + DefaultSkillMaxUploadSizeMB = 20 + MinSkillMaxUploadSizeMB = 1 + MaxSkillMaxUploadSizeMB = 500 ) diff --git a/internal/config/skills_config_test.go b/internal/config/skills_config_test.go new file mode 100644 index 0000000000..7d4bc33598 --- /dev/null +++ b/internal/config/skills_config_test.go @@ -0,0 +1,55 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSkillsConfigMaxUploadSizeBytesDefaultsAndClamps(t *testing.T) { + tests := []struct { + name string + mb int + want int64 + }{ + {name: "default", mb: 0, want: int64(DefaultSkillMaxUploadSizeMB) << 20}, + {name: "negative uses default", mb: -5, want: int64(DefaultSkillMaxUploadSizeMB) << 20}, + {name: "custom", mb: 50, want: 50 << 20}, + {name: "maximum", mb: 500, want: 500 << 20}, + {name: "above maximum", mb: 999, want: int64(MaxSkillMaxUploadSizeMB) << 20}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := (SkillsConfig{MaxUploadSizeMB: tc.mb}).MaxUploadSizeBytes() + if got != tc.want { + t.Fatalf("MaxUploadSizeBytes() = %d, want %d", got, tc.want) + } + }) + } +} + +func TestLoadSkillsMaxUploadSizeFromConfigAndEnv(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json5") + if err := os.WriteFile(cfgPath, []byte("{\"skills\":{\"max_upload_size_mb\":64}}"), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + if cfg.Skills.MaxUploadSizeMB != 64 { + t.Fatalf("config max_upload_size_mb = %d, want 64", cfg.Skills.MaxUploadSizeMB) + } + + t.Setenv("GOCLAW_SKILLS_MAX_UPLOAD_SIZE_MB", "128") + cfg, err = Load(cfgPath) + if err != nil { + t.Fatalf("load config with env: %v", err) + } + if cfg.Skills.MaxUploadSizeMB != 128 { + t.Fatalf("env max upload size = %d, want 128", cfg.Skills.MaxUploadSizeMB) + } +} diff --git a/internal/http/skills.go b/internal/http/skills.go index 52078ca32f..bdfc7aa2fc 100644 --- a/internal/http/skills.go +++ b/internal/http/skills.go @@ -21,8 +21,6 @@ import ( "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) -const maxSkillUploadSize = 20 << 20 // 20 MB - var ( aggregateInstallDeps = skills.AggregateMissingDeps installManagedDeps = skills.InstallDeps @@ -37,13 +35,29 @@ type SkillsHandler struct { msgBus *bus.MessageBus tenantCfgStore store.SkillTenantConfigStore tenantStore store.TenantStore - db *sql.DB // for export/import direct queries + db *sql.DB // for export/import direct queries uploadLocks sync.Map // per-slug mutex; bounded by validated slug set, entries are tiny (*sync.Mutex) + maxUploadSize int64 // per-file upload cap in bytes } // NewSkillsHandler creates a handler for skill management endpoints. func NewSkillsHandler(skills store.SkillManageStore, baseDir, dataDir, bundledDir string, msgBus *bus.MessageBus, tenantCfgStore store.SkillTenantConfigStore, tenantStore store.TenantStore) *SkillsHandler { - return &SkillsHandler{skills: skills, baseDir: baseDir, dataDir: dataDir, bundledDir: bundledDir, msgBus: msgBus, tenantCfgStore: tenantCfgStore, tenantStore: tenantStore} + return &SkillsHandler{skills: skills, baseDir: baseDir, dataDir: dataDir, bundledDir: bundledDir, msgBus: msgBus, tenantCfgStore: tenantCfgStore, tenantStore: tenantStore, maxUploadSize: config.SkillsConfig{}.MaxUploadSizeBytes()} +} + +// SetMaxUploadSizeBytes configures the per-file upload cap for skill ZIP uploads. +func (h *SkillsHandler) SetMaxUploadSizeBytes(size int64) { + if size <= 0 { + size = config.SkillsConfig{}.MaxUploadSizeBytes() + } + h.maxUploadSize = size +} + +func (h *SkillsHandler) maxSkillUploadSize() int64 { + if h.maxUploadSize <= 0 { + return config.SkillsConfig{}.MaxUploadSizeBytes() + } + return h.maxUploadSize } // tenantSkillsDir returns the skills-store directory scoped to the requesting tenant. diff --git a/internal/http/skills_upload.go b/internal/http/skills_upload.go index ea1cbe43c5..a2600f76a2 100644 --- a/internal/http/skills_upload.go +++ b/internal/http/skills_upload.go @@ -39,7 +39,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { return } - r.Body = http.MaxBytesReader(w, r.Body, maxSkillUploadSize) + r.Body = http.MaxBytesReader(w, r.Body, h.maxSkillUploadSize()) file, header, err := r.FormFile("file") if err != nil { diff --git a/internal/http/skills_upload_test.go b/internal/http/skills_upload_test.go index f8dca60aab..a45498d141 100644 --- a/internal/http/skills_upload_test.go +++ b/internal/http/skills_upload_test.go @@ -11,6 +11,7 @@ import ( "net/http/httptest" "path/filepath" "reflect" + "strings" "testing" "github.com/google/uuid" @@ -308,6 +309,22 @@ func TestHandleInstallDeps_ExistingEndpointStillReturnsInstallResult(t *testing. } } +func TestHandleUpload_UsesConfiguredMaxUploadSize(t *testing.T) { + handler, _, ctx, _ := newTestUploadHandler(t) + handler.SetMaxUploadSizeBytes(64) + + req := newZipUploadRequest(t, ctx, map[string]string{ + "SKILL.md": skillMarkdown("Too Large Skill", "too-large-skill"), + "asset.txt": strings.Repeat("x", 2048), + }) + w := httptest.NewRecorder() + handler.handleUpload(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body = %s", w.Code, http.StatusBadRequest, w.Body.String()) + } +} + func newTestUploadHandler(t *testing.T) (*SkillsHandler, *skillManageStoreStub, context.Context, string) { t.Helper()