Merge branch 'sipeed:main' into main
This commit is contained in:
commit
e0bf0de271
6 changed files with 88 additions and 23 deletions
|
|
@ -86,14 +86,14 @@ func (s *JSONLStore) metaPath(key string) string {
|
||||||
|
|
||||||
// sanitizeKey converts a session key to a safe filename component.
|
// sanitizeKey converts a session key to a safe filename component.
|
||||||
// Mirrors pkg/session.sanitizeFilename so that migration paths match.
|
// Mirrors pkg/session.sanitizeFilename so that migration paths match.
|
||||||
//
|
// Replaces ':' with '_' (session key separator) and '/' and '\' with '_'
|
||||||
// Note: this is a lossy mapping — "telegram:123" and "telegram_123"
|
// so composite IDs (e.g. Telegram forum "chatID/threadID", Slack "channel/thread_ts")
|
||||||
// both produce the same filename. This is an intentional tradeoff:
|
// do not create subdirectories or break on Windows.
|
||||||
// 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.
|
|
||||||
func sanitizeKey(key string) string {
|
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.
|
// readMeta loads the metadata file for a session.
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
|
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
|
||||||
lowerModel := strings.ToLower(model)
|
lowerModel := strings.ToLower(model)
|
||||||
|
|
||||||
|
if providerName == "" && model == "" {
|
||||||
|
return providerSelection{}, fmt.Errorf("no model configured: agents.defaults.model is empty")
|
||||||
|
}
|
||||||
|
|
||||||
sel := providerSelection{
|
sel := providerSelection{
|
||||||
providerType: providerTypeHTTPCompat,
|
providerType: providerTypeHTTPCompat,
|
||||||
model: model,
|
model: model,
|
||||||
|
|
|
||||||
|
|
@ -146,12 +146,15 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// sanitizeFilename converts a session key into a cross-platform safe filename.
|
// sanitizeFilename converts a session key into a cross-platform safe filename.
|
||||||
// Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the
|
// Replaces ':' with '_' (session key separator) and '/' and '\' with '_' so
|
||||||
// volume separator on Windows, so filepath.Base would misinterpret the key.
|
// composite IDs (e.g. Telegram forum "chatID/threadID") do not create
|
||||||
// We replace it with '_'. The original key is preserved inside the JSON file,
|
// subdirectories or break on Windows. The original key is preserved inside
|
||||||
// so loadSessions still maps back to the right in-memory key.
|
// the JSON file, so loadSessions still maps back to the right in-memory key.
|
||||||
func sanitizeFilename(key string) string {
|
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 {
|
func (sm *SessionManager) Save(key string) error {
|
||||||
|
|
@ -162,10 +165,9 @@ func (sm *SessionManager) Save(key string) error {
|
||||||
filename := sanitizeFilename(key)
|
filename := sanitizeFilename(key)
|
||||||
|
|
||||||
// filepath.IsLocal rejects empty names, "..", absolute paths, and
|
// filepath.IsLocal rejects empty names, "..", absolute paths, and
|
||||||
// OS-reserved device names (NUL, COM1 … on Windows).
|
// OS-reserved device names (NUL, COM1 … on Windows). sanitizeFilename
|
||||||
// The extra checks reject "." and any directory separators so that
|
// already replaced '/' and '\' with '_', so no subdirs are created.
|
||||||
// the session file is always written directly inside sm.storage.
|
if filename == "." || !filepath.IsLocal(filename) {
|
||||||
if filename == "." || !filepath.IsLocal(filename) || strings.ContainsAny(filename, `/\`) {
|
|
||||||
return os.ErrInvalid
|
return os.ErrInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ func TestSanitizeFilename(t *testing.T) {
|
||||||
{"slack:C01234", "slack_C01234"},
|
{"slack:C01234", "slack_C01234"},
|
||||||
{"no-colons-here", "no-colons-here"},
|
{"no-colons-here", "no-colons-here"},
|
||||||
{"multiple:colons:here", "multiple_colons_here"},
|
{"multiple:colons:here", "multiple_colons_here"},
|
||||||
|
{"agent:main:telegram:group:-1003822706455/12", "agent_main_telegram_group_-1003822706455_12"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|
@ -64,11 +65,21 @@ func TestSave_RejectsPathTraversal(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
sm := NewSessionManager(tmpDir)
|
sm := NewSessionManager(tmpDir)
|
||||||
|
|
||||||
badKeys := []string{"", ".", "..", "foo/bar", "foo\\bar"}
|
// Invalid names that must still be rejected.
|
||||||
|
badKeys := []string{"", ".", ".."}
|
||||||
for _, key := range badKeys {
|
for _, key := range badKeys {
|
||||||
sm.GetOrCreate(key)
|
sm.GetOrCreate(key)
|
||||||
if err := sm.Save(key); err == nil {
|
if err := sm.Save(key); err == nil {
|
||||||
t.Errorf("Save(%q) should have failed but didn't", key)
|
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)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,12 @@ func (h *Handler) startGatewayLocked() (int, error) {
|
||||||
execPath := findPicoclawBinary()
|
execPath := findPicoclawBinary()
|
||||||
|
|
||||||
cmd := exec.Command(execPath, "gateway")
|
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()
|
stdoutPipe, err := cmd.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -530,18 +536,32 @@ func (h *Handler) currentGatewayStatus() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// findPicoclawBinary locates the picoclaw executable.
|
// 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 {
|
func findPicoclawBinary() string {
|
||||||
if exe, err := os.Executable(); err == nil {
|
binaryName := "picoclaw"
|
||||||
dir := filepath.Dir(exe)
|
if runtime.GOOS == "windows" {
|
||||||
candidate := filepath.Join(dir, "picoclaw")
|
binaryName = "picoclaw.exe"
|
||||||
if runtime.GOOS == "windows" {
|
}
|
||||||
candidate += ".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() {
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Fall back to PATH lookup
|
||||||
return "picoclaw"
|
return "picoclaw"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -120,3 +121,30 @@ func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) {
|
||||||
t.Fatalf("gateway_start_reason missing or not string: %#v", body["gateway_start_reason"])
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue