Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 19 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion internal/config/config_load.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ func Default() *Config {
},
RateLimitPerHour: 150,
},
Skills: SkillsConfig{
MaxUploadSizeMB: DefaultSkillMaxUploadSizeMB,
},
Sessions: SessionsConfig{},
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
15 changes: 9 additions & 6 deletions internal/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
55 changes: 55 additions & 0 deletions internal/config/skills_config_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
22 changes: 18 additions & 4 deletions internal/http/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ import (
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)

const maxSkillUploadSize = 20 << 20 // 20 MB

var (
aggregateInstallDeps = skills.AggregateMissingDeps
installManagedDeps = skills.InstallDeps
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/http/skills_upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions internal/http/skills_upload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http/httptest"
"path/filepath"
"reflect"
"strings"
"testing"

"github.com/google/uuid"
Expand Down Expand Up @@ -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()

Expand Down