Merge upstream/main into feat/subturn-poc

Includes JSONL session persistence (#1170), spawn_status tool, Azure provider,
credential encryption, and various fixes. SubTurn features preserved and
integrated with new spawn_status functionality.
This commit is contained in:
Administrator
2026-03-17 21:55:20 +08:00
110 changed files with 7413 additions and 1547 deletions
+79 -6
View File
@@ -4,11 +4,13 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync/atomic"
"github.com/caarlos0/env/v11"
"github.com/sipeed/picoclaw/pkg/credential"
"github.com/sipeed/picoclaw/pkg/fileutil"
)
@@ -624,8 +626,9 @@ func (c *ModelConfig) Validate() error {
}
type GatewayConfig struct {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
}
type ToolDiscoveryConfig struct {
@@ -698,8 +701,9 @@ type WebToolsConfig struct {
}
type CronToolsConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
AllowCommand bool ` env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND" json:"allow_command"`
}
type ExecConfig struct {
@@ -749,6 +753,7 @@ type ToolsConfig struct {
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"`
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
@@ -838,10 +843,24 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
if passphrase := credential.PassphraseProvider(); passphrase != "" {
for _, m := range cfg.ModelList {
if m.APIKey != "" && !strings.HasPrefix(m.APIKey, "enc://") && !strings.HasPrefix(m.APIKey, "file://") {
fmt.Fprintf(os.Stderr,
"picoclaw: warning: model %q has a plaintext api_key; call SaveConfig to encrypt it\n",
m.ModelName)
}
}
}
if err := env.Parse(cfg); err != nil {
return nil, err
}
if err := resolveAPIKeys(cfg.ModelList, filepath.Dir(path)); err != nil {
return nil, err
}
// Migrate legacy channel config fields to new unified structures
cfg.migrateChannelConfigs()
@@ -858,6 +877,48 @@ func LoadConfig(path string) (*Config, error) {
return cfg, nil
}
// encryptPlaintextAPIKeys returns a copy of models with plaintext api_key values
// encrypted. Returns (nil, nil) when nothing changed (all keys already sealed or
// empty). Returns (nil, error) if any key fails to encrypt — callers must treat
// this as a hard failure to prevent a mixed plaintext/ciphertext state on disk.
// Symmetric counterpart of resolveAPIKeys: both operate purely on []ModelConfig
// and leave JSON marshaling to the caller.
func encryptPlaintextAPIKeys(models []ModelConfig, passphrase string) ([]ModelConfig, error) {
sealed := make([]ModelConfig, len(models))
copy(sealed, models)
changed := false
for i := range sealed {
m := &sealed[i]
if m.APIKey == "" || strings.HasPrefix(m.APIKey, "enc://") || strings.HasPrefix(m.APIKey, "file://") {
continue
}
encrypted, err := credential.Encrypt(passphrase, "", m.APIKey)
if err != nil {
return nil, fmt.Errorf("cannot seal api_key for model %q: %w", m.ModelName, err)
}
m.APIKey = encrypted
changed = true
}
if !changed {
return nil, nil
}
return sealed, nil
}
// resolveAPIKeys decrypts or dereferences each api_key in models in-place.
// Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt).
func resolveAPIKeys(models []ModelConfig, configDir string) error {
cr := credential.NewResolver(configDir)
for i := range models {
resolved, err := cr.Resolve(models[i].APIKey)
if err != nil {
return fmt.Errorf("model_list[%d] (%s): %w", i, models[i].ModelName, err)
}
models[i].APIKey = resolved
}
return nil
}
func (c *Config) migrateChannelConfigs() {
// Discord: mention_only -> group_trigger.mention_only
if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
@@ -872,12 +933,22 @@ func (c *Config) migrateChannelConfigs() {
}
func SaveConfig(path string, cfg *Config) error {
if passphrase := credential.PassphraseProvider(); passphrase != "" {
sealed, err := encryptPlaintextAPIKeys(cfg.ModelList, passphrase)
if err != nil {
return err
}
if sealed != nil {
tmp := *cfg
tmp.ModelList = sealed
cfg = &tmp
}
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
// Use unified atomic write utility with explicit sync for flash storage reliability.
return fileutil.WriteFileAtomic(path, data, 0o600)
}
@@ -1044,6 +1115,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
return t.ReadFile.Enabled
case "spawn":
return t.Spawn.Enabled
case "spawn_status":
return t.SpawnStatus.Enabled
case "spi":
return t.SPI.Enabled
case "subagent":