diff --git a/cmd/gateway.go b/cmd/gateway.go index a3798128..8cc54660 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -383,7 +383,7 @@ func runGateway() { httpapi.InitGatewayNoAuthFallbackAllowed(config.GatewayNoAuthFallbackAllowed(cfg.Gateway)) 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, usageCapSvc) + 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, usageCapSvc, cfg.Skills) // Wire dependencies for system prompt preview parity. if agentsH != nil { diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index ce8586ca..7d7cb532 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -2,6 +2,7 @@ package cmd import ( "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" httpapi "github.com/nextlevelbuilder/goclaw/internal/http" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -10,7 +11,7 @@ import ( ) // wireHTTP creates HTTP handlers (agents + skills + traces + MCP + channel instances + providers + builtin tools + pending messages). -func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, modelReg providers.ModelRegistry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister, usageCapSvc *usagecaps.Service) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler, *httpapi.SecureCLIGrantHandler, *httpapi.MCPUserCredentialsHandler) { +func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, modelReg providers.ModelRegistry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister, usageCapSvc *usagecaps.Service, skillUploadConfig config.SkillsConfig) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler, *httpapi.SecureCLIGrantHandler, *httpapi.MCPUserCredentialsHandler) { var agentsH *httpapi.AgentsHandler var skillsH *httpapi.SkillsHandler var tracesH *httpapi.TracesHandler @@ -38,6 +39,10 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir if len(dirs) > 0 { skillsH = httpapi.NewSkillsHandler(manageStore, dirs[0], dataDir, bundledSkillsDir, msgBus, stores.SkillTenantCfgs, stores.Tenants) skillsH.SetDB(stores.DB) + skillsH.SetUploadLimitConfig(skillUploadConfig) + if stores.SystemConfigs != nil { + skillsH.SetSystemConfigStore(stores.SystemConfigs) + } } } } diff --git a/cmd/gateway_system_config_sync_test.go b/cmd/gateway_system_config_sync_test.go index 3b9e2b2c..be831ca6 100644 --- a/cmd/gateway_system_config_sync_test.go +++ b/cmd/gateway_system_config_sync_test.go @@ -46,3 +46,17 @@ func TestSeedConfigForContextPersistsZeroInboundDebounce(t *testing.T) { t.Fatalf("gateway.inbound_debounce_ms = %q, want 0", got) } } + +func TestSeedConfigForContextDoesNotCreateSkillUploadTenantOverride(t *testing.T) { + t.Parallel() + + sc := &captureSystemConfigStore{data: map[string]string{}} + cfg := config.Default() + cfg.Skills.MaxUploadSizeMB = 64 + + seedConfigForContext(store.WithTenantID(context.Background(), store.MasterTenantID), sc, cfg, false) + + if _, ok := sc.data[config.SkillMaxUploadSizeSystemConfigKey]; ok { + t.Fatalf("%s should not be seeded; missing key lets SKILL.md frontmatter override global config", config.SkillMaxUploadSizeSystemConfigKey) + } +} diff --git a/docs/04-gateway-protocol.md b/docs/04-gateway-protocol.md index 61f764bb..91af3125 100644 --- a/docs/04-gateway-protocol.md +++ b/docs/04-gateway-protocol.md @@ -442,7 +442,7 @@ All CRUD endpoints require `Authorization: Bearer ` and `X-GoClaw-User-Id | Method | Path | Description | |--------|------|-------------| | GET | `/v1/skills` | List skills | -| POST | `/v1/skills/upload` | Upload skill ZIP (max 20 MB) | +| POST | `/v1/skills/upload` | Upload skill ZIP (configurable, default 20 MB, max 500 MB) | | DELETE | `/v1/skills/{id}` | Delete a skill | **Traces** (`/v1/traces`): diff --git a/docs/16-skill-publishing.md b/docs/16-skill-publishing.md index 14acb69e..7244c1e5 100644 --- a/docs/16-skill-publishing.md +++ b/docs/16-skill-publishing.md @@ -183,7 +183,7 @@ Unlike the HTTP upload handler, the tool does **not** archive the skill on missi | `..` in relative path | Skip (prevent traversal) | | Symlinks | Skip (prevent escape) | | System artifacts | Skip (`.DS_Store`, `__MACOSX`, `Thumbs.db`, etc.) | -| Total dir size > 20 MB | Reject with error | +| Total upload size exceeds configured limit | Reject with error. Default is 20 MB; configurable via `skills.max_upload_size_mb`, `GOCLAW_SKILLS_MAX_UPLOAD_SIZE_MB`, tenant `skills.max_upload_size_mb`, or SKILL.md frontmatter `max_upload_size_mb`; clamped to 1-500 MB. | --- diff --git a/docs/18-http-api.md b/docs/18-http-api.md index d2b16dad..3d4ef5ab 100644 --- a/docs/18-http-api.md +++ b/docs/18-http-api.md @@ -321,7 +321,7 @@ Use `direct_selection_count` plus the `selected_provider` sequence to verify rea | Method | Path | Description | |--------|------|-------------| | `GET` | `/v1/skills` | List all skills | -| `POST` | `/v1/skills/upload` | Upload ZIP with SKILL.md (20 MB limit) | +| `POST` | `/v1/skills/upload` | Upload ZIP with SKILL.md (configurable 20 MB default, 1-500 MB range) | | `GET` | `/v1/skills/{id}` | Get skill details | | `PUT` | `/v1/skills/{id}` | Update skill metadata | | `DELETE` | `/v1/skills/{id}` | Delete skill (not system skills) | @@ -331,6 +331,12 @@ Use `direct_selection_count` plus the `selected_provider` sequence to verify rea ### Skill Grants +Skill upload size is enforced per ZIP file. The effective limit resolves in this order: +tenant `system_configs["skills.max_upload_size_mb"]`, then `SKILL.md` frontmatter +`max_upload_size_mb`, then config/env `skills.max_upload_size_mb` / +`GOCLAW_SKILLS_MAX_UPLOAD_SIZE_MB`, then the default 20 MB. Values are clamped +to 1-500 MB. + | Method | Path | Description | |--------|------|-------------| | `POST` | `/v1/skills/{id}/grants/agent` | Grant skill to agent | diff --git a/docs/21-agent-evolution-and-skill-management.md b/docs/21-agent-evolution-and-skill-management.md index 21be814c..06ddf443 100644 --- a/docs/21-agent-evolution-and-skill-management.md +++ b/docs/21-agent-evolution-and-skill-management.md @@ -450,7 +450,7 @@ System skills (`is_system=true`) cannot be modified through any path. | Symlink detection | `filepath.WalkDir` + `d.Type()&os.ModeSymlink` check | | Path traversal | `strings.Contains(rel, "..")` rejection | | Content size limit | 100KB max for SKILL.md content | -| Companion size limit | 20MB max total for companion files (scripts, assets) | +| Companion size limit | Configurable per ZIP upload; default 20MB, clamped to 1-500MB | | Soft-delete | Files moved to `.trash/`, never hard-deleted | --- diff --git a/docs/project-changelog.md b/docs/project-changelog.md index d32a8800..d3f7094f 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -6,6 +6,17 @@ Significant changes, features, and fixes in reverse chronological order. ## 2026-05-24 +### Configurable skill upload limits + +**Features** + +- Added configurable skill ZIP upload limits with config/env, SKILL.md frontmatter, and tenant system setting support. +- Added dashboard settings and dynamic upload validation so the Web UI follows the tenant limit instead of hardcoding 20MB. + +**Tests** + +- Added backend coverage for limit precedence, clamping, oversized rejection, and frontend coverage for parameterized upload validation. + ### CLI environment variable visibility **Features** diff --git a/internal/config/config.go b/internal/config/config.go index bdc2cd36..b4d7102d 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"` @@ -129,7 +130,37 @@ 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 ZIP upload limit +} + +const ( + DefaultSkillMaxUploadSizeMB = 20 + MinSkillMaxUploadSizeMB = 1 + MaxSkillMaxUploadSizeMB = 500 + + SkillMaxUploadSizeSystemConfigKey = "skills.max_upload_size_mb" +) + +func ClampSkillMaxUploadSizeMB(value int) int { + if value == 0 { + return DefaultSkillMaxUploadSizeMB + } + if value < MinSkillMaxUploadSizeMB { + return MinSkillMaxUploadSizeMB + } + if value > MaxSkillMaxUploadSizeMB { + return MaxSkillMaxUploadSizeMB + } + return value +} + +func (c SkillsConfig) EffectiveMaxUploadSizeMB() int { + return ClampSkillMaxUploadSizeMB(c.MaxUploadSizeMB) +} + +func (c SkillsConfig) EffectiveMaxUploadSizeBytes() int64 { + return int64(c.EffectiveMaxUploadSizeMB()) << 20 } // AgentBinding maps a channel/peer pattern to a specific agent. @@ -479,6 +510,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 205dd511..18cc3e1b 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -107,6 +107,9 @@ func Default() *Config { }, RateLimitPerHour: 150, }, + Skills: SkillsConfig{ + MaxUploadSizeMB: DefaultSkillMaxUploadSizeMB, + }, Sessions: SessionsConfig{}, } } @@ -234,6 +237,11 @@ func (c *Config) applyEnvOverrides() { c.Gateway.Port = port } } + if v := os.Getenv("GOCLAW_SKILLS_MAX_UPLOAD_SIZE_MB"); v != "" { + if mb, err := strconv.Atoi(v); err == nil { + c.Skills.MaxUploadSizeMB = ClampSkillMaxUploadSizeMB(mb) + } + } // Database envStr("GOCLAW_POSTGRES_DSN", &c.Database.PostgresDSN) diff --git a/internal/config/config_load_test.go b/internal/config/config_load_test.go index 14923e73..a1fe6f9c 100644 --- a/internal/config/config_load_test.go +++ b/internal/config/config_load_test.go @@ -24,6 +24,9 @@ func TestDefault_SensibleDefaults(t *testing.T) { if cfg.Agents.Defaults.MaxToolIterations != DefaultMaxIterations { t.Fatalf("default max iterations: got %d", cfg.Agents.Defaults.MaxToolIterations) } + if cfg.Skills.EffectiveMaxUploadSizeMB() != DefaultSkillMaxUploadSizeMB { + t.Fatalf("default skill upload max: got %d, want %d", cfg.Skills.EffectiveMaxUploadSizeMB(), DefaultSkillMaxUploadSizeMB) + } } @@ -103,6 +106,52 @@ func TestLoad_EnvVarOverrides(t *testing.T) { } } +func TestLoad_SkillsMaxUploadSizeFromFileAndEnv(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json5") + os.WriteFile(cfgPath, []byte(`{"skills":{"max_upload_size_mb":64}}`), 0644) + + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("load error: %v", err) + } + if cfg.Skills.EffectiveMaxUploadSizeMB() != 64 { + t.Fatalf("file skill upload max: got %d, want 64", cfg.Skills.EffectiveMaxUploadSizeMB()) + } + + t.Setenv("GOCLAW_SKILLS_MAX_UPLOAD_SIZE_MB", "128") + cfg, err = Load(cfgPath) + if err != nil { + t.Fatalf("load with env error: %v", err) + } + if cfg.Skills.EffectiveMaxUploadSizeMB() != 128 { + t.Fatalf("env skill upload max: got %d, want 128", cfg.Skills.EffectiveMaxUploadSizeMB()) + } +} + +func TestSkillsMaxUploadSizeClampAndSystemConfigOverlay(t *testing.T) { + cfg := Default() + cfg.Skills.MaxUploadSizeMB = 0 + if got := cfg.Skills.EffectiveMaxUploadSizeMB(); got != DefaultSkillMaxUploadSizeMB { + t.Fatalf("zero upload max: got %d, want %d", got, DefaultSkillMaxUploadSizeMB) + } + + cfg.Skills.MaxUploadSizeMB = -10 + if got := cfg.Skills.EffectiveMaxUploadSizeMB(); got != MinSkillMaxUploadSizeMB { + t.Fatalf("negative upload max: got %d, want %d", got, MinSkillMaxUploadSizeMB) + } + + cfg.Skills.MaxUploadSizeMB = 999 + if got := cfg.Skills.EffectiveMaxUploadSizeMB(); got != MaxSkillMaxUploadSizeMB { + t.Fatalf("high upload max: got %d, want %d", got, MaxSkillMaxUploadSizeMB) + } + + cfg.ApplySystemConfigs(map[string]string{"skills.max_upload_size_mb": "77"}) + if got := cfg.Skills.EffectiveMaxUploadSizeMB(); got != 77 { + t.Fatalf("system config upload max: got %d, want 77", got) + } +} + func TestLoad_EnvVarOverrides_InvalidPort(t *testing.T) { t.Setenv("GOCLAW_PORT", "not-a-number") diff --git a/internal/config/config_system.go b/internal/config/config_system.go index 4789327c..6b2a9365 100644 --- a/internal/config/config_system.go +++ b/internal/config/config_system.go @@ -74,6 +74,10 @@ func (c *Config) ApplySystemConfigs(configs map[string]string) { integer("tools.browser.max_pages", &c.Tools.Browser.MaxPages) boolValue("tools.browser.cookie_sync_enabled", &c.Tools.Browser.CookieSyncEnabled) + // Skills + integer(SkillMaxUploadSizeSystemConfigKey, &c.Skills.MaxUploadSizeMB) + c.Skills.MaxUploadSizeMB = ClampSkillMaxUploadSizeMB(c.Skills.MaxUploadSizeMB) + // TTS str("tts.provider", &c.Tts.Provider) str("tts.auto", &c.Tts.Auto) diff --git a/internal/gateway/methods/config.go b/internal/gateway/methods/config.go index 590d66a8..d27c9f45 100644 --- a/internal/gateway/methods/config.go +++ b/internal/gateway/methods/config.go @@ -265,14 +265,27 @@ func (m *ConfigMethods) handleSchema(_ context.Context, client *gateway.Client, "type": "object", "description": "Gateway server settings (host, port, token)", }, - "tools": map[string]any{ - "type": "object", - "description": "Tool configuration (browser, exec, web search)", - }, - "sessions": map[string]any{ - "type": "object", - "description": "Session storage configuration", - }, + "tools": map[string]any{ + "type": "object", + "description": "Tool configuration (browser, exec, web search)", + }, + "skills": map[string]any{ + "type": "object", + "description": "Skill storage and upload settings", + "properties": map[string]any{ + "max_upload_size_mb": map[string]any{ + "type": "integer", + "minimum": config.MinSkillMaxUploadSizeMB, + "maximum": config.MaxSkillMaxUploadSizeMB, + "default": config.DefaultSkillMaxUploadSizeMB, + "description": "Maximum skill ZIP upload size in MB", + }, + }, + }, + "sessions": map[string]any{ + "type": "object", + "description": "Session storage configuration", + }, }, } diff --git a/internal/http/skills.go b/internal/http/skills.go index 7457cbe8..7d04a4e5 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 @@ -40,11 +38,13 @@ type SkillsHandler struct { tenantStore store.TenantStore db *sql.DB // for export/import direct queries uploadLocks sync.Map // per-slug mutex; bounded by validated slug set, entries are tiny (*sync.Mutex) + uploadLimitCfg config.SkillsConfig + systemConfigs store.SystemConfigStore } // 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, uploadLimitCfg: config.SkillsConfig{MaxUploadSizeMB: config.DefaultSkillMaxUploadSizeMB}} } // tenantSkillsDir returns the skills-store directory scoped to the requesting tenant. diff --git a/internal/http/skills_grants.go b/internal/http/skills_grants.go index eccebcad..60ce877b 100644 --- a/internal/http/skills_grants.go +++ b/internal/http/skills_grants.go @@ -1,8 +1,6 @@ package http import ( - "archive/zip" - "io" "log/slog" "net/http" @@ -225,16 +223,3 @@ func (h *SkillsHandler) handleRevokeUser(w http.ResponseWriter, r *http.Request) } // --- Helpers --- - -func readZipFile(f *zip.File) (string, error) { - rc, err := f.Open() - if err != nil { - return "", err - } - defer rc.Close() - data, err := io.ReadAll(rc) - if err != nil { - return "", err - } - return string(data), nil -} diff --git a/internal/http/skills_upload.go b/internal/http/skills_upload.go index dbd9e14d..4f69171f 100644 --- a/internal/http/skills_upload.go +++ b/internal/http/skills_upload.go @@ -5,6 +5,7 @@ import ( "context" "crypto/sha256" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -43,7 +44,8 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { return } - r.Body = http.MaxBytesReader(w, r.Body, maxSkillUploadSize) + preParseLimitMB := h.resolvePreParseUploadLimitMB(r.Context()) + r.Body = http.MaxBytesReader(w, r.Body, skillUploadBodyBytes(preParseLimitMB)) file, header, err := r.FormFile("file") if err != nil { @@ -70,6 +72,11 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { size, err := io.Copy(tmp, file) if err != nil { tmp.Close() + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, skillUploadTooLargeMessage(maxBytesErr.Limit, preParseLimitMB))}) + return + } writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save upload")}) return } @@ -141,6 +148,11 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { } name, description, slug, frontmatter := skills.ParseSkillFrontmatter(skillContent) + uploadLimitMB := h.resolveSkillUploadLimitMB(r.Context(), frontmatter) + if size > skillUploadLimitBytes(uploadLimitMB) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, skillUploadTooLargeMessage(size, uploadLimitMB))}) + return + } if name == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name in SKILL.md frontmatter")}) return @@ -239,17 +251,11 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create skill file directory")}) return } - data, err := readZipFile(f) - if err != nil { + if err := copyZipFileToPath(f, destPath); err != nil { os.RemoveAll(destDir) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "failed to read ZIP entry")}) return } - if err := os.WriteFile(destPath, []byte(data), 0644); err != nil { - os.RemoveAll(destDir) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to write skill files")}) - return - } if cleanName == "SKILL.md" { wroteSkillMD = true } diff --git a/internal/http/skills_upload_limits.go b/internal/http/skills_upload_limits.go new file mode 100644 index 00000000..75a31a09 --- /dev/null +++ b/internal/http/skills_upload_limits.go @@ -0,0 +1,88 @@ +package http + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const ( + skillUploadMaxSizeConfigKey = config.SkillMaxUploadSizeSystemConfigKey + skillUploadMultipartOverheadBytes = int64(1 << 20) +) + +func (h *SkillsHandler) SetUploadLimitConfig(cfg config.SkillsConfig) { + h.uploadLimitCfg = cfg +} + +func (h *SkillsHandler) SetSystemConfigStore(s store.SystemConfigStore) { + h.systemConfigs = s +} + +func (h *SkillsHandler) resolveSkillUploadLimitMB(ctx context.Context, frontmatter map[string]string) int { + if mb, ok := h.resolveTenantSkillUploadLimitMB(ctx); ok { + return mb + } + if mb, ok := parseSkillUploadLimitMB(frontmatter["max_upload_size_mb"]); ok { + return config.ClampSkillMaxUploadSizeMB(mb) + } + return h.uploadLimitCfg.EffectiveMaxUploadSizeMB() +} + +func (h *SkillsHandler) resolvePreParseUploadLimitMB(ctx context.Context) int { + if mb, ok := h.resolveTenantSkillUploadLimitMB(ctx); ok { + return mb + } + return config.MaxSkillMaxUploadSizeMB +} + +func (h *SkillsHandler) resolveTenantSkillUploadLimitMB(ctx context.Context) (int, bool) { + if h.systemConfigs == nil { + return 0, false + } + raw, err := h.systemConfigs.Get(ctx, skillUploadMaxSizeConfigKey) + if err != nil { + return 0, false + } + mb, ok := parseSkillUploadLimitMB(raw) + if !ok { + return 0, false + } + return config.ClampSkillMaxUploadSizeMB(mb), true +} + +func parseSkillUploadLimitMB(raw string) (int, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, false + } + mb, err := strconv.Atoi(raw) + if err != nil { + return 0, false + } + return mb, true +} + +func skillUploadLimitBytes(limitMB int) int64 { + return int64(config.ClampSkillMaxUploadSizeMB(limitMB)) << 20 +} + +func skillUploadBodyBytes(limitMB int) int64 { + return skillUploadLimitBytes(limitMB) + skillUploadMultipartOverheadBytes +} + +func skillUploadTooLargeMessage(size int64, limitMB int) string { + return fmt.Sprintf("skill ZIP size %s exceeds %d MB limit", formatUploadBytes(size), limitMB) +} + +func formatUploadBytes(size int64) string { + const mb = int64(1 << 20) + if size >= mb { + return fmt.Sprintf("%.1f MB", float64(size)/float64(mb)) + } + return fmt.Sprintf("%d bytes", size) +} diff --git a/internal/http/skills_upload_test.go b/internal/http/skills_upload_test.go index c2bb6a0a..de45592c 100644 --- a/internal/http/skills_upload_test.go +++ b/internal/http/skills_upload_test.go @@ -12,11 +12,13 @@ import ( "net/http/httptest" "path/filepath" "reflect" + "strings" "testing" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" @@ -670,6 +672,65 @@ func TestHandleUpload_ReturnsGrantErrors(t *testing.T) { } } +func TestResolveSkillUploadLimitMBPrecedenceAndClamp(t *testing.T) { + ctx := store.WithTenantID(context.Background(), store.MasterTenantID) + handler, _, _, _ := newTestUploadHandler(t) + handler.SetUploadLimitConfig(config.SkillsConfig{MaxUploadSizeMB: 30}) + + if got := handler.resolveSkillUploadLimitMB(ctx, nil); got != 30 { + t.Fatalf("global limit = %d, want 30", got) + } + if got := handler.resolveSkillUploadLimitMB(ctx, map[string]string{"max_upload_size_mb": "100"}); got != 100 { + t.Fatalf("frontmatter limit = %d, want 100", got) + } + + handler.SetSystemConfigStore(&skillUploadSystemConfigStore{data: map[string]string{skillUploadMaxSizeConfigKey: "40"}}) + if got := handler.resolveSkillUploadLimitMB(ctx, map[string]string{"max_upload_size_mb": "100"}); got != 40 { + t.Fatalf("tenant limit = %d, want 40", got) + } + + handler.SetSystemConfigStore(&skillUploadSystemConfigStore{data: map[string]string{skillUploadMaxSizeConfigKey: "999"}}) + if got := handler.resolveSkillUploadLimitMB(ctx, nil); got != config.MaxSkillMaxUploadSizeMB { + t.Fatalf("clamped tenant limit = %d, want %d", got, config.MaxSkillMaxUploadSizeMB) + } +} + +func TestUploadRejectsZipAboveConfiguredLimit(t *testing.T) { + handler, _, ctx, _ := newTestUploadHandler(t) + handler.SetUploadLimitConfig(config.SkillsConfig{MaxUploadSizeMB: 1}) + + req := newZipUploadRequestWithBinary(t, ctx, map[string][]byte{ + "SKILL.md": []byte(skillMarkdown("Large Skill", "large-skill")), + "large.bin": bytes.Repeat([]byte("x"), (1<<20)+1), + }) + w := httptest.NewRecorder() + handler.handleUpload(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "exceeds") || !strings.Contains(w.Body.String(), "1 MB") { + t.Fatalf("body = %s, want configured upload limit error", w.Body.String()) + } +} + +func TestUploadAllowsFrontmatterLimitAboveGlobalDefault(t *testing.T) { + handler, _, ctx, _ := newTestUploadHandler(t) + handler.SetUploadLimitConfig(config.SkillsConfig{MaxUploadSizeMB: 1}) + skillMD := "---\nname: Video Skill\nslug: video-skill\nmax_upload_size_mb: 2\n---\nSkill body\n" + + req := newZipUploadRequestWithBinary(t, ctx, map[string][]byte{ + "SKILL.md": []byte(skillMD), + "large.bin": bytes.Repeat([]byte("x"), (1<<20)+(128<<10)), + }) + w := httptest.NewRecorder() + handler.handleUpload(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } +} + func newTestUploadHandler(t *testing.T) (*SkillsHandler, *skillManageStoreStub, context.Context, string) { t.Helper() @@ -738,6 +799,43 @@ func newZipUploadRequestWithManagers(t *testing.T, ctx context.Context, files ma return req.WithContext(ctx) } +func newZipUploadRequestWithBinary(t *testing.T, ctx context.Context, files map[string][]byte) *http.Request { + t.Helper() + + var zipBuf bytes.Buffer + zw := zip.NewWriter(&zipBuf) + for name, content := range files { + header := &zip.FileHeader{Name: name, Method: zip.Store} + w, err := zw.CreateHeader(header) + if err != nil { + t.Fatalf("zip create %s: %v", name, err) + } + if _, err := w.Write(content); err != nil { + t.Fatalf("zip write %s: %v", name, err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("zip close: %v", err) + } + + var body bytes.Buffer + mw := multipart.NewWriter(&body) + part, err := mw.CreateFormFile("file", "skill.zip") + if err != nil { + t.Fatalf("multipart file: %v", err) + } + if _, err := part.Write(zipBuf.Bytes()); err != nil { + t.Fatalf("multipart write: %v", err) + } + if err := mw.Close(); err != nil { + t.Fatalf("multipart close: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/v1/skills/upload", &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + return req.WithContext(ctx) +} + func newUploadManagerIDsFormRequest(t *testing.T, raw string) *http.Request { t.Helper() @@ -770,6 +868,34 @@ type skillManageStoreStub struct { lastUpdates map[uuid.UUID]map[string]any } +type skillUploadSystemConfigStore struct { + data map[string]string +} + +func (s *skillUploadSystemConfigStore) Get(_ context.Context, key string) (string, error) { + if v, ok := s.data[key]; ok { + return v, nil + } + return "", errors.New("not found") +} + +func (s *skillUploadSystemConfigStore) Set(_ context.Context, key, value string) error { + if s.data == nil { + s.data = map[string]string{} + } + s.data[key] = value + return nil +} + +func (s *skillUploadSystemConfigStore) Delete(_ context.Context, key string) error { + delete(s.data, key) + return nil +} + +func (s *skillUploadSystemConfigStore) List(_ context.Context) (map[string]string, error) { + return s.data, nil +} + type skillGrantCall struct { SkillID uuid.UUID AgentID uuid.UUID diff --git a/internal/http/skills_zip_helpers.go b/internal/http/skills_zip_helpers.go new file mode 100644 index 00000000..6a798400 --- /dev/null +++ b/internal/http/skills_zip_helpers.go @@ -0,0 +1,46 @@ +package http + +import ( + "archive/zip" + "errors" + "io" + "os" +) + +const maxSkillMarkdownBytes = 100 << 10 + +var errSkillMarkdownTooLarge = errors.New("SKILL.md exceeds 100 KB limit") + +func readZipFile(f *zip.File) (string, error) { + rc, err := f.Open() + if err != nil { + return "", err + } + defer rc.Close() + + data, err := io.ReadAll(io.LimitReader(rc, maxSkillMarkdownBytes+1)) + if err != nil { + return "", err + } + if len(data) > maxSkillMarkdownBytes { + return "", errSkillMarkdownTooLarge + } + return string(data), nil +} + +func copyZipFileToPath(f *zip.File, destPath string) error { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + dst, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer dst.Close() + + _, err = io.Copy(dst, rc) + return err +} diff --git a/ui/web/src/components/layout/system-settings-constants.ts b/ui/web/src/components/layout/system-settings-constants.ts index 3bb1226c..7d0e3174 100644 --- a/ui/web/src/components/layout/system-settings-constants.ts +++ b/ui/web/src/components/layout/system-settings-constants.ts @@ -44,6 +44,7 @@ export interface InitState { kgMinConfidence: string; bgProvider: string; bgModel: string; + skillUploadMaxSize: string; } export const DEFAULTS: InitState = { @@ -54,6 +55,7 @@ export const DEFAULTS: InitState = { compThreshold: "", compKeepRecent: "", compMaxTokens: "", kgProvider: "", kgModel: "", kgMinConfidence: "0.75", bgProvider: "", bgModel: "", + skillUploadMaxSize: "20", }; export function parseBool(v: string | undefined, fallback: boolean): boolean { diff --git a/ui/web/src/components/layout/system-settings-modal.tsx b/ui/web/src/components/layout/system-settings-modal.tsx index 01cc4815..a7ae8d74 100644 --- a/ui/web/src/components/layout/system-settings-modal.tsx +++ b/ui/web/src/components/layout/system-settings-modal.tsx @@ -17,6 +17,7 @@ import { toast } from "@/stores/use-toast-store"; import { EMBEDDING_MODELS, DEFAULT_EMBEDDING_MODELS, DEFAULTS, parseBool, type InitState } from "./system-settings-constants"; import { SystemSettingsEmbeddingCard } from "./system-settings-embedding-card"; import { SystemSettingsCompactionCard } from "./system-settings-compaction-card"; +import { SystemSettingsSkillsCard } from "./system-settings-skills-card"; import { Eye, MessageSquareText, Brain } from "lucide-react"; interface SystemSettingsModalProps { @@ -60,6 +61,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP // Background Workers const [bgProvider, setBgProvider] = useState(""); const [bgModel, setBgModel] = useState(""); + const [skillUploadMaxSize, setSkillUploadMaxSize] = useState("20"); const applyConfigs = useCallback(( configs: Record, @@ -76,6 +78,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP kgProvider: kgSettings?.extraction_provider ?? "", kgModel: kgSettings?.extraction_model ?? "", kgMinConfidence: String(kgSettings?.min_confidence ?? 0.75), bgProvider: configs["background.provider"] ?? "", bgModel: configs["background.model"] ?? "", + skillUploadMaxSize: configs["skills.max_upload_size_mb"] ?? "20", }; setInit(s); setEmbProvider(s.embProvider); setEmbModel(s.embModel); setEmbMaxChunkLen(s.embMaxChunkLen); setEmbChunkOverlap(s.embChunkOverlap); @@ -83,6 +86,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP setCompProvider(s.compProvider); setCompModel(s.compModel); setCompThreshold(s.compThreshold); setCompKeepRecent(s.compKeepRecent); setCompMaxTokens(s.compMaxTokens); setKgProvider(s.kgProvider); setKgModel(s.kgModel); setKgMinConfidence(s.kgMinConfidence); setBgProvider(s.bgProvider); setBgModel(s.bgModel); + setSkillUploadMaxSize(s.skillUploadMaxSize); resetEmb(); }, [resetEmb]); @@ -126,6 +130,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP if (compMaxTokens !== init.compMaxTokens) updates["compaction.max_tokens"] = compMaxTokens; if (bgProvider !== init.bgProvider) updates["background.provider"] = bgProvider; if (bgModel !== init.bgModel) updates["background.model"] = bgModel; + if (skillUploadMaxSize !== init.skillUploadMaxSize) updates["skills.max_upload_size_mb"] = skillUploadMaxSize; for (const [key, value] of Object.entries(updates)) await http.put(`/v1/system-configs/${key}`, { value }); const kgChanged = kgProvider !== init.kgProvider || kgModel !== init.kgModel || kgMinConfidence !== init.kgMinConfidence; if (kgChanged) { @@ -203,6 +208,11 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP + + void; +} + +export function SystemSettingsSkillsCard({ + uploadMaxSize, + setUploadMaxSize, +}: SystemSettingsSkillsCardProps) { + const { t } = useTranslation("system-settings"); + + return ( + + + + + {t("skills.title")} + + {t("skills.description")} + + +
+
+ +

{t("skills.maxUploadSizeHint")}

+
+ setUploadMaxSize(e.target.value)} + className="w-24 shrink-0 text-base md:text-sm" + /> +
+
+
+ ); +} diff --git a/ui/web/src/i18n/locales/en/skills.json b/ui/web/src/i18n/locales/en/skills.json index 69801a19..e8f01366 100644 --- a/ui/web/src/i18n/locales/en/skills.json +++ b/ui/web/src/i18n/locales/en/skills.json @@ -106,7 +106,7 @@ }, "upload": { "title": "Upload Skills", - "description": "Upload ZIP files containing SKILL.md with YAML frontmatter (name, description, slug).", + "description": "Upload ZIP files containing SKILL.md with YAML frontmatter (name, description, slug). Max {{max}} MB per file.", "button": "Upload", "uploading": "Uploading...", "dropOrClick": "Click or drag & drop .zip files", @@ -114,7 +114,7 @@ "onlyZip": "Only .zip files are accepted", "cancel": "Cancel", "done": "Done", - "tooLarge": "File exceeds 20MB limit", + "tooLarge": "File exceeds the configured upload limit", "invalidZip": "Invalid ZIP file", "noSkillMd": "ZIP must contain SKILL.md at root", "emptySkillMd": "SKILL.md is empty", diff --git a/ui/web/src/i18n/locales/en/system-settings.json b/ui/web/src/i18n/locales/en/system-settings.json index fafc76c7..2b9c96a3 100644 --- a/ui/web/src/i18n/locales/en/system-settings.json +++ b/ui/web/src/i18n/locales/en/system-settings.json @@ -64,6 +64,12 @@ "modelPlaceholder": "(default)", "info": "Used for vault enrichment (document summarization), consolidation (session summaries), and dreaming. Leave empty to fall back to the agent default provider." }, + "skills": { + "title": "Skills", + "description": "Tenant skill package upload limits.", + "maxUploadSize": "Max upload size", + "maxUploadSizeHint": "Per-file ZIP limit in MB. Allowed range: 1-500." + }, "compaction": { "title": "Pending Message Compaction", "description": "Summarize long pending message history to stay within context limits.", diff --git a/ui/web/src/i18n/locales/vi/skills.json b/ui/web/src/i18n/locales/vi/skills.json index 2ea7605b..329d18e4 100644 --- a/ui/web/src/i18n/locales/vi/skills.json +++ b/ui/web/src/i18n/locales/vi/skills.json @@ -64,7 +64,7 @@ }, "upload": { "title": "Tải lên Skill", - "description": "Tải lên tệp ZIP chứa SKILL.md với YAML frontmatter (name, description, slug).", + "description": "Tải lên tệp ZIP chứa SKILL.md với YAML frontmatter (name, description, slug). Tối đa {{max}} MB mỗi tệp.", "button": "Tải lên", "uploading": "Đang tải lên...", "dropOrClick": "Nhấp hoặc kéo thả tệp .zip", @@ -72,7 +72,7 @@ "onlyZip": "Chỉ chấp nhận tệp .zip", "cancel": "Hủy", "done": "Xong", - "tooLarge": "Tệp vượt quá giới hạn 20MB", + "tooLarge": "Tệp vượt quá giới hạn tải lên đã cấu hình", "invalidZip": "Tệp ZIP không hợp lệ", "noSkillMd": "ZIP phải chứa SKILL.md ở thư mục gốc", "emptySkillMd": "SKILL.md trống", diff --git a/ui/web/src/i18n/locales/vi/system-settings.json b/ui/web/src/i18n/locales/vi/system-settings.json index 994ffc63..ebb8d3fc 100644 --- a/ui/web/src/i18n/locales/vi/system-settings.json +++ b/ui/web/src/i18n/locales/vi/system-settings.json @@ -64,6 +64,12 @@ "modelPlaceholder": "(mặc định)", "info": "Dùng cho vault enrichment (tóm tắt tài liệu), consolidation (tóm tắt phiên) và dreaming. Để trống sẽ fallback về agent default provider." }, + "skills": { + "title": "Skills", + "description": "Giới hạn tải lên gói skill theo tenant.", + "maxUploadSize": "Dung lượng tải lên tối đa", + "maxUploadSizeHint": "Giới hạn ZIP theo từng tệp, tính bằng MB. Khoảng cho phép: 1-500." + }, "compaction": { "title": "Nén tin nhắn chờ", "description": "Tóm tắt lịch sử tin nhắn chờ dài để nằm trong giới hạn context.", diff --git a/ui/web/src/i18n/locales/zh/skills.json b/ui/web/src/i18n/locales/zh/skills.json index a423d2ab..e5db5acb 100644 --- a/ui/web/src/i18n/locales/zh/skills.json +++ b/ui/web/src/i18n/locales/zh/skills.json @@ -64,7 +64,7 @@ }, "upload": { "title": "上传Skill", - "description": "上传包含带 YAML frontmatter (name, description, slug) 的 SKILL.md 的 ZIP 文件。", + "description": "上传包含带 YAML frontmatter (name, description, slug) 的 SKILL.md 的 ZIP 文件。每个文件最大 {{max}} MB。", "button": "上传", "uploading": "上传中...", "dropOrClick": "点击或拖放 .zip 文件", @@ -72,7 +72,7 @@ "onlyZip": "只接受 .zip 文件", "cancel": "取消", "done": "完成", - "tooLarge": "文件超过 20MB 限制", + "tooLarge": "文件超过配置的上传限制", "invalidZip": "无效的 ZIP 文件", "noSkillMd": "ZIP 必须包含根目录的 SKILL.md", "emptySkillMd": "SKILL.md 为空", diff --git a/ui/web/src/i18n/locales/zh/system-settings.json b/ui/web/src/i18n/locales/zh/system-settings.json index b87a3aaa..0b81a363 100644 --- a/ui/web/src/i18n/locales/zh/system-settings.json +++ b/ui/web/src/i18n/locales/zh/system-settings.json @@ -64,6 +64,12 @@ "modelPlaceholder": "(默认)", "info": "用于知识库文档摘要、会话整合和梦境。留空则回退到Agent默认Provider。" }, + "skills": { + "title": "技能", + "description": "租户技能包上传限制。", + "maxUploadSize": "最大上传大小", + "maxUploadSizeHint": "单个 ZIP 文件限制,单位 MB。允许范围:1-500。" + }, "compaction": { "title": "待处理消息压缩", "description": "压缩长待处理消息历史以保持在上下文限制内。", diff --git a/ui/web/src/pages/skills/hooks/use-skill-upload-limit.ts b/ui/web/src/pages/skills/hooks/use-skill-upload-limit.ts new file mode 100644 index 00000000..8b500f5a --- /dev/null +++ b/ui/web/src/pages/skills/hooks/use-skill-upload-limit.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; +import { useHttp } from "@/hooks/use-ws"; +import { + DEFAULT_SKILL_UPLOAD_SIZE_MB, + normalizeSkillUploadSizeMB, +} from "../lib/validate-skill-zip"; + +const SKILL_UPLOAD_LIMIT_KEY = "skills.max_upload_size_mb"; + +export function useSkillUploadLimit(open: boolean): number { + const http = useHttp(); + const [limit, setLimit] = useState(DEFAULT_SKILL_UPLOAD_SIZE_MB); + + useEffect(() => { + if (!open) return; + let cancelled = false; + + http.get>("/v1/system-configs") + .then((configs) => { + if (!cancelled) { + setLimit(normalizeSkillUploadSizeMB(Number(configs[SKILL_UPLOAD_LIMIT_KEY]))); + } + }) + .catch(() => { + if (!cancelled) setLimit(DEFAULT_SKILL_UPLOAD_SIZE_MB); + }); + + return () => { + cancelled = true; + }; + }, [open, http]); + + return limit; +} diff --git a/ui/web/src/pages/skills/lib/resolve-upload-skills.ts b/ui/web/src/pages/skills/lib/resolve-upload-skills.ts index 3a94c0ee..0854e7cd 100644 --- a/ui/web/src/pages/skills/lib/resolve-upload-skills.ts +++ b/ui/web/src/pages/skills/lib/resolve-upload-skills.ts @@ -1,9 +1,9 @@ -import { validateMultiSkillZip, type MultiSkillZipValidation } from "./validate-skill-zip"; +import { validateMultiSkillZip, type MultiSkillZipValidation, type SkillZipValidationOptions } from "./validate-skill-zip"; import type { SkillEntry, SkillStatus } from "./skill-upload-types"; type SkillEntrySeed = Omit; -type ValidateSkillArchive = (file: File) => Promise; +type ValidateSkillArchive = (file: File, options?: SkillZipValidationOptions) => Promise; // Browser-side ZIP parsing is best-effort only. Some valid ZIP variants are // accepted by the backend but rejected by JSZip, so fall back to a direct @@ -11,9 +11,10 @@ type ValidateSkillArchive = (file: File) => Promise; export async function resolveUploadSkills( file: File, validateArchive: ValidateSkillArchive = validateMultiSkillZip, + options: SkillZipValidationOptions = {}, ): Promise { try { - const validation = await validateArchive(file); + const validation = await validateArchive(file, options); if (validation.error === "upload.invalidZip") { return [fallbackUploadSkill()]; } diff --git a/ui/web/src/pages/skills/lib/validate-skill-zip.test.ts b/ui/web/src/pages/skills/lib/validate-skill-zip.test.ts index f20294b3..40f119b2 100644 --- a/ui/web/src/pages/skills/lib/validate-skill-zip.test.ts +++ b/ui/web/src/pages/skills/lib/validate-skill-zip.test.ts @@ -78,6 +78,20 @@ describe("validateSkillZip (backward compat)", () => { }); }); +describe("validateSkillZip upload size limit", () => { + it("uses caller-provided limit when validating file size", async () => { + const file = new File([new Uint8Array(2 * 1024 * 1024)], "large.zip", { type: "application/zip" }); + const result = await validateMultiSkillZip(file, { maxUploadSizeMB: 1 }); + expect(result.error).toBe("upload.tooLarge"); + }); + + it("defaults to 20MB for backward compatibility", async () => { + const file = new File([new Uint8Array(2 * 1024 * 1024)], "not-a-real.zip", { type: "application/zip" }); + const result = await validateSkillZip(file); + expect(result.error).toBe("upload.invalidZip"); + }); +}); + describe("validateMultiSkillZip", () => { it("detects multiple skills in ZIP", async () => { const file = await createTestZip({ diff --git a/ui/web/src/pages/skills/lib/validate-skill-zip.ts b/ui/web/src/pages/skills/lib/validate-skill-zip.ts index a55abeec..264feffa 100644 --- a/ui/web/src/pages/skills/lib/validate-skill-zip.ts +++ b/ui/web/src/pages/skills/lib/validate-skill-zip.ts @@ -36,8 +36,14 @@ export interface MultiSkillZipValidation { error?: string; } -// Constants matching server-side (internal/http/skills.go) -const MAX_SKILL_SIZE = 20 * 1024 * 1024; // 20MB +export interface SkillZipValidationOptions { + maxUploadSizeMB?: number; +} + +// Constants matching server-side skill upload limits. +export const DEFAULT_SKILL_UPLOAD_SIZE_MB = 20; +export const MIN_SKILL_UPLOAD_SIZE_MB = 1; +export const MAX_SKILL_UPLOAD_SIZE_MB = 500; const MAX_SKILLS_PER_ZIP = 50; const SLUG_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---/; @@ -50,8 +56,8 @@ const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---/; * Validate a skill ZIP file client-side — backward-compatible single-skill path. * Delegates to validateMultiSkillZip and returns the first skill's result. */ -export async function validateSkillZip(file: File): Promise { - const multi = await validateMultiSkillZip(file); +export async function validateSkillZip(file: File, options: SkillZipValidationOptions = {}): Promise { + const multi = await validateMultiSkillZip(file, options); if (multi.error) return { valid: false, error: multi.error }; const first = multi.skills[0]; if (!first) return { valid: false, error: "upload.noSkillMd" }; @@ -75,11 +81,11 @@ export async function validateSkillZip(file: File): Promise * Returns one SkillValidationEntry per detected SKILL.md, each independently * validated with a SHA-256 contentHash. */ -export async function validateMultiSkillZip(file: File): Promise { +export async function validateMultiSkillZip(file: File, options: SkillZipValidationOptions = {}): Promise { if (!file.name.toLowerCase().endsWith(".zip")) { return { skills: [], error: "upload.onlyZip" }; } - if (file.size > MAX_SKILL_SIZE) { + if (file.size > uploadSizeLimitBytes(options.maxUploadSizeMB)) { return { skills: [], error: "upload.tooLarge" }; } @@ -106,6 +112,17 @@ export async function validateMultiSkillZip(file: File): Promise MAX_SKILL_UPLOAD_SIZE_MB) return MAX_SKILL_UPLOAD_SIZE_MB; + return Math.trunc(value); +} + +function uploadSizeLimitBytes(value?: number): number { + return normalizeSkillUploadSizeMB(value) * 1024 * 1024; +} + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- diff --git a/ui/web/src/pages/skills/skill-upload-dialog.tsx b/ui/web/src/pages/skills/skill-upload-dialog.tsx index c971e533..32ef6b74 100644 --- a/ui/web/src/pages/skills/skill-upload-dialog.tsx +++ b/ui/web/src/pages/skills/skill-upload-dialog.tsx @@ -21,6 +21,7 @@ import { uniqueId } from "@/lib/utils"; import type { SkillUploadOptions, SkillUploadResponse } from "./hooks/use-skills"; import type { FileEntry, SkillStatus } from "./lib/skill-upload-types"; import { FileEntryBlock } from "./skill-upload-entry"; +import { useSkillUploadLimit } from "./hooks/use-skill-upload-limit"; import JSZip from "jszip"; interface SkillUploadDialogProps { @@ -38,6 +39,7 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD const [grantManagers, setGrantManagers] = useState(true); const [managerAgentIds, setManagerAgentIds] = useState([]); const { agents, refresh: refreshAgents } = useAgents(); + const maxUploadSizeMB = useSkillUploadLimit(open); const inputRef = useRef(null); useEffect(() => { @@ -66,7 +68,7 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD // Validate all files concurrently const results = await Promise.all( pending.map(async (entry) => { - const resolved = await resolveUploadSkills(entry.file); + const resolved = await resolveUploadSkills(entry.file, undefined, { maxUploadSizeMB }); return { id: entry.id, skills: resolved.map((skill) => ({ @@ -263,7 +265,7 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD {t("upload.title")} - {t("upload.description")} + {t("upload.description", { max: maxUploadSizeMB })} {/* Drop zone — hidden once upload starts or finishes */}