From 2e3e6788abf2e1af554be8538d46a9717c9ecd91 Mon Sep 17 00:00:00 2001 From: statxc <181730535+statxc@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:11:34 +0000 Subject: [PATCH 1/2] fix(session): sanitize '/' and '\' in session keys so forum topic keys don't create invalid paths --- pkg/memory/jsonl.go | 14 +++++++------- pkg/session/manager.go | 20 +++++++++++--------- pkg/session/manager_test.go | 13 ++++++++++++- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index e12e2c5ab..afe374166 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -86,14 +86,14 @@ func (s *JSONLStore) metaPath(key string) string { // sanitizeKey converts a session key to a safe filename component. // Mirrors pkg/session.sanitizeFilename so that migration paths match. -// -// Note: this is a lossy mapping — "telegram:123" and "telegram_123" -// both produce the same filename. This is an intentional tradeoff: -// keys with colons (e.g. from channels) are by far the common case, -// and a bidirectional encoding (like URL-encoding) would complicate -// file listings and debugging. +// Replaces ':' with '_' (session key separator) and '/' and '\' with '_' +// so composite IDs (e.g. Telegram forum "chatID/threadID", Slack "channel/thread_ts") +// do not create subdirectories or break on Windows. func sanitizeKey(key string) string { - return strings.ReplaceAll(key, ":", "_") + s := strings.ReplaceAll(key, ":", "_") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\\", "_") + return s } // readMeta loads the metadata file for a session. diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 07f981df1..a31dbd55c 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -146,12 +146,15 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) { } // sanitizeFilename converts a session key into a cross-platform safe filename. -// Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the -// volume separator on Windows, so filepath.Base would misinterpret the key. -// We replace it with '_'. The original key is preserved inside the JSON file, -// so loadSessions still maps back to the right in-memory key. +// Replaces ':' with '_' (session key separator) and '/' and '\' with '_' so +// composite IDs (e.g. Telegram forum "chatID/threadID") do not create +// subdirectories or break on Windows. The original key is preserved inside +// the JSON file, so loadSessions still maps back to the right in-memory key. func sanitizeFilename(key string) string { - return strings.ReplaceAll(key, ":", "_") + s := strings.ReplaceAll(key, ":", "_") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\\", "_") + return s } func (sm *SessionManager) Save(key string) error { @@ -162,10 +165,9 @@ func (sm *SessionManager) Save(key string) error { filename := sanitizeFilename(key) // filepath.IsLocal rejects empty names, "..", absolute paths, and - // OS-reserved device names (NUL, COM1 … on Windows). - // The extra checks reject "." and any directory separators so that - // the session file is always written directly inside sm.storage. - if filename == "." || !filepath.IsLocal(filename) || strings.ContainsAny(filename, `/\`) { + // OS-reserved device names (NUL, COM1 … on Windows). sanitizeFilename + // already replaced '/' and '\' with '_', so no subdirs are created. + if filename == "." || !filepath.IsLocal(filename) { return os.ErrInvalid } diff --git a/pkg/session/manager_test.go b/pkg/session/manager_test.go index 5ef5f4349..bc5615966 100644 --- a/pkg/session/manager_test.go +++ b/pkg/session/manager_test.go @@ -17,6 +17,7 @@ func TestSanitizeFilename(t *testing.T) { {"slack:C01234", "slack_C01234"}, {"no-colons-here", "no-colons-here"}, {"multiple:colons:here", "multiple_colons_here"}, + {"agent:main:telegram:group:-1003822706455/12", "agent_main_telegram_group_-1003822706455_12"}, } for _, tt := range tests { @@ -64,11 +65,21 @@ func TestSave_RejectsPathTraversal(t *testing.T) { tmpDir := t.TempDir() sm := NewSessionManager(tmpDir) - badKeys := []string{"", ".", "..", "foo/bar", "foo\\bar"} + // Invalid names that must still be rejected. + badKeys := []string{"", ".", ".."} for _, key := range badKeys { sm.GetOrCreate(key) if err := sm.Save(key); err == nil { t.Errorf("Save(%q) should have failed but didn't", key) } } + + // Keys containing path separators are sanitized (no subdirs created). + sm.GetOrCreate("foo/bar") + if err := sm.Save("foo/bar"); err != nil { + t.Fatalf("Save(\"foo/bar\") after sanitize should succeed: %v", err) + } + if _, err := os.Stat(filepath.Join(tmpDir, "foo_bar.json")); os.IsNotExist(err) { + t.Errorf("expected foo_bar.json in storage (sanitized from foo/bar)") + } } From d5cbf198b2905df6db737045af170d36e2533db3 Mon Sep 17 00:00:00 2001 From: Cage <48486193+snac21@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:54:08 +0800 Subject: [PATCH 2/2] fix: resolve gateway binary path, pass --config flag, and clarify empty model error (#1337) --- pkg/providers/factory.go | 4 ++++ web/backend/api/gateway.go | 32 ++++++++++++++++++++++++++------ web/backend/api/gateway_test.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go index d952c8cb0..ee9c11899 100644 --- a/pkg/providers/factory.go +++ b/pkg/providers/factory.go @@ -40,6 +40,10 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { providerName := strings.ToLower(cfg.Agents.Defaults.Provider) lowerModel := strings.ToLower(model) + if providerName == "" && model == "" { + return providerSelection{}, fmt.Errorf("no model configured: agents.defaults.model is empty") + } + sel := providerSelection{ providerType: providerTypeHTTPCompat, model: model, diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 1aea1c801..8f86dd73d 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -134,6 +134,12 @@ func (h *Handler) startGatewayLocked() (int, error) { execPath := findPicoclawBinary() cmd := exec.Command(execPath, "gateway") + // Forward the launcher's config path via the environment variable that + // GetConfigPath() already reads, so the gateway sub-process uses the same + // config file without requiring a --config flag on the gateway subcommand. + if h.configPath != "" { + cmd.Env = append(os.Environ(), "PICOCLAW_CONFIG="+h.configPath) + } stdoutPipe, err := cmd.StdoutPipe() if err != nil { @@ -530,18 +536,32 @@ func (h *Handler) currentGatewayStatus() string { } // findPicoclawBinary locates the picoclaw executable. -// Tries the same directory as the current executable first, then falls back to $PATH. +// Search order: +// 1. PICOCLAW_BINARY environment variable (explicit override) +// 2. Same directory as the current executable +// 3. Falls back to "picoclaw" and relies on $PATH func findPicoclawBinary() string { - if exe, err := os.Executable(); err == nil { - dir := filepath.Dir(exe) - candidate := filepath.Join(dir, "picoclaw") - if runtime.GOOS == "windows" { - candidate += ".exe" + binaryName := "picoclaw" + if runtime.GOOS == "windows" { + binaryName = "picoclaw.exe" + } + + // 1. Explicit override via environment variable + if p := os.Getenv("PICOCLAW_BINARY"); p != "" { + if info, _ := os.Stat(p); info != nil && !info.IsDir() { + return p } + } + + // 2. Same directory as the launcher executable + if exe, err := os.Executable(); err == nil { + candidate := filepath.Join(filepath.Dir(exe), binaryName) if info, err := os.Stat(candidate); err == nil && !info.IsDir() { return candidate } } + + // 3. Fall back to PATH lookup return "picoclaw" } diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 336bb6a0c..998c133b5 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -120,3 +121,30 @@ func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) { t.Fatalf("gateway_start_reason missing or not string: %#v", body["gateway_start_reason"]) } } + +func TestFindPicoclawBinary_EnvOverride(t *testing.T) { + // Create a temporary file to act as the mock binary + tmpDir := t.TempDir() + mockBinary := filepath.Join(tmpDir, "picoclaw-mock") + if err := os.WriteFile(mockBinary, []byte("mock"), 0o755); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + t.Setenv("PICOCLAW_BINARY", mockBinary) + + got := findPicoclawBinary() + if got != mockBinary { + t.Errorf("findPicoclawBinary() = %q, want %q", got, mockBinary) + } +} + +func TestFindPicoclawBinary_EnvOverride_InvalidPath(t *testing.T) { + // When PICOCLAW_BINARY points to a non-existent path, fall through to next strategy + t.Setenv("PICOCLAW_BINARY", "/nonexistent/picoclaw-binary") + + got := findPicoclawBinary() + // Should not return the invalid path; falls back to "picoclaw" or another found path + if got == "/nonexistent/picoclaw-binary" { + t.Errorf("findPicoclawBinary() returned invalid env path %q, expected fallback", got) + } +}