feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"encoding/json"
|
2026-02-16 08:06:39 +00:00
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"runtime"
|
2026-02-27 16:18:10 +00:00
|
|
|
|
"strings"
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
|
"testing"
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
|
2026-03-21 17:55:00 +00:00
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
|
|
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
"github.com/sipeed/picoclaw/pkg/credential"
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
// mustSetupSSHKey generates a temporary Ed25519 SSH key in t.TempDir() and sets
|
|
|
|
|
|
// PICOCLAW_SSH_KEY_PATH to its path for the duration of the test. This is required
|
|
|
|
|
|
// whenever a test exercises encryption/decryption via credential.Encrypt or SaveConfig.
|
|
|
|
|
|
func mustSetupSSHKey(t *testing.T) {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
keyPath := filepath.Join(t.TempDir(), "picoclaw_ed25519.key")
|
|
|
|
|
|
if err := credential.GenerateSSHKey(keyPath); err != nil {
|
|
|
|
|
|
t.Fatalf("mustSetupSSHKey: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", keyPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
|
func TestAgentModelConfig_UnmarshalString(t *testing.T) {
|
|
|
|
|
|
var m AgentModelConfig
|
|
|
|
|
|
if err := json.Unmarshal([]byte(`"gpt-4"`), &m); err != nil {
|
|
|
|
|
|
t.Fatalf("unmarshal string: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if m.Primary != "gpt-4" {
|
|
|
|
|
|
t.Errorf("Primary = %q, want 'gpt-4'", m.Primary)
|
|
|
|
|
|
}
|
|
|
|
|
|
if m.Fallbacks != nil {
|
|
|
|
|
|
t.Errorf("Fallbacks = %v, want nil", m.Fallbacks)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAgentModelConfig_UnmarshalObject(t *testing.T) {
|
|
|
|
|
|
var m AgentModelConfig
|
|
|
|
|
|
data := `{"primary": "claude-opus", "fallbacks": ["gpt-4o-mini", "haiku"]}`
|
|
|
|
|
|
if err := json.Unmarshal([]byte(data), &m); err != nil {
|
|
|
|
|
|
t.Fatalf("unmarshal object: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if m.Primary != "claude-opus" {
|
|
|
|
|
|
t.Errorf("Primary = %q, want 'claude-opus'", m.Primary)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(m.Fallbacks) != 2 {
|
|
|
|
|
|
t.Fatalf("Fallbacks len = %d, want 2", len(m.Fallbacks))
|
|
|
|
|
|
}
|
|
|
|
|
|
if m.Fallbacks[0] != "gpt-4o-mini" || m.Fallbacks[1] != "haiku" {
|
|
|
|
|
|
t.Errorf("Fallbacks = %v", m.Fallbacks)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAgentModelConfig_MarshalString(t *testing.T) {
|
|
|
|
|
|
m := AgentModelConfig{Primary: "gpt-4"}
|
|
|
|
|
|
data, err := json.Marshal(m)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("marshal: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if string(data) != `"gpt-4"` {
|
|
|
|
|
|
t.Errorf("marshal = %s, want '\"gpt-4\"'", string(data))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAgentModelConfig_MarshalObject(t *testing.T) {
|
|
|
|
|
|
m := AgentModelConfig{Primary: "claude-opus", Fallbacks: []string{"haiku"}}
|
|
|
|
|
|
data, err := json.Marshal(m)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("marshal: %v", err)
|
|
|
|
|
|
}
|
2026-02-18 19:48:23 +00:00
|
|
|
|
var result map[string]any
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
|
json.Unmarshal(data, &result)
|
|
|
|
|
|
if result["primary"] != "claude-opus" {
|
|
|
|
|
|
t.Errorf("primary = %v", result["primary"])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAgentConfig_FullParse(t *testing.T) {
|
|
|
|
|
|
jsonData := `{
|
|
|
|
|
|
"agents": {
|
|
|
|
|
|
"defaults": {
|
|
|
|
|
|
"workspace": "~/.picoclaw/workspace",
|
|
|
|
|
|
"model": "glm-4.7",
|
|
|
|
|
|
"max_tokens": 8192,
|
|
|
|
|
|
"max_tool_iterations": 20
|
|
|
|
|
|
},
|
|
|
|
|
|
"list": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": "sales",
|
|
|
|
|
|
"default": true,
|
|
|
|
|
|
"name": "Sales Bot",
|
|
|
|
|
|
"model": "gpt-4"
|
|
|
|
|
|
},
|
2026-03-29 11:58:19 +00:00
|
|
|
|
{
|
|
|
|
|
|
"id": "support",
|
|
|
|
|
|
"name": "Support Bot",
|
|
|
|
|
|
"model": {
|
|
|
|
|
|
"primary": "claude-opus",
|
|
|
|
|
|
"fallbacks": ["haiku"]
|
|
|
|
|
|
},
|
|
|
|
|
|
"subagents": {
|
|
|
|
|
|
"allow_agents": ["sales"]
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
|
}
|
2026-03-29 11:58:19 +00:00
|
|
|
|
}
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
"session": {
|
2026-04-01 09:19:50 +00:00
|
|
|
|
"dimensions": ["sender"],
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
|
"identity_links": {
|
|
|
|
|
|
"john": ["telegram:123", "discord:john#1234"]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(cfg.Agents.List) != 2 {
|
|
|
|
|
|
t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sales := cfg.Agents.List[0]
|
|
|
|
|
|
if sales.ID != "sales" || !sales.Default || sales.Name != "Sales Bot" {
|
|
|
|
|
|
t.Errorf("sales = %+v", sales)
|
|
|
|
|
|
}
|
|
|
|
|
|
if sales.Model == nil || sales.Model.Primary != "gpt-4" {
|
|
|
|
|
|
t.Errorf("sales.Model = %+v", sales.Model)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
support := cfg.Agents.List[1]
|
|
|
|
|
|
if support.ID != "support" || support.Name != "Support Bot" {
|
|
|
|
|
|
t.Errorf("support = %+v", support)
|
|
|
|
|
|
}
|
|
|
|
|
|
if support.Model == nil || support.Model.Primary != "claude-opus" {
|
|
|
|
|
|
t.Errorf("support.Model = %+v", support.Model)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(support.Model.Fallbacks) != 1 || support.Model.Fallbacks[0] != "haiku" {
|
|
|
|
|
|
t.Errorf("support.Model.Fallbacks = %v", support.Model.Fallbacks)
|
|
|
|
|
|
}
|
|
|
|
|
|
if support.Subagents == nil || len(support.Subagents.AllowAgents) != 1 {
|
|
|
|
|
|
t.Errorf("support.Subagents = %+v", support.Subagents)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 14:13:04 +00:00
|
|
|
|
if len(cfg.Session.Dimensions) != 1 || cfg.Session.Dimensions[0] != "sender" {
|
|
|
|
|
|
t.Errorf("Session.Dimensions = %v", cfg.Session.Dimensions)
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
|
}
|
|
|
|
|
|
if len(cfg.Session.IdentityLinks) != 1 {
|
|
|
|
|
|
t.Errorf("Session.IdentityLinks = %v", cfg.Session.IdentityLinks)
|
|
|
|
|
|
}
|
|
|
|
|
|
links := cfg.Session.IdentityLinks["john"]
|
|
|
|
|
|
if len(links) != 2 {
|
|
|
|
|
|
t.Errorf("john links = %v", links)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 02:06:40 +00:00
|
|
|
|
func TestTurnProfileConfig_ParseAndResolve(t *testing.T) {
|
|
|
|
|
|
jsonData := `{
|
|
|
|
|
|
"agents": {
|
|
|
|
|
|
"defaults": {
|
|
|
|
|
|
"turn_profile": {
|
|
|
|
|
|
"enabled": true,
|
|
|
|
|
|
"history": {"mode": "off"},
|
|
|
|
|
|
"system_prompt": {"mode": "off"},
|
|
|
|
|
|
"skills": {"mode": "off"},
|
|
|
|
|
|
"tools": {
|
|
|
|
|
|
"mode": "custom",
|
|
|
|
|
|
"allow": ["web_search", "web_fetch"]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := cfg.ValidateTurnProfile(); err != nil {
|
|
|
|
|
|
t.Fatalf("ValidateTurnProfile() error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
profile, ok, err := cfg.Agents.Defaults.ResolveTurnProfile()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ResolveTurnProfile() error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatal("ResolveTurnProfile() ok = false, want true")
|
|
|
|
|
|
}
|
|
|
|
|
|
if profile.HistoryMode != TurnProfileModeOff ||
|
|
|
|
|
|
profile.SystemPromptMode != TurnProfileModeOff ||
|
|
|
|
|
|
profile.SkillsMode != TurnProfileModeOff ||
|
|
|
|
|
|
profile.ToolsMode != TurnProfileModeCustom {
|
|
|
|
|
|
t.Fatalf("resolved clean_web modes = %+v", profile)
|
|
|
|
|
|
}
|
|
|
|
|
|
assert.Equal(t, []string{"web_search", "web_fetch"}, profile.AllowedTools)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestTurnProfileConfig_DisabledOrMissingIsNoop(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
|
|
|
|
|
profile, ok, err := cfg.Agents.Defaults.ResolveTurnProfile()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ResolveTurnProfile(missing) error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if ok {
|
|
|
|
|
|
t.Fatal("ResolveTurnProfile(missing) ok = true, want false")
|
|
|
|
|
|
}
|
|
|
|
|
|
if profile.Enabled {
|
|
|
|
|
|
t.Fatalf("ResolveTurnProfile(missing) profile.Enabled = true, want false")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg.Agents.Defaults.TurnProfile = TurnProfileConfig{
|
|
|
|
|
|
Enabled: false,
|
|
|
|
|
|
History: TurnProfileBlock{
|
|
|
|
|
|
Mode: TurnProfileModeOff,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
profile, ok, err = cfg.Agents.Defaults.ResolveTurnProfile()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ResolveTurnProfile(disabled) error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if ok || profile.Enabled {
|
|
|
|
|
|
t.Fatalf("disabled profile = (%+v, %v), want no-op", profile, ok)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg.Agents.Defaults.TurnProfile = TurnProfileConfig{
|
|
|
|
|
|
Enabled: false,
|
|
|
|
|
|
History: TurnProfileBlock{
|
|
|
|
|
|
Mode: TurnProfileModeCustom,
|
|
|
|
|
|
},
|
|
|
|
|
|
Tools: TurnProfileBlock{
|
|
|
|
|
|
Mode: TurnProfileMode("sometimes"),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := cfg.ValidateTurnProfile(); err != nil {
|
|
|
|
|
|
t.Fatalf("ValidateTurnProfile(disabled unsupported modes) error = %v, want nil", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestTurnProfileConfig_ValidationRejectsUnsupportedModes(t *testing.T) {
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
raw string
|
|
|
|
|
|
want string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "history custom unsupported",
|
|
|
|
|
|
raw: `{"agents":{"defaults":{"turn_profile":{"enabled":true,"history":{"mode":"custom"}}}}}`,
|
|
|
|
|
|
want: "history.mode",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "system prompt custom unsupported",
|
|
|
|
|
|
raw: `{"agents":{"defaults":{"turn_profile":{"enabled":true,"system_prompt":{"mode":"custom"}}}}}`,
|
|
|
|
|
|
want: "system_prompt.mode",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "unknown mode",
|
|
|
|
|
|
raw: `{"agents":{"defaults":{"turn_profile":{"enabled":true,"tools":{"mode":"sometimes"}}}}}`,
|
|
|
|
|
|
want: "unsupported mode",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if err := json.Unmarshal([]byte(tt.raw), cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
err := cfg.ValidateTurnProfile()
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
t.Fatal("ValidateTurnProfile() error = nil, want error")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(err.Error(), tt.want) {
|
|
|
|
|
|
t.Fatalf("ValidateTurnProfile() error = %v, want containing %q", err, tt.want)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-03 17:30:36 +00:00
|
|
|
|
func TestDefaultConfig_MCPMaxInlineTextChars(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if cfg.Tools.MCP.GetMaxInlineTextChars() != DefaultMCPMaxInlineTextChars {
|
|
|
|
|
|
t.Fatalf(
|
|
|
|
|
|
"DefaultConfig().Tools.MCP.GetMaxInlineTextChars() = %d, want %d",
|
|
|
|
|
|
cfg.Tools.MCP.GetMaxInlineTextChars(),
|
|
|
|
|
|
DefaultMCPMaxInlineTextChars,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 08:13:27 +00:00
|
|
|
|
func TestDefaultConfig_EvolutionDefaults(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
|
|
|
|
|
assert.False(t, cfg.Evolution.Enabled)
|
|
|
|
|
|
assert.Equal(t, "observe", cfg.Evolution.Mode)
|
|
|
|
|
|
assert.Equal(t, "", cfg.Evolution.StateDir)
|
|
|
|
|
|
assert.Equal(t, 2, cfg.Evolution.MinTaskCount)
|
|
|
|
|
|
assert.Equal(t, 0.7, cfg.Evolution.MinSuccessRatio)
|
|
|
|
|
|
assert.Equal(t, "after_turn", cfg.Evolution.ColdPathTrigger)
|
|
|
|
|
|
assert.Equal(t, 2, cfg.Evolution.EffectiveMinTaskCount())
|
|
|
|
|
|
assert.Equal(t, 0.7, cfg.Evolution.EffectiveMinSuccessRatio())
|
|
|
|
|
|
assert.False(t, cfg.Evolution.RunsColdPathAutomatically())
|
|
|
|
|
|
assert.False(t, cfg.Evolution.AutoAppliesDrafts())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestEvolutionConfig_EffectiveMode(t *testing.T) {
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
cfg EvolutionConfig
|
|
|
|
|
|
want string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "disabled returns empty",
|
|
|
|
|
|
cfg: EvolutionConfig{
|
|
|
|
|
|
Enabled: false,
|
|
|
|
|
|
Mode: "apply",
|
|
|
|
|
|
},
|
|
|
|
|
|
want: "",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "enabled empty mode defaults to observe",
|
|
|
|
|
|
cfg: EvolutionConfig{
|
|
|
|
|
|
Enabled: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
want: "observe",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "enabled whitespace mode defaults to observe",
|
|
|
|
|
|
cfg: EvolutionConfig{
|
|
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Mode: " \t\n ",
|
|
|
|
|
|
},
|
|
|
|
|
|
want: "observe",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "enabled returns configured mode",
|
|
|
|
|
|
cfg: EvolutionConfig{
|
|
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Mode: "draft",
|
|
|
|
|
|
},
|
|
|
|
|
|
want: "draft",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "enabled trims and normalizes mode",
|
|
|
|
|
|
cfg: EvolutionConfig{
|
|
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Mode: " Draft ",
|
|
|
|
|
|
},
|
|
|
|
|
|
want: "draft",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "enabled returns apply mode",
|
|
|
|
|
|
cfg: EvolutionConfig{
|
|
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Mode: "apply",
|
|
|
|
|
|
},
|
|
|
|
|
|
want: "apply",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "enabled normalizes uppercase apply",
|
|
|
|
|
|
cfg: EvolutionConfig{
|
|
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Mode: "APPLY",
|
|
|
|
|
|
},
|
|
|
|
|
|
want: "apply",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "enabled unknown mode falls back to observe",
|
|
|
|
|
|
cfg: EvolutionConfig{
|
|
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Mode: "propose",
|
|
|
|
|
|
},
|
|
|
|
|
|
want: "observe",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
|
assert.Equal(t, tt.want, tt.cfg.EffectiveMode())
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestEvolutionConfig_ModeSemantics(t *testing.T) {
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
cfg EvolutionConfig
|
|
|
|
|
|
wantRunsCold bool
|
|
|
|
|
|
wantAutoApply bool
|
|
|
|
|
|
}{
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "disabled does not run cold path",
|
|
|
|
|
|
cfg: EvolutionConfig{
|
|
|
|
|
|
Enabled: false,
|
|
|
|
|
|
Mode: "apply",
|
|
|
|
|
|
},
|
|
|
|
|
|
wantRunsCold: false,
|
|
|
|
|
|
wantAutoApply: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "observe only records hot path",
|
|
|
|
|
|
cfg: EvolutionConfig{
|
|
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Mode: "observe",
|
|
|
|
|
|
},
|
|
|
|
|
|
wantRunsCold: false,
|
|
|
|
|
|
wantAutoApply: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "draft runs cold path without applying",
|
|
|
|
|
|
cfg: EvolutionConfig{
|
|
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Mode: "draft",
|
|
|
|
|
|
},
|
|
|
|
|
|
wantRunsCold: true,
|
|
|
|
|
|
wantAutoApply: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "draft scheduled runs cold path without after turn",
|
|
|
|
|
|
cfg: EvolutionConfig{
|
|
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Mode: "draft",
|
|
|
|
|
|
ColdPathTrigger: "scheduled",
|
|
|
|
|
|
ColdPathTimes: []string{"03:00"},
|
|
|
|
|
|
},
|
|
|
|
|
|
wantRunsCold: true,
|
|
|
|
|
|
wantAutoApply: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "apply runs cold path and auto applies",
|
|
|
|
|
|
cfg: EvolutionConfig{
|
|
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Mode: "apply",
|
|
|
|
|
|
},
|
|
|
|
|
|
wantRunsCold: true,
|
|
|
|
|
|
wantAutoApply: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
|
assert.Equal(t, tt.wantRunsCold, tt.cfg.RunsColdPathAutomatically())
|
|
|
|
|
|
assert.Equal(t, tt.wantAutoApply, tt.cfg.AutoAppliesDrafts())
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestEvolutionConfig_ColdPathTriggerMode(t *testing.T) {
|
|
|
|
|
|
assert.Equal(t, "after_turn", (EvolutionConfig{Enabled: true, Mode: "draft"}).ColdPathTriggerMode())
|
|
|
|
|
|
assert.True(t, (EvolutionConfig{Enabled: true, Mode: "draft"}).RunsColdPathAfterTurn())
|
|
|
|
|
|
assert.False(t, (EvolutionConfig{Enabled: true, Mode: "draft"}).RunsColdPathScheduled())
|
|
|
|
|
|
|
|
|
|
|
|
scheduled := EvolutionConfig{
|
|
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Mode: "apply",
|
|
|
|
|
|
ColdPathTrigger: "scheduled",
|
|
|
|
|
|
ColdPathTimes: []string{"03:00"},
|
|
|
|
|
|
}
|
|
|
|
|
|
assert.Equal(t, "scheduled", scheduled.ColdPathTriggerMode())
|
|
|
|
|
|
assert.False(t, scheduled.RunsColdPathAfterTurn())
|
|
|
|
|
|
assert.True(t, scheduled.RunsColdPathScheduled())
|
|
|
|
|
|
|
|
|
|
|
|
manual := EvolutionConfig{Enabled: true, Mode: "draft", ColdPathTrigger: "manual"}
|
|
|
|
|
|
assert.Equal(t, "manual", manual.ColdPathTriggerMode())
|
|
|
|
|
|
assert.False(t, manual.RunsColdPathAutomatically())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestEvolutionConfig_NewThresholdNamesPreferLegacyAliases(t *testing.T) {
|
|
|
|
|
|
cfg := EvolutionConfig{MinTaskCount: 4, MinSuccessRatio: 0.9, MinCaseCount: 1, MinSuccessRate: 0.2}
|
|
|
|
|
|
assert.Equal(t, 4, cfg.EffectiveMinTaskCount())
|
|
|
|
|
|
assert.Equal(t, 0.9, cfg.EffectiveMinSuccessRatio())
|
|
|
|
|
|
|
|
|
|
|
|
legacy := EvolutionConfig{MinCaseCount: 5, MinSuccessRate: 0.8}
|
|
|
|
|
|
assert.Equal(t, 5, legacy.EffectiveMinTaskCount())
|
|
|
|
|
|
assert.Equal(t, 0.8, legacy.EffectiveMinSuccessRatio())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestEvolutionConfig_MarshalUsesNewThresholdNames(t *testing.T) {
|
|
|
|
|
|
data, err := json.Marshal(EvolutionConfig{
|
|
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Mode: "draft",
|
|
|
|
|
|
MinCaseCount: 5,
|
|
|
|
|
|
MinSuccessRate: 0.8,
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("json.Marshal: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var raw map[string]any
|
|
|
|
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
|
|
|
|
t.Fatalf("json.Unmarshal: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if raw["min_task_count"] != float64(5) {
|
|
|
|
|
|
t.Fatalf("min_task_count = %#v, want 5", raw["min_task_count"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if raw["min_success_ratio"] != 0.8 {
|
|
|
|
|
|
t.Fatalf("min_success_ratio = %#v, want 0.8", raw["min_success_ratio"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := raw["min_case_count"]; ok {
|
|
|
|
|
|
t.Fatalf("min_case_count should not be marshaled: %#v", raw)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := raw["min_success_rate"]; ok {
|
|
|
|
|
|
t.Fatalf("min_success_rate should not be marshaled: %#v", raw)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestLoadConfig_EvolutionEnabledWithoutModeUsesObserveSemantics(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
raw := `{
|
|
|
|
|
|
"version": 3,
|
|
|
|
|
|
"evolution": {
|
|
|
|
|
|
"enabled": true
|
|
|
|
|
|
}
|
|
|
|
|
|
}`
|
|
|
|
|
|
if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteFile(configPath): %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
assert.True(t, cfg.Evolution.Enabled)
|
|
|
|
|
|
assert.Equal(t, "", cfg.Evolution.Mode)
|
|
|
|
|
|
assert.Equal(t, "observe", cfg.Evolution.EffectiveMode())
|
|
|
|
|
|
assert.False(t, cfg.Evolution.RunsColdPathAutomatically())
|
|
|
|
|
|
assert.False(t, cfg.Evolution.AutoAppliesDrafts())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestLoadConfig_EvolutionExplicitApplyModeAutoApplies(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
raw := `{
|
|
|
|
|
|
"version": 3,
|
|
|
|
|
|
"evolution": {
|
|
|
|
|
|
"enabled": true,
|
|
|
|
|
|
"mode": "apply"
|
|
|
|
|
|
}
|
|
|
|
|
|
}`
|
|
|
|
|
|
if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteFile(configPath): %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
assert.True(t, cfg.Evolution.Enabled)
|
|
|
|
|
|
assert.Equal(t, "apply", cfg.Evolution.Mode)
|
|
|
|
|
|
assert.Equal(t, "apply", cfg.Evolution.EffectiveMode())
|
|
|
|
|
|
assert.True(t, cfg.Evolution.RunsColdPathAutomatically())
|
|
|
|
|
|
assert.True(t, cfg.Evolution.AutoAppliesDrafts())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestSaveConfig_DisabledEvolutionOmitsApplyMode(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
|
|
|
|
|
if err := SaveConfig(configPath, cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("SaveConfig() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
data, err := os.ReadFile(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ReadFile(configPath): %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var raw map[string]any
|
|
|
|
|
|
if unmarshalErr := json.Unmarshal(data, &raw); unmarshalErr != nil {
|
|
|
|
|
|
t.Fatalf("Unmarshal saved config: %v", unmarshalErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
evolutionRaw, ok := raw["evolution"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("saved evolution config = %#v, want object", raw["evolution"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := evolutionRaw["mode"]; ok {
|
|
|
|
|
|
t.Fatalf("disabled evolution should not persist mode: %#v", evolutionRaw)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
evolutionRaw["enabled"] = true
|
|
|
|
|
|
edited, err := json.Marshal(raw)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Marshal edited config: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if writeErr := os.WriteFile(configPath, edited, 0o600); writeErr != nil {
|
|
|
|
|
|
t.Fatalf("WriteFile(configPath): %v", writeErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loaded, err := LoadConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
assert.True(t, loaded.Evolution.Enabled)
|
|
|
|
|
|
assert.Equal(t, "observe", loaded.Evolution.EffectiveMode())
|
|
|
|
|
|
assert.False(t, loaded.Evolution.AutoAppliesDrafts())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-03 17:30:36 +00:00
|
|
|
|
func TestLoadConfig_MCPMaxInlineTextChars(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
raw := `{
|
|
|
|
|
|
"tools": {
|
|
|
|
|
|
"mcp": {
|
|
|
|
|
|
"enabled": true,
|
|
|
|
|
|
"max_inline_text_chars": 2048
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}`
|
|
|
|
|
|
if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteFile(configPath): %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := cfg.Tools.MCP.GetMaxInlineTextChars(); got != 2048 {
|
|
|
|
|
|
t.Fatalf("cfg.Tools.MCP.GetMaxInlineTextChars() = %d, want 2048", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
|
func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) {
|
|
|
|
|
|
jsonData := `{
|
|
|
|
|
|
"agents": {
|
|
|
|
|
|
"defaults": {
|
|
|
|
|
|
"workspace": "~/.picoclaw/workspace",
|
|
|
|
|
|
"model": "glm-4.7",
|
|
|
|
|
|
"max_tokens": 8192,
|
|
|
|
|
|
"max_tool_iterations": 20
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(cfg.Agents.List) != 0 {
|
|
|
|
|
|
t.Errorf("agents.list should be empty for backward compat, got %d", len(cfg.Agents.List))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-13 15:24:26 +00:00
|
|
|
|
|
2026-04-01 14:13:04 +00:00
|
|
|
|
func TestAgentConfig_ParsesDispatchRules(t *testing.T) {
|
|
|
|
|
|
jsonData := `{
|
|
|
|
|
|
"agents": {
|
|
|
|
|
|
"defaults": {
|
|
|
|
|
|
"workspace": "~/.picoclaw/workspace",
|
|
|
|
|
|
"model": "glm-4.7"
|
|
|
|
|
|
},
|
|
|
|
|
|
"list": [
|
|
|
|
|
|
{ "id": "main", "default": true },
|
|
|
|
|
|
{ "id": "support" }
|
|
|
|
|
|
],
|
|
|
|
|
|
"dispatch": {
|
|
|
|
|
|
"rules": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "support-vip",
|
|
|
|
|
|
"agent": "support",
|
|
|
|
|
|
"when": {
|
|
|
|
|
|
"channel": "telegram",
|
|
|
|
|
|
"chat": "group:-100123",
|
|
|
|
|
|
"sender": "12345",
|
|
|
|
|
|
"mentioned": true
|
|
|
|
|
|
},
|
|
|
|
|
|
"session_dimensions": ["chat", "sender"]
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Agents.Dispatch == nil {
|
|
|
|
|
|
t.Fatal("Agents.Dispatch should not be nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(cfg.Agents.Dispatch.Rules) != 1 {
|
|
|
|
|
|
t.Fatalf("Dispatch.Rules len = %d, want 1", len(cfg.Agents.Dispatch.Rules))
|
|
|
|
|
|
}
|
|
|
|
|
|
rule := cfg.Agents.Dispatch.Rules[0]
|
|
|
|
|
|
if rule.Name != "support-vip" || rule.Agent != "support" {
|
|
|
|
|
|
t.Fatalf("rule = %+v", rule)
|
|
|
|
|
|
}
|
|
|
|
|
|
if rule.When.Channel != "telegram" || rule.When.Chat != "group:-100123" || rule.When.Sender != "12345" {
|
|
|
|
|
|
t.Fatalf("rule.When = %+v", rule.When)
|
|
|
|
|
|
}
|
|
|
|
|
|
if rule.When.Mentioned == nil || !*rule.When.Mentioned {
|
|
|
|
|
|
t.Fatalf("rule.When.Mentioned = %+v, want true", rule.When.Mentioned)
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := rule.SessionDimensions; len(got) != 2 || got[0] != "chat" || got[1] != "sender" {
|
|
|
|
|
|
t.Fatalf("rule.SessionDimensions = %v, want [chat sender]", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 14:57:10 +00:00
|
|
|
|
func TestLoadConfig_MigratesLegacyBindingsToDispatchRules(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
raw := `{
|
|
|
|
|
|
"version": 2,
|
|
|
|
|
|
"agents": {
|
|
|
|
|
|
"defaults": {
|
|
|
|
|
|
"workspace": "~/.picoclaw/workspace",
|
|
|
|
|
|
"model": "glm-4.7"
|
|
|
|
|
|
},
|
|
|
|
|
|
"list": [
|
|
|
|
|
|
{ "id": "main", "default": true },
|
|
|
|
|
|
{ "id": "support" },
|
|
|
|
|
|
{ "id": "ops" },
|
|
|
|
|
|
{ "id": "slack" }
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
"bindings": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"agent_id": "support",
|
|
|
|
|
|
"match": {
|
|
|
|
|
|
"channel": "telegram",
|
|
|
|
|
|
"peer": { "kind": "group", "id": "-100123" }
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"agent_id": "ops",
|
|
|
|
|
|
"match": {
|
|
|
|
|
|
"channel": "discord",
|
|
|
|
|
|
"guild_id": "guild-1"
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"agent_id": "slack",
|
|
|
|
|
|
"match": {
|
|
|
|
|
|
"channel": "slack",
|
|
|
|
|
|
"account_id": "*"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}`
|
|
|
|
|
|
if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteFile(configPath): %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Agents.Dispatch == nil {
|
|
|
|
|
|
t.Fatal("Agents.Dispatch should not be nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(cfg.Agents.Dispatch.Rules) != 3 {
|
|
|
|
|
|
t.Fatalf("Dispatch.Rules len = %d, want 3", len(cfg.Agents.Dispatch.Rules))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
first := cfg.Agents.Dispatch.Rules[0]
|
|
|
|
|
|
if first.Agent != "support" {
|
|
|
|
|
|
t.Fatalf("first.Agent = %q, want %q", first.Agent, "support")
|
|
|
|
|
|
}
|
|
|
|
|
|
if first.When.Channel != "telegram" || first.When.Chat != "group:-100123" {
|
|
|
|
|
|
t.Fatalf("first.When = %+v", first.When)
|
|
|
|
|
|
}
|
|
|
|
|
|
if first.When.Account != legacyDefaultAccountID {
|
|
|
|
|
|
t.Fatalf("first.When.Account = %q, want %q", first.When.Account, legacyDefaultAccountID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
second := cfg.Agents.Dispatch.Rules[1]
|
|
|
|
|
|
if second.Agent != "ops" || second.When.Space != "guild:guild-1" {
|
|
|
|
|
|
t.Fatalf("second = %+v", second)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
third := cfg.Agents.Dispatch.Rules[2]
|
|
|
|
|
|
if third.Agent != "slack" {
|
|
|
|
|
|
t.Fatalf("third.Agent = %q, want %q", third.Agent, "slack")
|
|
|
|
|
|
}
|
|
|
|
|
|
if third.When.Channel != "slack" || third.When.Account != "" {
|
|
|
|
|
|
t.Fatalf("third.When = %+v", third.When)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestLoadConfig_PrefersDispatchRulesOverLegacyBindings(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
raw := `{
|
|
|
|
|
|
"version": 2,
|
|
|
|
|
|
"agents": {
|
|
|
|
|
|
"defaults": {
|
|
|
|
|
|
"workspace": "~/.picoclaw/workspace",
|
|
|
|
|
|
"model": "glm-4.7"
|
|
|
|
|
|
},
|
|
|
|
|
|
"list": [
|
|
|
|
|
|
{ "id": "main", "default": true },
|
|
|
|
|
|
{ "id": "support" }
|
|
|
|
|
|
],
|
|
|
|
|
|
"dispatch": {
|
|
|
|
|
|
"rules": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "explicit",
|
|
|
|
|
|
"agent": "support",
|
|
|
|
|
|
"when": {
|
|
|
|
|
|
"channel": "telegram",
|
|
|
|
|
|
"chat": "group:-100123"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
"bindings": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"agent_id": "main",
|
|
|
|
|
|
"match": {
|
|
|
|
|
|
"channel": "telegram",
|
|
|
|
|
|
"account_id": "*"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}`
|
|
|
|
|
|
if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteFile(configPath): %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Agents.Dispatch == nil {
|
|
|
|
|
|
t.Fatal("Agents.Dispatch should not be nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(cfg.Agents.Dispatch.Rules) != 1 {
|
|
|
|
|
|
t.Fatalf("Dispatch.Rules len = %d, want 1", len(cfg.Agents.Dispatch.Rules))
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Agents.Dispatch.Rules[0].Name != "explicit" {
|
|
|
|
|
|
t.Fatalf("Dispatch.Rules[0].Name = %q, want %q", cfg.Agents.Dispatch.Rules[0].Name, "explicit")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 16:32:53 +00:00
|
|
|
|
func TestLoadConfig_MigratesLegacyDirectBindingsWithIdentityLinks(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
raw := `{
|
|
|
|
|
|
"version": 2,
|
|
|
|
|
|
"agents": {
|
|
|
|
|
|
"defaults": {
|
|
|
|
|
|
"workspace": "~/.picoclaw/workspace",
|
|
|
|
|
|
"model": "glm-4.7"
|
|
|
|
|
|
},
|
|
|
|
|
|
"list": [
|
|
|
|
|
|
{ "id": "main", "default": true },
|
|
|
|
|
|
{ "id": "support" }
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
"session": {
|
|
|
|
|
|
"identity_links": {
|
|
|
|
|
|
"john": ["telegram:123", "123"]
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
"bindings": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"agent_id": "support",
|
|
|
|
|
|
"match": {
|
|
|
|
|
|
"channel": "telegram",
|
|
|
|
|
|
"peer": { "kind": "direct", "id": "123" }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}`
|
|
|
|
|
|
if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteFile(configPath): %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Agents.Dispatch == nil || len(cfg.Agents.Dispatch.Rules) != 1 {
|
|
|
|
|
|
t.Fatalf("Dispatch.Rules = %+v, want 1 migrated rule", cfg.Agents.Dispatch)
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := cfg.Agents.Dispatch.Rules[0].When.Sender; got != "john" {
|
|
|
|
|
|
t.Fatalf("migrated sender selector = %q, want %q", got, "john")
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-13 15:24:26 +00:00
|
|
|
|
|
2026-02-12 12:15:43 +00:00
|
|
|
|
// TestDefaultConfig_HeartbeatEnabled verifies heartbeat is enabled by default
|
|
|
|
|
|
func TestDefaultConfig_HeartbeatEnabled(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
|
|
|
|
|
if !cfg.Heartbeat.Enabled {
|
|
|
|
|
|
t.Error("Heartbeat should be enabled by default")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 15:51:52 +00:00
|
|
|
|
// TestDefaultConfig_WorkspacePath verifies workspace path is correctly set
|
|
|
|
|
|
func TestDefaultConfig_WorkspacePath(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
|
|
|
|
|
if cfg.Agents.Defaults.Workspace == "" {
|
|
|
|
|
|
t.Error("Workspace should not be empty")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 07:34:59 +00:00
|
|
|
|
// TestDefaultConfig_AnthropicModelsUseClaudeAPIIDs verifies that first-party
|
|
|
|
|
|
// Anthropic defaults use Claude API model IDs, not dotted display names or
|
|
|
|
|
|
// Bedrock-style provider prefixes. See:
|
|
|
|
|
|
// https://platform.claude.com/docs/en/about-claude/models/model-ids-and-versions
|
|
|
|
|
|
func TestDefaultConfig_AnthropicModelsUseClaudeAPIIDs(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
|
|
|
|
|
checked := 0
|
|
|
|
|
|
for _, model := range cfg.ModelList {
|
|
|
|
|
|
if model.Provider != "anthropic" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
checked++
|
|
|
|
|
|
if strings.Contains(model.Model, ".") {
|
|
|
|
|
|
t.Fatalf("Anthropic default model %q uses dotted ID %q", model.ModelName, model.Model)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if checked == 0 {
|
|
|
|
|
|
t.Fatal("DefaultConfig() missing Anthropic models")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 15:51:52 +00:00
|
|
|
|
// TestDefaultConfig_MaxTokens verifies max tokens has default value
|
|
|
|
|
|
func TestDefaultConfig_MaxTokens(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
|
|
|
|
|
if cfg.Agents.Defaults.MaxTokens == 0 {
|
|
|
|
|
|
t.Error("MaxTokens should not be zero")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestDefaultConfig_MaxToolIterations verifies max tool iterations has default value
|
|
|
|
|
|
func TestDefaultConfig_MaxToolIterations(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
|
|
|
|
|
if cfg.Agents.Defaults.MaxToolIterations == 0 {
|
|
|
|
|
|
t.Error("MaxToolIterations should not be zero")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestDefaultConfig_Temperature verifies temperature has default value
|
|
|
|
|
|
func TestDefaultConfig_Temperature(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
2026-02-19 18:16:37 +00:00
|
|
|
|
if cfg.Agents.Defaults.Temperature != nil {
|
|
|
|
|
|
t.Error("Temperature should be nil when not provided")
|
2026-02-12 15:51:52 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestDefaultConfig_Gateway verifies gateway defaults
|
|
|
|
|
|
func TestDefaultConfig_Gateway(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
2026-04-13 14:49:25 +00:00
|
|
|
|
if cfg.Gateway.Host != "localhost" {
|
2026-02-12 15:51:52 +00:00
|
|
|
|
t.Error("Gateway host should have default value")
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Gateway.Port == 0 {
|
|
|
|
|
|
t.Error("Gateway port should have default value")
|
|
|
|
|
|
}
|
2026-03-17 10:46:00 +00:00
|
|
|
|
if cfg.Gateway.HotReload {
|
|
|
|
|
|
t.Error("Gateway hot reload should be disabled by default")
|
|
|
|
|
|
}
|
2026-02-12 15:51:52 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestDefaultConfig_Channels verifies channels are disabled by default
|
|
|
|
|
|
func TestDefaultConfig_Channels(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
2026-04-11 16:57:26 +00:00
|
|
|
|
for name, bc := range cfg.Channels {
|
|
|
|
|
|
if bc.Enabled {
|
|
|
|
|
|
t.Errorf("Channel %q should be disabled by default", name)
|
|
|
|
|
|
}
|
2026-02-12 15:51:52 +00:00
|
|
|
|
}
|
2026-04-11 16:57:26 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 08:38:47 +00:00
|
|
|
|
func TestDefaultConfig_ChannelStreamingDisabled(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
|
|
|
|
|
telegram := cfg.Channels.Get(ChannelTelegram)
|
|
|
|
|
|
if telegram == nil {
|
|
|
|
|
|
t.Fatal("DefaultConfig() missing telegram channel")
|
|
|
|
|
|
}
|
|
|
|
|
|
decoded, err := telegram.GetDecoded()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("telegram GetDecoded() error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
settings, ok := decoded.(*TelegramSettings)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("telegram settings type = %T, want *TelegramSettings", decoded)
|
|
|
|
|
|
}
|
|
|
|
|
|
if settings.Streaming.Enabled {
|
|
|
|
|
|
t.Fatal("DefaultConfig().telegram.settings.streaming.enabled should be false")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pico := cfg.Channels.Get(ChannelPico)
|
|
|
|
|
|
if pico == nil {
|
|
|
|
|
|
t.Fatal("DefaultConfig() missing pico channel")
|
|
|
|
|
|
}
|
|
|
|
|
|
decoded, err = pico.GetDecoded()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("pico GetDecoded() error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
picoSettings, ok := decoded.(*PicoSettings)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("pico settings type = %T, want *PicoSettings", decoded)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !picoSettings.Streaming.Enabled {
|
|
|
|
|
|
t.Fatal("DefaultConfig().pico.settings.streaming.enabled should be true")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 16:57:26 +00:00
|
|
|
|
func TestValidateSingletonChannels_RejectsMultipleInstances(t *testing.T) {
|
|
|
|
|
|
channels := ChannelsConfig{
|
|
|
|
|
|
"pico1": &Channel{Enabled: true, Type: ChannelPico},
|
|
|
|
|
|
"pico2": &Channel{Enabled: true, Type: ChannelPico},
|
|
|
|
|
|
}
|
|
|
|
|
|
err := validateSingletonChannels(channels)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
t.Fatal("expected error for multiple pico channels, got nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(err.Error(), "singleton") {
|
|
|
|
|
|
t.Fatalf("expected singleton error, got: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestValidateSingletonChannels_AllowsSingleInstance(t *testing.T) {
|
|
|
|
|
|
channels := ChannelsConfig{
|
|
|
|
|
|
"pico1": &Channel{Enabled: true, Type: ChannelPico},
|
2026-02-12 15:51:52 +00:00
|
|
|
|
}
|
2026-04-11 16:57:26 +00:00
|
|
|
|
err := validateSingletonChannels(channels)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("expected no error for single pico channel, got: %v", err)
|
2026-02-12 15:51:52 +00:00
|
|
|
|
}
|
2026-04-11 16:57:26 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestValidateSingletonChannels_IgnoresDisabledInstances(t *testing.T) {
|
|
|
|
|
|
channels := ChannelsConfig{
|
|
|
|
|
|
"pico1": &Channel{Enabled: true, Type: ChannelPico},
|
|
|
|
|
|
"pico2": &Channel{Enabled: false, Type: ChannelPico},
|
2026-02-12 15:51:52 +00:00
|
|
|
|
}
|
2026-04-11 16:57:26 +00:00
|
|
|
|
err := validateSingletonChannels(channels)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("expected no error when only one pico channel is enabled, got: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestValidateSingletonChannels_AllowsMultiInstanceTypes(t *testing.T) {
|
|
|
|
|
|
channels := ChannelsConfig{
|
|
|
|
|
|
"tg1": &Channel{Enabled: true, Type: ChannelTelegram},
|
|
|
|
|
|
"tg2": &Channel{Enabled: true, Type: ChannelTelegram},
|
|
|
|
|
|
}
|
|
|
|
|
|
err := validateSingletonChannels(channels)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("telegram should allow multiple instances, got error: %v", err)
|
2026-03-07 17:44:24 +00:00
|
|
|
|
}
|
2026-02-12 15:51:52 +00:00
|
|
|
|
}
|
2026-02-12 12:15:43 +00:00
|
|
|
|
|
2026-02-12 15:51:52 +00:00
|
|
|
|
// TestDefaultConfig_WebTools verifies web tools config
|
|
|
|
|
|
func TestDefaultConfig_WebTools(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
|
|
|
|
|
// Verify web tools defaults
|
2026-02-14 02:32:42 +00:00
|
|
|
|
if cfg.Tools.Web.Brave.MaxResults != 5 {
|
|
|
|
|
|
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
|
2026-02-12 15:51:52 +00:00
|
|
|
|
}
|
2026-03-27 16:03:34 +00:00
|
|
|
|
if len(cfg.Tools.Web.Brave.APIKeys) != 0 {
|
2026-02-14 02:32:42 +00:00
|
|
|
|
t.Error("Brave API key should be empty by default")
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
|
|
|
|
|
|
t.Error("Expected DuckDuckGo MaxResults 5, got ", cfg.Tools.Web.DuckDuckGo.MaxResults)
|
2026-02-12 15:51:52 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 08:06:39 +00:00
|
|
|
|
func TestSaveConfig_FilePermissions(t *testing.T) {
|
|
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
|
|
t.Skip("file permission bits are not enforced on Windows")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
|
|
path := filepath.Join(tmpDir, "config.json")
|
|
|
|
|
|
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if err := SaveConfig(path, cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("SaveConfig failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
info, err := os.Stat(path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Stat failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
perm := info.Mode().Perm()
|
2026-02-18 19:48:23 +00:00
|
|
|
|
if perm != 0o600 {
|
2026-02-16 08:06:39 +00:00
|
|
|
|
t.Errorf("config file has permission %04o, want 0600", perm)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-27 16:18:10 +00:00
|
|
|
|
func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
|
|
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
|
|
path := filepath.Join(tmpDir, "config.json")
|
|
|
|
|
|
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if err := SaveConfig(path, cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("SaveConfig failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
data, err := os.ReadFile(path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ReadFile failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 06:10:11 +00:00
|
|
|
|
if !strings.Contains(string(data), `"model_name": ""`) {
|
|
|
|
|
|
t.Fatalf("saved config should include empty legacy model_name field, got: %s", string(data))
|
2026-02-27 16:18:10 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 15:49:01 +00:00
|
|
|
|
func TestSaveConfig_PreservesDisabledTelegramPlaceholder(t *testing.T) {
|
|
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
|
|
path := filepath.Join(tmpDir, "config.json")
|
|
|
|
|
|
|
|
|
|
|
|
cfg := DefaultConfig()
|
2026-04-11 16:57:26 +00:00
|
|
|
|
if bc := cfg.Channels.Get("telegram"); bc != nil {
|
|
|
|
|
|
bc.Placeholder.Enabled = false
|
|
|
|
|
|
}
|
2026-03-24 15:49:01 +00:00
|
|
|
|
|
|
|
|
|
|
if err := SaveConfig(path, cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("SaveConfig failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
data, err := os.ReadFile(path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ReadFile failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(string(data), `"placeholder": {`) {
|
|
|
|
|
|
t.Fatalf("saved config should include telegram placeholder config, got: %s", string(data))
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(string(data), `"enabled": false`) {
|
|
|
|
|
|
t.Fatalf("saved config should persist placeholder.enabled=false, got: %s", string(data))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loaded, err := LoadConfig(path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
|
|
|
|
}
|
2026-04-11 16:57:26 +00:00
|
|
|
|
bc := loaded.Channels.Get("telegram")
|
|
|
|
|
|
if bc != nil && bc.Placeholder.Enabled {
|
2026-03-24 15:49:01 +00:00
|
|
|
|
t.Fatal("telegram placeholder should remain disabled after SaveConfig/LoadConfig round-trip")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 08:38:47 +00:00
|
|
|
|
func TestSaveConfig_PreservesExplicitDisabledPicoStreaming(t *testing.T) {
|
|
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
|
|
path := filepath.Join(tmpDir, "config.json")
|
|
|
|
|
|
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
pico := cfg.Channels.Get(ChannelPico)
|
|
|
|
|
|
if pico == nil {
|
|
|
|
|
|
t.Fatal("DefaultConfig() missing pico channel")
|
|
|
|
|
|
}
|
|
|
|
|
|
pico.Settings = RawNode(`{"streaming":{"enabled":false}}`)
|
|
|
|
|
|
|
|
|
|
|
|
if err := SaveConfig(path, cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("SaveConfig failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
data, err := os.ReadFile(path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ReadFile failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(string(data), `"streaming"`) || !strings.Contains(string(data), `"enabled": false`) {
|
|
|
|
|
|
t.Fatalf("saved config should preserve explicit disabled pico streaming, got:\n%s", string(data))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loaded, err := LoadConfig(path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
loadedPico := loaded.Channels.Get(ChannelPico)
|
|
|
|
|
|
if loadedPico == nil {
|
|
|
|
|
|
t.Fatal("loaded config missing pico channel")
|
|
|
|
|
|
}
|
|
|
|
|
|
decoded, err := loadedPico.GetDecoded()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("pico GetDecoded() error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
settings, ok := decoded.(*PicoSettings)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("pico settings type = %T, want *PicoSettings", decoded)
|
|
|
|
|
|
}
|
|
|
|
|
|
if settings.Streaming.Enabled {
|
|
|
|
|
|
t.Fatal("explicit disabled pico streaming should remain disabled after SaveConfig/LoadConfig round-trip")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 15:56:45 +00:00
|
|
|
|
// TestSaveConfig_FiltersVirtualModels verifies that SaveConfig does not write
|
|
|
|
|
|
// virtual models (generated by expandMultiKeyModels) to the config file.
|
|
|
|
|
|
func TestSaveConfig_FiltersVirtualModels(t *testing.T) {
|
|
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
|
|
path := filepath.Join(tmpDir, "config.json")
|
|
|
|
|
|
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
|
|
|
|
|
// Manually add a virtual model to ModelList (simulating what expandMultiKeyModels does)
|
|
|
|
|
|
primaryModel := &ModelConfig{
|
|
|
|
|
|
ModelName: "gpt-4",
|
|
|
|
|
|
Model: "openai/gpt-4o",
|
2026-03-27 16:03:34 +00:00
|
|
|
|
APIKeys: SimpleSecureStrings("key1"),
|
2026-03-24 15:56:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
virtualModel := &ModelConfig{
|
|
|
|
|
|
ModelName: "gpt-4__key_1",
|
|
|
|
|
|
Model: "openai/gpt-4o",
|
2026-03-27 16:03:34 +00:00
|
|
|
|
APIKeys: SimpleSecureStrings("key2"),
|
2026-03-24 15:56:45 +00:00
|
|
|
|
isVirtual: true,
|
|
|
|
|
|
}
|
|
|
|
|
|
cfg.ModelList = []*ModelConfig{primaryModel, virtualModel}
|
|
|
|
|
|
|
|
|
|
|
|
// SaveConfig should filter out virtual models
|
|
|
|
|
|
if err := SaveConfig(path, cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("SaveConfig failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Reload and verify
|
|
|
|
|
|
reloaded, err := LoadConfig(path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Should only have the primary model, not the virtual one
|
|
|
|
|
|
if len(reloaded.ModelList) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 model after reload, got %d", len(reloaded.ModelList))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if reloaded.ModelList[0].ModelName != "gpt-4" {
|
|
|
|
|
|
t.Errorf("expected model_name 'gpt-4', got %q", reloaded.ModelList[0].ModelName)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Verify virtual model was not persisted
|
|
|
|
|
|
for _, m := range reloaded.ModelList {
|
|
|
|
|
|
if m.ModelName == "gpt-4__key_1" {
|
|
|
|
|
|
t.Errorf("virtual model gpt-4__key_1 should not have been saved")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Verify the saved file does not contain the virtual model name
|
|
|
|
|
|
data, err := os.ReadFile(path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ReadFile failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.Contains(string(data), "gpt-4__key_1") {
|
|
|
|
|
|
t.Errorf("saved config should not contain virtual model name 'gpt-4__key_1'")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 15:51:52 +00:00
|
|
|
|
// TestConfig_Complete verifies all config fields are set
|
|
|
|
|
|
func TestConfig_Complete(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
|
|
|
|
|
if cfg.Agents.Defaults.Workspace == "" {
|
|
|
|
|
|
t.Error("Workspace should not be empty")
|
|
|
|
|
|
}
|
2026-02-19 18:16:37 +00:00
|
|
|
|
if cfg.Agents.Defaults.Temperature != nil {
|
|
|
|
|
|
t.Error("Temperature should be nil when not provided")
|
2026-02-12 15:51:52 +00:00
|
|
|
|
}
|
|
|
|
|
|
if cfg.Agents.Defaults.MaxTokens == 0 {
|
|
|
|
|
|
t.Error("MaxTokens should not be zero")
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Agents.Defaults.MaxToolIterations == 0 {
|
|
|
|
|
|
t.Error("MaxToolIterations should not be zero")
|
|
|
|
|
|
}
|
2026-04-13 14:49:25 +00:00
|
|
|
|
if cfg.Gateway.Host != "localhost" {
|
2026-02-12 15:51:52 +00:00
|
|
|
|
t.Error("Gateway host should have default value")
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Gateway.Port == 0 {
|
|
|
|
|
|
t.Error("Gateway port should have default value")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !cfg.Heartbeat.Enabled {
|
|
|
|
|
|
t.Error("Heartbeat should be enabled by default")
|
2026-02-12 12:15:43 +00:00
|
|
|
|
}
|
2026-03-11 08:33:01 +00:00
|
|
|
|
if !cfg.Tools.Exec.AllowRemote {
|
|
|
|
|
|
t.Error("Exec.AllowRemote should be true by default")
|
2026-02-18 08:30:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 03:55:30 +00:00
|
|
|
|
func TestDefaultConfig_WebPreferNativeEnabled(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if !cfg.Tools.Web.PreferNative {
|
|
|
|
|
|
t.Fatal("DefaultConfig().Tools.Web.PreferNative should be true")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 05:03:06 +00:00
|
|
|
|
func TestDefaultConfig_WebProviderIsAuto(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if cfg.Tools.Web.Provider != "auto" {
|
|
|
|
|
|
t.Fatalf("DefaultConfig().Tools.Web.Provider = %q, want auto", cfg.Tools.Web.Provider)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 10:45:28 +00:00
|
|
|
|
func TestConfigExample_WebProviderIsAuto(t *testing.T) {
|
|
|
|
|
|
data, err := os.ReadFile(filepath.Join("..", "..", "config", "config.example.json"))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ReadFile(config.example.json) error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var cfg Config
|
|
|
|
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("Unmarshal(config.example.json) error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Tools.Web.Provider != "auto" {
|
|
|
|
|
|
t.Fatalf("config.example.json tools.web.provider = %q, want auto", cfg.Tools.Web.Provider)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 01:31:42 +00:00
|
|
|
|
func TestDefaultConfig_ToolFeedbackDisabled(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if cfg.Agents.Defaults.ToolFeedback.Enabled {
|
|
|
|
|
|
t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.Enabled should be false")
|
|
|
|
|
|
}
|
2026-04-24 03:49:41 +00:00
|
|
|
|
if cfg.Agents.Defaults.ToolFeedback.SeparateMessages {
|
|
|
|
|
|
t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.SeparateMessages should be false")
|
|
|
|
|
|
}
|
2026-03-26 01:31:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
if err := os.WriteFile(
|
|
|
|
|
|
configPath,
|
|
|
|
|
|
[]byte(`{"version":1,"agents":{"defaults":{"workspace":"./workspace"}}}`),
|
|
|
|
|
|
0o600,
|
|
|
|
|
|
); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteFile() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Agents.Defaults.ToolFeedback.Enabled {
|
2026-03-29 11:58:19 +00:00
|
|
|
|
t.Fatal(
|
|
|
|
|
|
"agents.defaults.tool_feedback.enabled should remain false when unset in config file",
|
|
|
|
|
|
)
|
2026-03-26 01:31:42 +00:00
|
|
|
|
}
|
2026-04-24 03:49:41 +00:00
|
|
|
|
if cfg.Agents.Defaults.ToolFeedback.SeparateMessages {
|
|
|
|
|
|
t.Fatal("agents.defaults.tool_feedback.separate_messages should remain false when unset in config file")
|
|
|
|
|
|
}
|
2026-03-26 01:31:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 03:55:30 +00:00
|
|
|
|
func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
2026-03-19 06:55:30 +00:00
|
|
|
|
if err := os.WriteFile(configPath, []byte(`{"version":1,"tools":{"web":{"enabled":true}}}`), 0o600); err != nil {
|
2026-03-18 03:55:30 +00:00
|
|
|
|
t.Fatalf("WriteFile() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !cfg.Tools.Web.PreferNative {
|
|
|
|
|
|
t.Fatal("PreferNative should remain true when unset in config file")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestLoadConfig_WebPreferNativeCanBeDisabled(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"prefer_native":false}}}`), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteFile() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Tools.Web.PreferNative {
|
|
|
|
|
|
t.Fatal("PreferNative should be false when disabled in config file")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 01:45:52 +00:00
|
|
|
|
func TestLoadConfig_SyntaxErrorReportsLineAndColumn(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
raw := "{\n \"version\": 2,\n \"tools\": {\n \"web\": {\n \"enabled\": true,,\n \"format\": \"markdown\"\n }\n }\n}\n"
|
|
|
|
|
|
if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteFile() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_, err := LoadConfig(configPath)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
t.Fatal("expected syntax error, got nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(err.Error(), "syntax error at line 5, column 23") {
|
|
|
|
|
|
t.Fatalf("expected line/column diagnostic, got %q", err.Error())
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(err.Error(), "\"enabled\": true,,") {
|
|
|
|
|
|
t.Fatalf("expected source snippet in diagnostic, got %q", err.Error())
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(err.Error(), "^") {
|
|
|
|
|
|
t.Fatalf("expected caret marker in diagnostic, got %q", err.Error())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestLoadConfig_TypeErrorReportsFieldPath(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
raw := "{\n \"version\": 2,\n \"tools\": {\n \"web\": {\n \"fetch_limit_bytes\": \"oops\"\n }\n }\n}\n"
|
|
|
|
|
|
if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteFile() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_, err := LoadConfig(configPath)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
t.Fatal("expected type error, got nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(err.Error(), "type error at line 5, column 33") {
|
|
|
|
|
|
t.Fatalf("expected line/column diagnostic, got %q", err.Error())
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(err.Error(), "fetch_limit_bytes") {
|
|
|
|
|
|
t.Fatalf("expected field name in diagnostic, got %q", err.Error())
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(err.Error(), "\"fetch_limit_bytes\": \"oops\"") {
|
|
|
|
|
|
t.Fatalf("expected source snippet in diagnostic, got %q", err.Error())
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(err.Error(), "^") {
|
|
|
|
|
|
t.Fatalf("expected caret marker in diagnostic, got %q", err.Error())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestLoadConfig_UnknownFieldsReportsExactPaths(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
raw := "{\n \"version\": 2,\n \"tools\": {\n \"weeb\": {\n \"enabled\": true\n },\n \"web\": {\n \"fatch_limit_bytes\": 123\n }\n }\n}\n"
|
|
|
|
|
|
if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteFile() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_, err := LoadConfig(configPath)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
t.Fatal("expected unknown field error, got nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(err.Error(), "tools.weeb") || !strings.Contains(err.Error(), "tools.web.fatch_limit_bytes") {
|
|
|
|
|
|
t.Fatalf("expected exact unknown field paths, got %q", err.Error())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 08:33:01 +00:00
|
|
|
|
func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if !cfg.Tools.Exec.AllowRemote {
|
|
|
|
|
|
t.Fatal("DefaultConfig().Tools.Exec.AllowRemote should be true")
|
2026-02-18 08:30:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 12:55:41 +00:00
|
|
|
|
func TestDefaultConfig_FilterSensitiveDataEnabled(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if !cfg.Tools.FilterSensitiveData {
|
|
|
|
|
|
t.Fatal("DefaultConfig().Tools.FilterSensitiveData should be true")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestDefaultConfig_FilterMinLength(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if cfg.Tools.FilterMinLength != 8 {
|
|
|
|
|
|
t.Fatalf("DefaultConfig().Tools.FilterMinLength = %d, want 8", cfg.Tools.FilterMinLength)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 02:17:34 +00:00
|
|
|
|
func TestDefaultConfig_LoadImageEnabled(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if !cfg.Tools.LoadImage.Enabled {
|
|
|
|
|
|
t.Fatal("DefaultConfig().Tools.LoadImage.Enabled should be true")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !cfg.Tools.IsToolEnabled("load_image") {
|
|
|
|
|
|
t.Fatal("DefaultConfig().Tools.IsToolEnabled(load_image) should be true")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestLoadConfig_LoadImageCanBeDisabled(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
raw := "{\n \"version\": 2,\n \"tools\": {\n \"load_image\": {\n \"enabled\": false\n }\n }\n}\n"
|
|
|
|
|
|
if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteFile() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Tools.LoadImage.Enabled {
|
|
|
|
|
|
t.Fatal("LoadConfig().Tools.LoadImage.Enabled should be false")
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Tools.IsToolEnabled("load_image") {
|
|
|
|
|
|
t.Fatal("LoadConfig().Tools.IsToolEnabled(load_image) should be false")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 23:20:59 +00:00
|
|
|
|
func TestDefaultConfig_MessageMediaDisabled(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if !cfg.Tools.Message.Enabled {
|
|
|
|
|
|
t.Fatal("DefaultConfig().Tools.Message.Enabled should be true")
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Tools.Message.MediaEnabled {
|
|
|
|
|
|
t.Fatal("DefaultConfig().Tools.Message.MediaEnabled should be false")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 12:55:41 +00:00
|
|
|
|
func TestToolsConfig_GetFilterMinLength(t *testing.T) {
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
minLen int
|
|
|
|
|
|
expected int
|
|
|
|
|
|
}{
|
|
|
|
|
|
{"zero returns default", 0, 8},
|
|
|
|
|
|
{"negative returns default", -1, 8},
|
|
|
|
|
|
{"positive returns value", 16, 16},
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
|
cfg := &ToolsConfig{FilterMinLength: tt.minLen}
|
|
|
|
|
|
if got := cfg.GetFilterMinLength(); got != tt.expected {
|
|
|
|
|
|
t.Errorf("GetFilterMinLength() = %v, want %v", got, tt.expected)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 01:44:32 +00:00
|
|
|
|
func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if !cfg.Tools.Cron.AllowCommand {
|
|
|
|
|
|
t.Fatal("DefaultConfig().Tools.Cron.AllowCommand should be true")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 13:25:30 +00:00
|
|
|
|
func TestDefaultConfig_CronCommandAllowedRemotesEmpty(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if len(cfg.Tools.Cron.CommandAllowedRemotes) != 0 {
|
|
|
|
|
|
t.Fatalf(
|
|
|
|
|
|
"DefaultConfig().Tools.Cron.CommandAllowedRemotes = %#v, want empty",
|
|
|
|
|
|
cfg.Tools.Cron.CommandAllowedRemotes,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 11:21:58 +00:00
|
|
|
|
func TestDefaultConfig_HooksDefaults(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if !cfg.Hooks.Enabled {
|
|
|
|
|
|
t.Fatal("DefaultConfig().Hooks.Enabled should be true")
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Hooks.Defaults.ObserverTimeoutMS != 500 {
|
|
|
|
|
|
t.Fatalf("ObserverTimeoutMS = %d, want 500", cfg.Hooks.Defaults.ObserverTimeoutMS)
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Hooks.Defaults.InterceptorTimeoutMS != 5000 {
|
|
|
|
|
|
t.Fatalf("InterceptorTimeoutMS = %d, want 5000", cfg.Hooks.Defaults.InterceptorTimeoutMS)
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Hooks.Defaults.ApprovalTimeoutMS != 60000 {
|
|
|
|
|
|
t.Fatalf("ApprovalTimeoutMS = %d, want 60000", cfg.Hooks.Defaults.ApprovalTimeoutMS)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 06:53:05 +00:00
|
|
|
|
func TestDefaultConfig_LogLevel(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
2026-03-27 13:04:28 +00:00
|
|
|
|
if cfg.Gateway.LogLevel != "warn" {
|
2026-03-23 05:14:53 +00:00
|
|
|
|
t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Gateway.LogLevel)
|
2026-03-21 06:53:05 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 08:33:01 +00:00
|
|
|
|
func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) {
|
2026-02-18 08:30:30 +00:00
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
2026-03-11 08:33:01 +00:00
|
|
|
|
if err := os.WriteFile(configPath, []byte(`{"version":1,"tools":{"exec":{"enable_deny_patterns":true}}}`),
|
|
|
|
|
|
0o600); err != nil {
|
2026-02-18 08:30:30 +00:00
|
|
|
|
t.Fatalf("WriteFile() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error: %v", err)
|
|
|
|
|
|
}
|
2026-03-11 08:33:01 +00:00
|
|
|
|
if !cfg.Tools.Exec.AllowRemote {
|
|
|
|
|
|
t.Fatal("tools.exec.allow_remote should remain true when unset in config file")
|
2026-02-18 08:30:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-24 09:16:16 +00:00
|
|
|
|
|
2026-03-17 01:44:32 +00:00
|
|
|
|
func TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
2026-03-19 06:55:30 +00:00
|
|
|
|
if err := os.WriteFile(
|
|
|
|
|
|
configPath,
|
|
|
|
|
|
[]byte(`{"version":1,"tools":{"cron":{"exec_timeout_minutes":5}}}`),
|
|
|
|
|
|
0o600,
|
|
|
|
|
|
); err != nil {
|
2026-03-17 01:44:32 +00:00
|
|
|
|
t.Fatalf("WriteFile() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !cfg.Tools.Cron.AllowCommand {
|
|
|
|
|
|
t.Fatal("tools.cron.allow_command should remain true when unset in config file")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 13:25:30 +00:00
|
|
|
|
func TestLoadConfig_CronCommandAllowedRemotes(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
if err := os.WriteFile(
|
|
|
|
|
|
configPath,
|
|
|
|
|
|
[]byte(`{"version":1,"tools":{"cron":{"command_allowed_remotes":["telegram:1234567890","discord"]}}}`),
|
|
|
|
|
|
0o600,
|
|
|
|
|
|
); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteFile() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
want := []string{"telegram:1234567890", "discord"}
|
|
|
|
|
|
if len(cfg.Tools.Cron.CommandAllowedRemotes) != len(want) {
|
|
|
|
|
|
t.Fatalf("CommandAllowedRemotes = %#v, want %#v", cfg.Tools.Cron.CommandAllowedRemotes, want)
|
|
|
|
|
|
}
|
|
|
|
|
|
for i := range want {
|
|
|
|
|
|
if cfg.Tools.Cron.CommandAllowedRemotes[i] != want[i] {
|
|
|
|
|
|
t.Fatalf("CommandAllowedRemotes = %#v, want %#v", cfg.Tools.Cron.CommandAllowedRemotes, want)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 09:16:16 +00:00
|
|
|
|
func TestLoadConfig_WebToolsProxy(t *testing.T) {
|
|
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(tmpDir, "config.json")
|
|
|
|
|
|
configJSON := `{
|
|
|
|
|
|
"agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}},
|
2026-03-12 08:10:29 +00:00
|
|
|
|
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.4","api_key":"x"}],
|
2026-02-24 09:16:16 +00:00
|
|
|
|
"tools": {"web":{"proxy":"http://127.0.0.1:7890"}}
|
|
|
|
|
|
}`
|
|
|
|
|
|
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("os.WriteFile() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Tools.Web.Proxy != "http://127.0.0.1:7890" {
|
|
|
|
|
|
t.Fatalf("Tools.Web.Proxy = %q, want %q", cfg.Tools.Web.Proxy, "http://127.0.0.1:7890")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-26 08:51:11 +00:00
|
|
|
|
|
2026-03-22 11:21:58 +00:00
|
|
|
|
func TestLoadConfig_HooksProcessConfig(t *testing.T) {
|
|
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(tmpDir, "config.json")
|
|
|
|
|
|
configJSON := `{
|
2026-03-23 02:54:08 +00:00
|
|
|
|
"version": 1,
|
2026-03-22 11:21:58 +00:00
|
|
|
|
"hooks": {
|
|
|
|
|
|
"processes": {
|
|
|
|
|
|
"review-gate": {
|
|
|
|
|
|
"enabled": true,
|
|
|
|
|
|
"transport": "stdio",
|
|
|
|
|
|
"command": ["uvx", "picoclaw-hook-reviewer"],
|
|
|
|
|
|
"dir": "/tmp/hooks",
|
|
|
|
|
|
"env": {
|
|
|
|
|
|
"HOOK_MODE": "rewrite"
|
|
|
|
|
|
},
|
|
|
|
|
|
"observe": ["turn_start", "turn_end"],
|
|
|
|
|
|
"intercept": ["before_tool", "approve_tool"]
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
"builtins": {
|
|
|
|
|
|
"audit": {
|
|
|
|
|
|
"enabled": true,
|
|
|
|
|
|
"priority": 5,
|
|
|
|
|
|
"config": {
|
|
|
|
|
|
"label": "audit"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}`
|
|
|
|
|
|
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("os.WriteFile() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
processCfg, ok := cfg.Hooks.Processes["review-gate"]
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatal("expected review-gate process hook")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !processCfg.Enabled {
|
|
|
|
|
|
t.Fatal("expected review-gate process hook to be enabled")
|
|
|
|
|
|
}
|
|
|
|
|
|
if processCfg.Transport != "stdio" {
|
|
|
|
|
|
t.Fatalf("Transport = %q, want stdio", processCfg.Transport)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(processCfg.Command) != 2 || processCfg.Command[0] != "uvx" {
|
|
|
|
|
|
t.Fatalf("Command = %v", processCfg.Command)
|
|
|
|
|
|
}
|
|
|
|
|
|
if processCfg.Dir != "/tmp/hooks" {
|
|
|
|
|
|
t.Fatalf("Dir = %q, want /tmp/hooks", processCfg.Dir)
|
|
|
|
|
|
}
|
|
|
|
|
|
if processCfg.Env["HOOK_MODE"] != "rewrite" {
|
|
|
|
|
|
t.Fatalf("HOOK_MODE = %q, want rewrite", processCfg.Env["HOOK_MODE"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(processCfg.Observe) != 2 || processCfg.Observe[1] != "turn_end" {
|
|
|
|
|
|
t.Fatalf("Observe = %v", processCfg.Observe)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(processCfg.Intercept) != 2 || processCfg.Intercept[1] != "approve_tool" {
|
|
|
|
|
|
t.Fatalf("Intercept = %v", processCfg.Intercept)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
builtinCfg, ok := cfg.Hooks.Builtins["audit"]
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatal("expected audit builtin hook")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !builtinCfg.Enabled {
|
|
|
|
|
|
t.Fatal("expected audit builtin hook to be enabled")
|
|
|
|
|
|
}
|
|
|
|
|
|
if builtinCfg.Priority != 5 {
|
|
|
|
|
|
t.Fatalf("Priority = %d, want 5", builtinCfg.Priority)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(string(builtinCfg.Config), `"audit"`) {
|
|
|
|
|
|
t.Fatalf("Config = %s", string(builtinCfg.Config))
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Hooks.Defaults.ApprovalTimeoutMS != 60000 {
|
|
|
|
|
|
t.Fatalf("ApprovalTimeoutMS = %d, want 60000", cfg.Hooks.Defaults.ApprovalTimeoutMS)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 09:19:50 +00:00
|
|
|
|
// TestDefaultConfig_SessionDimensions verifies the default session dimensions
|
2026-03-04 03:23:01 +00:00
|
|
|
|
// TestDefaultConfig_SummarizationThresholds verifies summarization defaults
|
|
|
|
|
|
func TestDefaultConfig_SummarizationThresholds(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
|
|
|
|
|
if cfg.Agents.Defaults.SummarizeMessageThreshold != 20 {
|
2026-03-29 11:58:19 +00:00
|
|
|
|
t.Errorf(
|
|
|
|
|
|
"SummarizeMessageThreshold = %d, want 20",
|
|
|
|
|
|
cfg.Agents.Defaults.SummarizeMessageThreshold,
|
|
|
|
|
|
)
|
2026-03-04 03:23:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
if cfg.Agents.Defaults.SummarizeTokenPercent != 75 {
|
|
|
|
|
|
t.Errorf("SummarizeTokenPercent = %d, want 75", cfg.Agents.Defaults.SummarizeTokenPercent)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 09:19:50 +00:00
|
|
|
|
func TestDefaultConfig_SessionDimensions(t *testing.T) {
|
2026-02-26 08:51:11 +00:00
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
2026-04-01 09:19:50 +00:00
|
|
|
|
if len(cfg.Session.Dimensions) != 1 || cfg.Session.Dimensions[0] != "chat" {
|
|
|
|
|
|
t.Errorf("Session.Dimensions = %v, want [chat]", cfg.Session.Dimensions)
|
2026-02-26 08:51:11 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-01 20:41:12 +00:00
|
|
|
|
|
2026-06-09 03:03:33 +00:00
|
|
|
|
func TestSessionConfig_ApplyDmScope(t *testing.T) {
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
dmScope string
|
|
|
|
|
|
dimensions []string
|
|
|
|
|
|
want []string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "per-channel-peer",
|
|
|
|
|
|
dmScope: "per-channel-peer",
|
|
|
|
|
|
want: []string{"chat", "sender"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "per-channel",
|
|
|
|
|
|
dmScope: "per-channel",
|
|
|
|
|
|
want: []string{"chat"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "per-peer",
|
|
|
|
|
|
dmScope: "per-peer",
|
|
|
|
|
|
want: []string{"sender"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "global",
|
|
|
|
|
|
dmScope: "global",
|
|
|
|
|
|
want: nil,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "explicit dimensions take precedence",
|
|
|
|
|
|
dmScope: "per-channel-peer",
|
|
|
|
|
|
dimensions: []string{"sender"},
|
|
|
|
|
|
want: []string{"sender"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "empty dm_scope is no-op",
|
|
|
|
|
|
dmScope: "",
|
|
|
|
|
|
want: nil,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
|
s := &SessionConfig{
|
|
|
|
|
|
DmScope: tt.dmScope,
|
|
|
|
|
|
Dimensions: tt.dimensions,
|
|
|
|
|
|
}
|
|
|
|
|
|
s.ApplyDmScope()
|
|
|
|
|
|
if len(s.Dimensions) != len(tt.want) {
|
|
|
|
|
|
t.Fatalf("Dimensions = %v, want %v", s.Dimensions, tt.want)
|
|
|
|
|
|
}
|
|
|
|
|
|
for i, v := range tt.want {
|
|
|
|
|
|
if s.Dimensions[i] != v {
|
|
|
|
|
|
t.Errorf("Dimensions[%d] = %q, want %q", i, s.Dimensions[i], v)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 08:00:18 +00:00
|
|
|
|
func TestSessionConfig_DeriveDmScope(t *testing.T) {
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
dimensions []string
|
|
|
|
|
|
dmScope string
|
|
|
|
|
|
wantScope string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "per-channel-peer from dimensions",
|
|
|
|
|
|
dimensions: []string{"chat", "sender"},
|
|
|
|
|
|
wantScope: "per-channel-peer",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "per-channel from dimensions",
|
|
|
|
|
|
dimensions: []string{"chat"},
|
|
|
|
|
|
wantScope: "per-channel",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "per-peer from dimensions",
|
|
|
|
|
|
dimensions: []string{"sender"},
|
|
|
|
|
|
wantScope: "per-peer",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "custom dimensions does not set scope",
|
|
|
|
|
|
dimensions: []string{"chat", "extra"},
|
|
|
|
|
|
wantScope: "",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "empty dimensions does not set scope",
|
|
|
|
|
|
dimensions: nil,
|
|
|
|
|
|
wantScope: "",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "existing dm_scope is not overwritten",
|
|
|
|
|
|
dimensions: []string{"chat", "sender"},
|
|
|
|
|
|
dmScope: "per-channel",
|
|
|
|
|
|
wantScope: "per-channel",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
|
s := &SessionConfig{
|
|
|
|
|
|
DmScope: tt.dmScope,
|
|
|
|
|
|
Dimensions: tt.dimensions,
|
|
|
|
|
|
}
|
|
|
|
|
|
s.DeriveDmScope()
|
|
|
|
|
|
if s.DmScope != tt.wantScope {
|
|
|
|
|
|
t.Errorf("DmScope = %q, want %q", s.DmScope, tt.wantScope)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestSessionConfig_ApplyDmScope_ClearsStaleDimensions(t *testing.T) {
|
|
|
|
|
|
// Simulates the PATCH handler scenario: dm_scope changed but stale
|
|
|
|
|
|
// dimensions remain from the old scope. After clearing dimensions,
|
|
|
|
|
|
// ApplyDmScope should re-derive from the new dm_scope.
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
dmScope string
|
|
|
|
|
|
want []string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "per-channel-peer to per-channel",
|
|
|
|
|
|
dmScope: "per-channel",
|
|
|
|
|
|
want: []string{"chat"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "per-channel-peer to per-peer",
|
|
|
|
|
|
dmScope: "per-peer",
|
|
|
|
|
|
want: []string{"sender"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "per-channel-peer to global",
|
|
|
|
|
|
dmScope: "global",
|
|
|
|
|
|
want: nil,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
|
s := &SessionConfig{
|
|
|
|
|
|
DmScope: tt.dmScope,
|
|
|
|
|
|
Dimensions: []string{"chat", "sender"}, // stale from per-channel-peer
|
|
|
|
|
|
}
|
|
|
|
|
|
// Simulate what the PATCH handler does: clear dimensions when dm_scope changes
|
|
|
|
|
|
s.Dimensions = nil
|
|
|
|
|
|
s.ApplyDmScope()
|
|
|
|
|
|
if len(s.Dimensions) != len(tt.want) {
|
|
|
|
|
|
t.Fatalf("Dimensions = %v, want %v", s.Dimensions, tt.want)
|
|
|
|
|
|
}
|
|
|
|
|
|
for i, v := range tt.want {
|
|
|
|
|
|
if s.Dimensions[i] != v {
|
|
|
|
|
|
t.Errorf("Dimensions[%d] = %q, want %q", i, s.Dimensions[i], v)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 20:41:12 +00:00
|
|
|
|
func TestDefaultConfig_WorkspacePath_Default(t *testing.T) {
|
|
|
|
|
|
t.Setenv("PICOCLAW_HOME", "")
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
|
|
|
|
|
|
var fakeHome string
|
|
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
|
|
fakeHome = `C:\tmp\home`
|
|
|
|
|
|
t.Setenv("USERPROFILE", fakeHome)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
fakeHome = "/tmp/home"
|
|
|
|
|
|
t.Setenv("HOME", fakeHome)
|
|
|
|
|
|
}
|
2026-03-01 20:41:12 +00:00
|
|
|
|
|
|
|
|
|
|
cfg := DefaultConfig()
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
want := filepath.Join(fakeHome, ".picoclaw", "workspace")
|
2026-03-01 20:41:12 +00:00
|
|
|
|
|
|
|
|
|
|
if cfg.Agents.Defaults.Workspace != want {
|
|
|
|
|
|
t.Errorf("Default workspace path = %q, want %q", cfg.Agents.Defaults.Workspace, want)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
|
|
|
|
|
|
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw/home")
|
|
|
|
|
|
|
|
|
|
|
|
cfg := DefaultConfig()
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
want := filepath.Join("/custom/picoclaw/home", "workspace")
|
2026-03-01 20:41:12 +00:00
|
|
|
|
|
|
|
|
|
|
if cfg.Agents.Defaults.Workspace != want {
|
2026-03-29 11:58:19 +00:00
|
|
|
|
t.Errorf(
|
|
|
|
|
|
"Workspace path with PICOCLAW_HOME = %q, want %q",
|
|
|
|
|
|
cfg.Agents.Defaults.Workspace,
|
|
|
|
|
|
want,
|
|
|
|
|
|
)
|
2026-03-01 20:41:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-11 08:33:01 +00:00
|
|
|
|
|
2026-04-08 10:15:42 +00:00
|
|
|
|
func TestDefaultConfig_IsolationEnabled(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
if cfg.Isolation.Enabled {
|
|
|
|
|
|
t.Fatal("DefaultConfig().Isolation.Enabled should be false")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestConfig_UnmarshalIsolation(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
raw := []byte(`{
|
|
|
|
|
|
"isolation": {
|
|
|
|
|
|
"enabled": false,
|
|
|
|
|
|
"expose_paths": [
|
|
|
|
|
|
{"source":"/src","target":"/dst","mode":"ro"}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
}`)
|
|
|
|
|
|
if err := json.Unmarshal(raw, cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("json.Unmarshal isolation config: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Isolation.Enabled {
|
|
|
|
|
|
t.Fatal("Isolation.Enabled should be false after unmarshal")
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(cfg.Isolation.ExposePaths) != 1 {
|
|
|
|
|
|
t.Fatalf("ExposePaths len = %d, want 1", len(cfg.Isolation.ExposePaths))
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := cfg.Isolation.ExposePaths[0]; got.Source != "/src" || got.Target != "/dst" || got.Mode != "ro" {
|
|
|
|
|
|
t.Fatalf("ExposePaths[0] = %+v, want source=/src target=/dst mode=ro", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 08:33:01 +00:00
|
|
|
|
// TestFlexibleStringSlice_UnmarshalText tests UnmarshalText with various comma separators
|
|
|
|
|
|
func TestFlexibleStringSlice_UnmarshalText(t *testing.T) {
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
input string
|
|
|
|
|
|
expected []string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "English commas only",
|
|
|
|
|
|
input: "123,456,789",
|
|
|
|
|
|
expected: []string{"123", "456", "789"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Chinese commas only",
|
|
|
|
|
|
input: "123,456,789",
|
|
|
|
|
|
expected: []string{"123", "456", "789"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Mixed English and Chinese commas",
|
|
|
|
|
|
input: "123,456,789",
|
|
|
|
|
|
expected: []string{"123", "456", "789"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Single value",
|
|
|
|
|
|
input: "123",
|
|
|
|
|
|
expected: []string{"123"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Values with whitespace",
|
|
|
|
|
|
input: " 123 , 456 , 789 ",
|
|
|
|
|
|
expected: []string{"123", "456", "789"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Empty string",
|
|
|
|
|
|
input: "",
|
|
|
|
|
|
expected: nil,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Only commas - English",
|
|
|
|
|
|
input: ",,",
|
|
|
|
|
|
expected: []string{},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Only commas - Chinese",
|
|
|
|
|
|
input: ",,",
|
|
|
|
|
|
expected: []string{},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Mixed commas with empty parts",
|
|
|
|
|
|
input: "123,,456,,789",
|
|
|
|
|
|
expected: []string{"123", "456", "789"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Complex mixed values",
|
|
|
|
|
|
input: "user1@example.com,user2@test.com, admin@domain.org",
|
|
|
|
|
|
expected: []string{"user1@example.com", "user2@test.com", "admin@domain.org"},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
|
var f FlexibleStringSlice
|
|
|
|
|
|
err := f.UnmarshalText([]byte(tt.input))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("UnmarshalText(%q) error = %v", tt.input, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if tt.expected == nil {
|
|
|
|
|
|
if f != nil {
|
|
|
|
|
|
t.Errorf("UnmarshalText(%q) = %v, want nil", tt.input, f)
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(f) != len(tt.expected) {
|
2026-03-29 11:58:19 +00:00
|
|
|
|
t.Errorf(
|
|
|
|
|
|
"UnmarshalText(%q) length = %d, want %d",
|
|
|
|
|
|
tt.input,
|
|
|
|
|
|
len(f),
|
|
|
|
|
|
len(tt.expected),
|
|
|
|
|
|
)
|
2026-03-11 08:33:01 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for i, v := range tt.expected {
|
|
|
|
|
|
if f[i] != v {
|
|
|
|
|
|
t.Errorf("UnmarshalText(%q)[%d] = %q, want %q", tt.input, i, f[i], v)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency tests nil vs empty slice behavior
|
|
|
|
|
|
func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) {
|
|
|
|
|
|
t.Run("Empty string returns nil", func(t *testing.T) {
|
|
|
|
|
|
var f FlexibleStringSlice
|
|
|
|
|
|
err := f.UnmarshalText([]byte(""))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("UnmarshalText error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if f != nil {
|
|
|
|
|
|
t.Errorf("Empty string should return nil, got %v", f)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
t.Run("Commas only returns empty slice", func(t *testing.T) {
|
|
|
|
|
|
var f FlexibleStringSlice
|
|
|
|
|
|
err := f.UnmarshalText([]byte(",,,"))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("UnmarshalText error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if f == nil {
|
|
|
|
|
|
t.Error("Commas only should return empty slice, not nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(f) != 0 {
|
|
|
|
|
|
t.Errorf("Expected empty slice, got %v", f)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
|
2026-03-26 20:40:38 +00:00
|
|
|
|
func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) {
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
input string
|
|
|
|
|
|
expected []string
|
|
|
|
|
|
}{
|
2026-04-21 08:04:28 +00:00
|
|
|
|
{
|
|
|
|
|
|
name: "null",
|
|
|
|
|
|
input: `null`,
|
|
|
|
|
|
expected: nil,
|
|
|
|
|
|
},
|
2026-03-26 20:40:38 +00:00
|
|
|
|
{
|
|
|
|
|
|
name: "single string",
|
|
|
|
|
|
input: `"Thinking..."`,
|
|
|
|
|
|
expected: []string{"Thinking..."},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "single number",
|
|
|
|
|
|
input: `123`,
|
|
|
|
|
|
expected: []string{"123"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "string array",
|
|
|
|
|
|
input: `["Thinking...", "Still working..."]`,
|
|
|
|
|
|
expected: []string{"Thinking...", "Still working..."},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "mixed array",
|
|
|
|
|
|
input: `["123", 456]`,
|
|
|
|
|
|
expected: []string{"123", "456"},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
|
var f FlexibleStringSlice
|
|
|
|
|
|
if err := json.Unmarshal([]byte(tt.input), &f); err != nil {
|
|
|
|
|
|
t.Fatalf("json.Unmarshal(%s) error = %v", tt.input, err)
|
|
|
|
|
|
}
|
2026-04-21 08:04:28 +00:00
|
|
|
|
if tt.expected == nil {
|
|
|
|
|
|
if f != nil {
|
|
|
|
|
|
t.Fatalf("json.Unmarshal(%s) = %#v, want nil slice", tt.input, f)
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-26 20:40:38 +00:00
|
|
|
|
if len(f) != len(tt.expected) {
|
|
|
|
|
|
t.Fatalf("json.Unmarshal(%s) len = %d, want %d", tt.input, len(f), len(tt.expected))
|
|
|
|
|
|
}
|
|
|
|
|
|
for i, want := range tt.expected {
|
|
|
|
|
|
if f[i] != want {
|
|
|
|
|
|
t.Fatalf("json.Unmarshal(%s)[%d] = %q, want %q", tt.input, i, f[i], want)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestLoadConfig_TelegramPlaceholderTextAcceptsSingleString(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
data := `{
|
|
|
|
|
|
"version": 1,
|
|
|
|
|
|
"agents": { "defaults": { "workspace": "", "model": "", "max_tokens": 0, "max_tool_iterations": 0 } },
|
|
|
|
|
|
"session": {},
|
|
|
|
|
|
"channels": {
|
|
|
|
|
|
"telegram": {
|
|
|
|
|
|
"enabled": true,
|
|
|
|
|
|
"bot_token": "",
|
|
|
|
|
|
"allow_from": [],
|
|
|
|
|
|
"placeholder": {
|
|
|
|
|
|
"enabled": true,
|
|
|
|
|
|
"text": "Thinking..."
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
"model_list": [],
|
|
|
|
|
|
"gateway": {},
|
|
|
|
|
|
"tools": {},
|
|
|
|
|
|
"heartbeat": {},
|
|
|
|
|
|
"devices": {},
|
|
|
|
|
|
"voice": {}
|
|
|
|
|
|
}`
|
|
|
|
|
|
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("setup: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(cfgPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig() error = %v", err)
|
|
|
|
|
|
}
|
2026-04-11 16:57:26 +00:00
|
|
|
|
bc := cfg.Channels.Get("telegram")
|
|
|
|
|
|
if got := []string(bc.Placeholder.Text); len(got) != 1 || got[0] != "Thinking..." {
|
2026-03-26 20:40:38 +00:00
|
|
|
|
t.Fatalf("placeholder.text = %#v, want [\"Thinking...\"]", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
// TestLoadConfig_WarnsForPlaintextAPIKey verifies that LoadConfig resolves a plaintext
|
2026-04-27 01:45:52 +00:00
|
|
|
|
// api_keys entry into memory but does NOT rewrite the config file. File writes are the sole
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
// responsibility of SaveConfig.
|
|
|
|
|
|
func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
2026-04-27 01:45:52 +00:00
|
|
|
|
const original = `{"version":2,"model_list":[{"model_name":"test","model":"openai/gpt-4","api_keys":["sk-plaintext"]}]}`
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("setup: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
|
|
|
|
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(cfgPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
// In-memory value must be the resolved plaintext.
|
2026-03-21 17:55:00 +00:00
|
|
|
|
if cfg.ModelList[0].APIKey() != "sk-plaintext" {
|
|
|
|
|
|
t.Errorf("in-memory api_key = %q, want %q", cfg.ModelList[0].APIKey(), "sk-plaintext")
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
}
|
2026-03-21 17:55:00 +00:00
|
|
|
|
// The file on disk must remain unchanged — no need upgrade version
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
raw, _ := os.ReadFile(cfgPath)
|
|
|
|
|
|
if string(raw) != original {
|
|
|
|
|
|
t.Errorf("LoadConfig must not modify the config file; got:\n%s", string(raw))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestSaveConfig_EncryptsPlaintextAPIKey verifies that SaveConfig writes enc:// ciphertext
|
|
|
|
|
|
// to disk and that a subsequent LoadConfig decrypts it back to the original plaintext.
|
|
|
|
|
|
func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
|
|
|
|
|
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
|
|
|
|
|
mustSetupSSHKey(t)
|
|
|
|
|
|
|
|
|
|
|
|
cfg := DefaultConfig()
|
2026-03-21 17:55:00 +00:00
|
|
|
|
cfg.ModelList = []*ModelConfig{
|
2026-03-27 16:03:34 +00:00
|
|
|
|
{ModelName: "test", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("")},
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
}
|
2026-03-27 16:03:34 +00:00
|
|
|
|
cfg.ModelList[0].APIKeys[0].Set("sk-plaintext")
|
|
|
|
|
|
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
if err := SaveConfig(cfgPath, cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("SaveConfig: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Disk must contain enc://, not the raw key.
|
2026-03-21 17:55:00 +00:00
|
|
|
|
secPath := filepath.Join(dir, SecurityConfigFile)
|
|
|
|
|
|
raw, _ := os.ReadFile(secPath)
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
if !strings.Contains(string(raw), "enc://") {
|
|
|
|
|
|
t.Errorf("saved file should contain enc://, got:\n%s", string(raw))
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.Contains(string(raw), "sk-plaintext") {
|
|
|
|
|
|
t.Errorf("saved file must not contain the plaintext key")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// A fresh load must decrypt back to the original plaintext.
|
|
|
|
|
|
cfg2, err := LoadConfig(cfgPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig after SaveConfig: %v", err)
|
|
|
|
|
|
}
|
2026-03-21 17:55:00 +00:00
|
|
|
|
if cfg2.ModelList[0].APIKey() != "sk-plaintext" {
|
|
|
|
|
|
t.Errorf("loaded api_key = %q, want %q", cfg2.ModelList[0].APIKey(), "sk-plaintext")
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestLoadConfig_NoSealWithoutPassphrase verifies that api_key values are left
|
|
|
|
|
|
// unchanged when PICOCLAW_KEY_PASSPHRASE is not set.
|
|
|
|
|
|
func TestLoadConfig_NoSealWithoutPassphrase(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}`
|
|
|
|
|
|
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("setup: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
|
|
|
|
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
|
|
|
|
|
|
|
|
|
|
|
if _, err := LoadConfig(cfgPath); err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
raw, _ := os.ReadFile(cfgPath)
|
|
|
|
|
|
if strings.Contains(string(raw), "enc://") {
|
|
|
|
|
|
t.Error("config file must not be modified when no passphrase is set")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestLoadConfig_FileRefNotSealed verifies that file:// api_key references are not
|
|
|
|
|
|
// converted to enc:// values (they are resolved at runtime by the Resolver).
|
|
|
|
|
|
func TestLoadConfig_FileRefNotSealed(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
keyFile := filepath.Join(dir, "openai.key")
|
|
|
|
|
|
if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("setup: %v", err)
|
|
|
|
|
|
}
|
2026-03-21 17:55:00 +00:00
|
|
|
|
data := `{"version":1,"model_list":[{"model_name":"test","model":"openai/gpt-4"}]}`
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("setup: %v", err)
|
|
|
|
|
|
}
|
2026-03-21 17:55:00 +00:00
|
|
|
|
secPath := filepath.Join(dir, SecurityConfigFile)
|
|
|
|
|
|
if err := saveSecurityConfig(
|
|
|
|
|
|
secPath,
|
2026-03-27 16:03:34 +00:00
|
|
|
|
&Config{ModelList: SecureModelList{
|
|
|
|
|
|
&ModelConfig{ModelName: "test", APIKeys: SimpleSecureStrings("file://openai.key")},
|
|
|
|
|
|
}}); err != nil {
|
2026-03-21 17:55:00 +00:00
|
|
|
|
t.Fatalf("saveSecurityConfig: %v", err)
|
|
|
|
|
|
}
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
|
|
|
|
|
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
|
|
|
|
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
|
|
|
|
|
|
|
|
|
|
|
if _, err := LoadConfig(cfgPath); err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 17:55:00 +00:00
|
|
|
|
raw, _ := os.ReadFile(secPath)
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
if !strings.Contains(string(raw), "file://openai.key") {
|
|
|
|
|
|
t.Error("file:// reference should be preserved unchanged in the config file")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.Contains(string(raw), "enc://") {
|
|
|
|
|
|
t.Error("file:// reference must not be converted to enc://")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestSaveConfig_MixedKeys verifies that SaveConfig encrypts only plaintext api_keys
|
|
|
|
|
|
// and leaves already-encrypted (enc://) and file:// entries unchanged.
|
|
|
|
|
|
func TestSaveConfig_MixedKeys(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
|
|
|
|
|
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
|
|
|
|
|
mustSetupSSHKey(t)
|
|
|
|
|
|
|
|
|
|
|
|
// Pre-encrypt one key so we have a genuine enc:// value to put in the config.
|
|
|
|
|
|
if err := SaveConfig(cfgPath, &Config{
|
2026-03-21 17:55:00 +00:00
|
|
|
|
ModelList: []*ModelConfig{
|
2026-03-27 16:03:34 +00:00
|
|
|
|
{ModelName: "pre", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-already-plain")},
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
},
|
|
|
|
|
|
}); err != nil {
|
|
|
|
|
|
t.Fatalf("setup SaveConfig: %v", err)
|
|
|
|
|
|
}
|
2026-03-21 17:55:00 +00:00
|
|
|
|
raw, _ := os.ReadFile(filepath.Join(dir, SecurityConfigFile))
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
// Extract the enc:// value from the saved file.
|
|
|
|
|
|
var tmp struct {
|
2026-03-21 17:55:00 +00:00
|
|
|
|
ModelList map[string]struct {
|
|
|
|
|
|
APIKeys []string `yaml:"api_keys"`
|
|
|
|
|
|
} `yaml:"model_list"`
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
}
|
2026-03-21 17:55:00 +00:00
|
|
|
|
if err := yaml.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 {
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
t.Fatalf("setup: could not parse saved config: %v", err)
|
|
|
|
|
|
}
|
2026-03-21 17:55:00 +00:00
|
|
|
|
alreadyEncrypted := tmp.ModelList["pre:0"].APIKeys[0]
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
if !strings.HasPrefix(alreadyEncrypted, "enc://") {
|
|
|
|
|
|
t.Fatalf("setup: expected enc:// key, got %q", alreadyEncrypted)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Build a config with three models:
|
|
|
|
|
|
// 1. plaintext → must be encrypted by SaveConfig
|
|
|
|
|
|
// 2. enc:// → must be left unchanged (already encrypted)
|
|
|
|
|
|
// 3. file:// → must be left unchanged (file reference)
|
|
|
|
|
|
keyFile := filepath.Join(dir, "api.key")
|
|
|
|
|
|
if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("setup: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
cfg := &Config{
|
2026-03-27 16:03:34 +00:00
|
|
|
|
Version: CurrentVersion,
|
2026-03-21 17:55:00 +00:00
|
|
|
|
ModelList: []*ModelConfig{
|
2026-03-29 11:58:19 +00:00
|
|
|
|
{
|
|
|
|
|
|
ModelName: "plain",
|
|
|
|
|
|
Model: "openai/gpt-4",
|
|
|
|
|
|
APIKeys: SimpleSecureStrings("sk-new-plaintext"),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
ModelName: "enc",
|
|
|
|
|
|
Model: "openai/gpt-4",
|
|
|
|
|
|
APIKeys: SimpleSecureStrings(alreadyEncrypted),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
ModelName: "file",
|
|
|
|
|
|
Model: "openai/gpt-4",
|
|
|
|
|
|
APIKeys: SimpleSecureStrings("file://api.key"),
|
|
|
|
|
|
},
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := SaveConfig(cfgPath, cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("SaveConfig: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 16:03:34 +00:00
|
|
|
|
t.Logf("alreadyEncrypted: %s", alreadyEncrypted)
|
2026-03-21 17:55:00 +00:00
|
|
|
|
raw, _ = os.ReadFile(filepath.Join(dir, SecurityConfigFile))
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
s := string(raw)
|
|
|
|
|
|
|
2026-03-21 17:55:00 +00:00
|
|
|
|
t.Logf("saved file:\n%s", s)
|
|
|
|
|
|
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
// 1. Plaintext must be encrypted.
|
|
|
|
|
|
if strings.Contains(s, "sk-new-plaintext") {
|
|
|
|
|
|
t.Error("plaintext key must not appear in saved file")
|
|
|
|
|
|
}
|
|
|
|
|
|
// 2. The pre-existing enc:// value must still be present (byte-for-byte unchanged).
|
|
|
|
|
|
if !strings.Contains(s, alreadyEncrypted) {
|
|
|
|
|
|
t.Error("pre-existing enc:// entry must be preserved unchanged")
|
|
|
|
|
|
}
|
|
|
|
|
|
// 3. file:// must be preserved.
|
|
|
|
|
|
if !strings.Contains(s, "file://api.key") {
|
|
|
|
|
|
t.Error("file:// reference must be preserved unchanged")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Now load and verify all three decrypt/resolve correctly.
|
|
|
|
|
|
cfg2, err := LoadConfig(cfgPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig after SaveConfig: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
byName := make(map[string]string)
|
|
|
|
|
|
for _, m := range cfg2.ModelList {
|
2026-03-21 17:55:00 +00:00
|
|
|
|
byName[m.ModelName] = m.APIKey()
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
}
|
|
|
|
|
|
if byName["plain"] != "sk-new-plaintext" {
|
|
|
|
|
|
t.Errorf("plain model api_key = %q, want %q", byName["plain"], "sk-new-plaintext")
|
|
|
|
|
|
}
|
|
|
|
|
|
if byName["enc"] != "sk-already-plain" {
|
|
|
|
|
|
t.Errorf("enc model api_key = %q, want %q", byName["enc"], "sk-already-plain")
|
|
|
|
|
|
}
|
|
|
|
|
|
if byName["file"] != "sk-from-file" {
|
|
|
|
|
|
t.Errorf("file model api_key = %q, want %q", byName["file"], "sk-from-file")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestLoadConfig_MixedKeys_NoPassphrase verifies that when PICOCLAW_KEY_PASSPHRASE
|
|
|
|
|
|
// is not set, enc:// entries cause LoadConfig to return an error, while plaintext
|
|
|
|
|
|
// and file:// entries in the same config are not affected.
|
|
|
|
|
|
func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
|
|
|
|
|
|
// First encrypt a key so we have a real enc:// value.
|
|
|
|
|
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
|
|
|
|
|
mustSetupSSHKey(t)
|
|
|
|
|
|
if err := SaveConfig(cfgPath, &Config{
|
2026-03-27 16:03:34 +00:00
|
|
|
|
Version: CurrentVersion,
|
2026-03-21 17:55:00 +00:00
|
|
|
|
ModelList: []*ModelConfig{
|
2026-03-27 16:03:34 +00:00
|
|
|
|
{ModelName: "m", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-secret")},
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
},
|
|
|
|
|
|
}); err != nil {
|
|
|
|
|
|
t.Fatalf("setup SaveConfig: %v", err)
|
|
|
|
|
|
}
|
2026-03-21 17:55:00 +00:00
|
|
|
|
raw, err := LoadConfig(cfgPath)
|
|
|
|
|
|
assert.NoError(t, err)
|
2026-03-27 16:03:34 +00:00
|
|
|
|
encValue := raw.ModelList[0].APIKeys[0].raw
|
2026-03-21 17:55:00 +00:00
|
|
|
|
assert.NotEmpty(t, encValue)
|
|
|
|
|
|
assert.Equal(t, "enc://", encValue[:6])
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
|
|
|
|
|
|
// Write a mixed config: enc:// + plaintext + file://
|
|
|
|
|
|
keyFile := filepath.Join(dir, "api.key")
|
2026-03-21 17:55:00 +00:00
|
|
|
|
if err = os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
t.Fatalf("setup: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
mixed, _ := json.Marshal(map[string]any{
|
|
|
|
|
|
"model_list": []map[string]any{
|
|
|
|
|
|
{"model_name": "enc", "model": "openai/gpt-4", "api_key": encValue},
|
|
|
|
|
|
{"model_name": "plain", "model": "openai/gpt-4", "api_key": "sk-plain"},
|
|
|
|
|
|
{"model_name": "file", "model": "openai/gpt-4", "api_key": "file://api.key"},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
2026-03-21 17:55:00 +00:00
|
|
|
|
if err = os.WriteFile(cfgPath, mixed, 0o600); err != nil {
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
t.Fatalf("setup write: %v", err)
|
|
|
|
|
|
}
|
2026-03-21 17:55:00 +00:00
|
|
|
|
secs, _ := yaml.Marshal(map[string]any{
|
|
|
|
|
|
"model_list": map[string]map[string]any{
|
|
|
|
|
|
"enc:0": {"api_keys": []string{encValue}},
|
|
|
|
|
|
"plain:0": {"api_keys": []string{"sk-plain"}},
|
|
|
|
|
|
"file:0": {"api_keys": []string{"file://api.key"}},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
if err = os.WriteFile(filepath.Join(dir, SecurityConfigFile), secs, 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("security write: %v", err)
|
|
|
|
|
|
}
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
|
|
|
|
|
|
// Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted.
|
|
|
|
|
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
|
|
|
|
|
|
2026-03-27 16:03:34 +00:00
|
|
|
|
cfg2, err := LoadConfig(cfgPath)
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
if err == nil {
|
2026-03-27 16:03:34 +00:00
|
|
|
|
t.Logf("LoadConfig: %#v", cfg2.ModelList)
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(err.Error(), "passphrase required") {
|
|
|
|
|
|
t.Errorf("error should mention passphrase required, got: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestSaveConfig_UsesPassphraseProvider verifies that SaveConfig encrypts plaintext
|
|
|
|
|
|
// api_keys using credential.PassphraseProvider() rather than os.Getenv directly.
|
|
|
|
|
|
// This matters for the launcher, which clears the environment variable and redirects
|
|
|
|
|
|
// PassphraseProvider to an in-memory SecureStore.
|
|
|
|
|
|
func TestSaveConfig_UsesPassphraseProvider(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
|
|
|
|
|
|
// Ensure the env var is empty — passphrase must come from PassphraseProvider only.
|
|
|
|
|
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
|
|
|
|
|
mustSetupSSHKey(t)
|
|
|
|
|
|
|
|
|
|
|
|
// Replace PassphraseProvider with an in-memory function (simulating SecureStore).
|
|
|
|
|
|
const testPassphrase = "provider-passphrase"
|
|
|
|
|
|
orig := credential.PassphraseProvider
|
|
|
|
|
|
credential.PassphraseProvider = func() string { return testPassphrase }
|
|
|
|
|
|
t.Cleanup(func() { credential.PassphraseProvider = orig })
|
|
|
|
|
|
|
|
|
|
|
|
cfg := DefaultConfig()
|
2026-03-21 17:55:00 +00:00
|
|
|
|
cfg.ModelList = []*ModelConfig{
|
2026-03-27 16:03:34 +00:00
|
|
|
|
{ModelName: "test", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-plaintext")},
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
}
|
|
|
|
|
|
if err := SaveConfig(cfgPath, cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("SaveConfig: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 17:55:00 +00:00
|
|
|
|
raw, _ := os.ReadFile(filepath.Join(dir, SecurityConfigFile))
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
if !strings.Contains(string(raw), "enc://") {
|
2026-03-29 11:58:19 +00:00
|
|
|
|
t.Errorf(
|
|
|
|
|
|
"SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\n%s",
|
|
|
|
|
|
raw,
|
|
|
|
|
|
)
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestLoadConfig_UsesPassphraseProvider verifies that LoadConfig decrypts enc:// keys
|
|
|
|
|
|
// using credential.PassphraseProvider() rather than os.Getenv directly.
|
|
|
|
|
|
func TestLoadConfig_UsesPassphraseProvider(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
|
|
|
|
|
|
// Ensure the env var is empty throughout.
|
|
|
|
|
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
|
|
|
|
|
mustSetupSSHKey(t)
|
|
|
|
|
|
|
|
|
|
|
|
const testPassphrase = "provider-passphrase"
|
|
|
|
|
|
const plainKey = "sk-secret"
|
|
|
|
|
|
|
|
|
|
|
|
// First, encrypt the key using the same passphrase.
|
|
|
|
|
|
encrypted, err := credential.Encrypt(testPassphrase, "", plainKey)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Encrypt: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
raw, _ := json.Marshal(map[string]any{
|
|
|
|
|
|
"model_list": []map[string]any{
|
|
|
|
|
|
{"model_name": "test", "model": "openai/gpt-4", "api_key": encrypted},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
if err = os.WriteFile(cfgPath, raw, 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("setup: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Redirect PassphraseProvider — env var is empty, so without this the load would fail.
|
|
|
|
|
|
orig := credential.PassphraseProvider
|
|
|
|
|
|
credential.PassphraseProvider = func() string { return testPassphrase }
|
|
|
|
|
|
t.Cleanup(func() { credential.PassphraseProvider = orig })
|
|
|
|
|
|
|
2026-03-27 16:03:34 +00:00
|
|
|
|
t.Logf("cfgPath: %s", cfgPath)
|
|
|
|
|
|
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
cfg, err := LoadConfig(cfgPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig: %v", err)
|
|
|
|
|
|
}
|
2026-03-21 17:55:00 +00:00
|
|
|
|
if cfg.ModelList[0].APIKey() != plainKey {
|
|
|
|
|
|
t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey(), plainKey)
|
feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521)
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen
- pkg/credential: new package with AES-256-GCM enc:// credential format,
HKDF-SHA256 key derivation (passphrase + optional SSH key binding),
ErrPassphraseRequired / ErrDecryptionFailed sentinel errors,
and PassphraseProvider hook for runtime passphrase injection
- pkg/credential/store: lock-free SecureStore via atomic.Pointer[string];
passphrase never written to disk or os.Environ
- pkg/credential/keygen: ed25519 SSH key generation helper used by onboard
- pkg/config: replace os.Getenv(PassphraseEnvVar) with
credential.PassphraseProvider() at all three call sites so that
LoadConfig and SaveConfig use whatever passphrase source is active
- cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate
picoclaw-specific SSH key, re-encrypt existing config on re-onboard
- docs/credential_encryption.md: design doc for the enc:// format
* fix(credential): address Copilot review comments on PR #1521
- credential.go: decouple ErrPassphraseRequired from env var name;
message is now 'enc:// passphrase required' since PassphraseProvider
may come from any source, not just os.Environ
- credential.go: Resolver resolves symlinks via EvalSymlinks before the
isWithinDir containment check, preventing symlink-based path traversal
for file:// credential references
- store.go: tighten comment to describe only what SecureStore guarantees
(in-memory only); remove claims about how callers transport the value
- store_test.go: replace the meaningless GetReturnsCopy test (Go strings
are immutable, equality across two calls proves nothing) with
TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under
10-goroutine concurrent Set/Get load
- config_test.go: update error-message assertion to match new sentinel text
- docs/credential_encryption.md: remove reference to non-existent
'picoclaw encrypt' subcommand; describe the onboard flow instead
* fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte
* fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
* lint: fix credential keygen lint, fix test keygen
* onboard: make encryption opt-in via --enc flag
Encryption (passphrase prompt + SSH key generation) is now only
triggered when the user passes --enc to 'picoclaw onboard'.
Without the flag, onboard skips the credential-encryption setup and
writes a plain config + workspace templates directly.
- Add --enc BoolFlag in NewOnboardCommand()
- Pass encrypt bool into onboard()
- Guard passphrase prompt, SSH key generation, and related env-var
setup behind the encrypt branch
- Adjust 'Next steps' output so the passphrase reminder only appears
when --enc was used
2026-03-16 06:06:32 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-21 05:18:25 +00:00
|
|
|
|
|
|
|
|
|
|
func TestConfigParsesLogLevel(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
2026-03-23 06:50:33 +00:00
|
|
|
|
data := `{"version":1,"gateway":{"log_level":"debug"}}`
|
2026-03-21 05:18:25 +00:00
|
|
|
|
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("setup: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(cfgPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig: %v", err)
|
|
|
|
|
|
}
|
2026-03-23 05:14:53 +00:00
|
|
|
|
if cfg.Gateway.LogLevel != "debug" {
|
|
|
|
|
|
t.Errorf("LogLevel = %q, want \"debug\"", cfg.Gateway.LogLevel)
|
2026-03-21 05:18:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestConfigLogLevelEmpty(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
2026-03-23 06:50:33 +00:00
|
|
|
|
data := `{"version":1}`
|
2026-03-21 05:18:25 +00:00
|
|
|
|
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("setup: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(cfgPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig: %v", err)
|
|
|
|
|
|
}
|
2026-03-21 06:53:05 +00:00
|
|
|
|
// When config omits log_level, the DefaultConfig value ("fatal") is preserved.
|
2026-03-27 13:04:28 +00:00
|
|
|
|
if cfg.Gateway.LogLevel != "warn" {
|
2026-03-23 05:14:53 +00:00
|
|
|
|
t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Gateway.LogLevel)
|
2026-03-21 05:18:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-22 07:49:25 +00:00
|
|
|
|
|
2026-03-31 12:32:42 +00:00
|
|
|
|
func TestResolveGatewayLogLevel(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
data := `{"version":1,"gateway":{"log_level":"debug"}}`
|
|
|
|
|
|
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("setup: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if got := ResolveGatewayLogLevel(cfgPath); got != "debug" {
|
|
|
|
|
|
t.Fatalf("ResolveGatewayLogLevel() = %q, want %q", got, "debug")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestResolveGatewayLogLevel_UsesEnvOverrideAndNormalizesInvalid(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
data := `{"version":1,"gateway":{"log_level":"debug"}}`
|
|
|
|
|
|
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("setup: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
t.Setenv("PICOCLAW_LOG_LEVEL", "warning")
|
|
|
|
|
|
if got := ResolveGatewayLogLevel(cfgPath); got != "warn" {
|
|
|
|
|
|
t.Fatalf("ResolveGatewayLogLevel() with env override = %q, want %q", got, "warn")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
t.Setenv("PICOCLAW_LOG_LEVEL", "garbage")
|
|
|
|
|
|
if got := ResolveGatewayLogLevel(cfgPath); got != DefaultGatewayLogLevel {
|
|
|
|
|
|
t.Fatalf("ResolveGatewayLogLevel() with invalid env override = %q, want %q", got, DefaultGatewayLogLevel)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 07:14:16 +00:00
|
|
|
|
func TestLoadConfig_AppliesLegacyClawHubRegistryEnvOverrides(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
data := `{"version":2,"tools":{"skills":{"registries":{"clawhub":{"enabled":true,"base_url":"https://clawhub.ai"}}}}}`
|
|
|
|
|
|
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("setup: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
t.Setenv(envSkillsClawHubBaseURL, "https://clawhub.example.com")
|
|
|
|
|
|
t.Setenv(envSkillsClawHubAuthToken, "clawhub-token-from-env")
|
|
|
|
|
|
t.Setenv(envSkillsClawHubEnabled, "false")
|
|
|
|
|
|
t.Setenv(envSkillsClawHubSearchPath, "/custom/search")
|
|
|
|
|
|
t.Setenv(envSkillsClawHubDownloadPath, "/custom/download")
|
|
|
|
|
|
t.Setenv(envSkillsClawHubTimeout, "17")
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(cfgPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
clawhub, ok := cfg.Tools.Skills.Registries.Get("clawhub")
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatal("clawhub registry missing")
|
|
|
|
|
|
}
|
|
|
|
|
|
if clawhub.BaseURL != "https://clawhub.example.com" {
|
|
|
|
|
|
t.Fatalf("BaseURL = %q, want %q", clawhub.BaseURL, "https://clawhub.example.com")
|
|
|
|
|
|
}
|
|
|
|
|
|
if clawhub.AuthToken.String() != "clawhub-token-from-env" {
|
|
|
|
|
|
t.Fatalf("AuthToken = %q, want %q", clawhub.AuthToken.String(), "clawhub-token-from-env")
|
|
|
|
|
|
}
|
|
|
|
|
|
if clawhub.Enabled {
|
|
|
|
|
|
t.Fatal("Enabled = true, want false")
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := clawhub.Param["search_path"]; got != "/custom/search" {
|
|
|
|
|
|
t.Fatalf("search_path = %v, want %q", got, "/custom/search")
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := clawhub.Param["download_path"]; got != "/custom/download" {
|
|
|
|
|
|
t.Fatalf("download_path = %v, want %q", got, "/custom/download")
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := clawhub.Param["timeout"]; got != 17 {
|
|
|
|
|
|
t.Fatalf("timeout = %v, want %d", got, 17)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestLoadConfig_AppliesGitHubRegistryEnvOverrides(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
data := `{"version":2,"tools":{"skills":{"registries":{"github":{"enabled":true,"base_url":"https://github.com"}}}}}`
|
|
|
|
|
|
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("setup: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
t.Setenv(envSkillsGitHubBaseURL, "https://ghe.example.com/git")
|
|
|
|
|
|
t.Setenv(envSkillsGitHubAuthToken, "github-token-from-env")
|
|
|
|
|
|
t.Setenv(envSkillsGitHubEnabled, "false")
|
|
|
|
|
|
t.Setenv(envSkillsGitHubProxy, "http://127.0.0.1:7890")
|
|
|
|
|
|
|
|
|
|
|
|
cfg, err := LoadConfig(cfgPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
github, ok := cfg.Tools.Skills.Registries.Get("github")
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatal("github registry missing")
|
|
|
|
|
|
}
|
|
|
|
|
|
if github.BaseURL != "https://ghe.example.com/git" {
|
|
|
|
|
|
t.Fatalf("BaseURL = %q, want %q", github.BaseURL, "https://ghe.example.com/git")
|
|
|
|
|
|
}
|
|
|
|
|
|
if github.AuthToken.String() != "github-token-from-env" {
|
|
|
|
|
|
t.Fatalf("AuthToken = %q, want %q", github.AuthToken.String(), "github-token-from-env")
|
|
|
|
|
|
}
|
|
|
|
|
|
if github.Enabled {
|
|
|
|
|
|
t.Fatal("Enabled = true, want false")
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := github.Param["proxy"]; got != "http://127.0.0.1:7890" {
|
|
|
|
|
|
t.Fatalf("proxy = %v, want %q", got, "http://127.0.0.1:7890")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 07:49:25 +00:00
|
|
|
|
func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
|
|
|
|
|
|
cfg := &Config{
|
2026-03-27 16:03:34 +00:00
|
|
|
|
Version: CurrentVersion,
|
2026-03-23 07:51:13 +00:00
|
|
|
|
ModelList: []*ModelConfig{
|
2026-03-22 07:49:25 +00:00
|
|
|
|
{
|
|
|
|
|
|
ModelName: "test-model",
|
|
|
|
|
|
Model: "openai/test",
|
2026-03-27 16:03:34 +00:00
|
|
|
|
APIKeys: SimpleSecureStrings("sk-test"),
|
2026-03-22 07:49:25 +00:00
|
|
|
|
ExtraBody: map[string]any{"custom_field": "value", "num_field": 42},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := SaveConfig(cfgPath, cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("SaveConfig error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loaded, err := LoadConfig(cfgPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if loaded.ModelList[0].ExtraBody == nil {
|
|
|
|
|
|
t.Fatal("ExtraBody should not be nil after round-trip")
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := loaded.ModelList[0].ExtraBody["custom_field"]; got != "value" {
|
|
|
|
|
|
t.Errorf("ExtraBody[custom_field] = %v, want value", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := loaded.ModelList[0].ExtraBody["num_field"]; got != float64(42) {
|
|
|
|
|
|
t.Errorf("ExtraBody[num_field] = %v, want 42", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-22 07:49:25 +00:00
|
|
|
|
|
2026-04-07 08:05:21 +00:00
|
|
|
|
func TestModelConfig_CustomHeadersRoundTrip(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
|
|
|
|
|
|
cfg := &Config{
|
|
|
|
|
|
Version: CurrentVersion,
|
|
|
|
|
|
ModelList: []*ModelConfig{
|
|
|
|
|
|
{
|
|
|
|
|
|
ModelName: "test-model",
|
|
|
|
|
|
Model: "openai/test",
|
|
|
|
|
|
APIKeys: SimpleSecureStrings("sk-test"),
|
|
|
|
|
|
CustomHeaders: map[string]string{"X-Source": "coding-plan", "X-Agent": "openclaw"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := SaveConfig(cfgPath, cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("SaveConfig error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loaded, err := LoadConfig(cfgPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if loaded.ModelList[0].CustomHeaders == nil {
|
|
|
|
|
|
t.Fatal("CustomHeaders should not be nil after round-trip")
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := loaded.ModelList[0].CustomHeaders["X-Source"]; got != "coding-plan" {
|
|
|
|
|
|
t.Errorf("CustomHeaders[X-Source] = %q, want coding-plan", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := loaded.ModelList[0].CustomHeaders["X-Agent"]; got != "openclaw" {
|
|
|
|
|
|
t.Errorf("CustomHeaders[X-Agent] = %q, want openclaw", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 19:10:30 +00:00
|
|
|
|
func TestModelConfig_ToolSchemaTransformRoundTrip(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
|
|
|
|
|
|
cfg := &Config{
|
|
|
|
|
|
Version: CurrentVersion,
|
|
|
|
|
|
ModelList: []*ModelConfig{
|
|
|
|
|
|
{
|
|
|
|
|
|
ModelName: "test-model",
|
|
|
|
|
|
Model: "openai/test",
|
|
|
|
|
|
APIKeys: SimpleSecureStrings("sk-test"),
|
|
|
|
|
|
ToolSchemaTransform: "simple",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := SaveConfig(cfgPath, cfg); err != nil {
|
|
|
|
|
|
t.Fatalf("SaveConfig error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loaded, err := LoadConfig(cfgPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("LoadConfig error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if got := loaded.ModelList[0].ToolSchemaTransform; got != "simple" {
|
|
|
|
|
|
t.Fatalf("ToolSchemaTransform = %q, want %q", got, "simple")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 07:49:25 +00:00
|
|
|
|
func TestDefaultConfig_MinimaxExtraBody(t *testing.T) {
|
|
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
|
|
|
|
|
|
|
|
var minimaxCfg *ModelConfig
|
|
|
|
|
|
for i := range cfg.ModelList {
|
2026-04-22 03:28:47 +00:00
|
|
|
|
if cfg.ModelList[i].Provider == "minimax" && cfg.ModelList[i].Model == "MiniMax-M2.5" {
|
2026-03-22 07:49:25 +00:00
|
|
|
|
minimaxCfg = cfg.ModelList[i]
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if minimaxCfg == nil {
|
|
|
|
|
|
t.Fatal("Minimax model not found in ModelList")
|
|
|
|
|
|
}
|
|
|
|
|
|
if minimaxCfg.ExtraBody == nil {
|
|
|
|
|
|
t.Fatal("Minimax ExtraBody should not be nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
if got, ok := minimaxCfg.ExtraBody["reasoning_split"]; !ok || got != true {
|
|
|
|
|
|
t.Fatalf("Minimax ExtraBody[reasoning_split] = %v, want true", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-23 12:55:41 +00:00
|
|
|
|
|
|
|
|
|
|
func TestFilterSensitiveData(t *testing.T) {
|
|
|
|
|
|
// Test with nil security config
|
|
|
|
|
|
cfg := &Config{}
|
|
|
|
|
|
if got := cfg.FilterSensitiveData("hello sk-key123 world"); got != "hello sk-key123 world" {
|
|
|
|
|
|
t.Errorf("nil security: got %q, want original", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Test with empty content
|
|
|
|
|
|
if got := cfg.FilterSensitiveData(""); got != "" {
|
|
|
|
|
|
t.Errorf("empty content: got %q, want empty", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Test short content (less than FilterMinLength=8, should skip filtering)
|
2026-03-27 16:03:34 +00:00
|
|
|
|
cfg.ModelList = SecureModelList{
|
|
|
|
|
|
&ModelConfig{
|
|
|
|
|
|
ModelName: "test",
|
|
|
|
|
|
APIKeys: SimpleSecureStrings("sk-long-key-12345"),
|
|
|
|
|
|
},
|
2026-03-23 12:55:41 +00:00
|
|
|
|
}
|
2026-03-27 16:03:34 +00:00
|
|
|
|
m, err := cfg.GetModelConfig("test")
|
|
|
|
|
|
assert.NoError(t, err)
|
|
|
|
|
|
m.APIKeys = SimpleSecureStrings("sk-long-key-12345")
|
2026-03-23 12:55:41 +00:00
|
|
|
|
cfg.Tools.FilterSensitiveData = true
|
|
|
|
|
|
cfg.Tools.FilterMinLength = 8
|
|
|
|
|
|
|
|
|
|
|
|
// Debug: check if sensitive values are collected
|
2026-03-27 16:03:34 +00:00
|
|
|
|
values := cfg.collectSensitiveValues()
|
2026-03-23 12:55:41 +00:00
|
|
|
|
t.Logf("collected %d sensitive values: %v", len(values), values)
|
|
|
|
|
|
|
|
|
|
|
|
if got := cfg.FilterSensitiveData("sk-key"); got != "sk-key" {
|
|
|
|
|
|
t.Errorf("short content should not be filtered: got %q", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Test filtering works
|
|
|
|
|
|
content := "Your API key is sk-long-key-12345 and token abc123"
|
|
|
|
|
|
// abc123 is not in sensitive values, only sk-long-key-12345 should be filtered
|
|
|
|
|
|
expected := "Your API key is [FILTERED] and token abc123"
|
|
|
|
|
|
if got := cfg.FilterSensitiveData(content); got != expected {
|
|
|
|
|
|
t.Errorf("filtering failed: got %q, want %q", got, expected)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Test disabled filtering
|
|
|
|
|
|
cfg.Tools.FilterSensitiveData = false
|
|
|
|
|
|
if got := cfg.FilterSensitiveData(content); got != content {
|
|
|
|
|
|
t.Errorf("disabled filtering: got %q, want original %q", got, content)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestFilterSensitiveData_MultipleKeys(t *testing.T) {
|
|
|
|
|
|
cfg := &Config{
|
|
|
|
|
|
Tools: ToolsConfig{
|
|
|
|
|
|
FilterSensitiveData: true,
|
|
|
|
|
|
FilterMinLength: 8,
|
|
|
|
|
|
},
|
2026-03-27 16:03:34 +00:00
|
|
|
|
ModelList: SecureModelList{
|
|
|
|
|
|
&ModelConfig{
|
|
|
|
|
|
ModelName: "model1",
|
|
|
|
|
|
Model: "openai/model1",
|
|
|
|
|
|
APIKeys: SecureStrings{NewSecureString("key-one"), NewSecureString("key-two")},
|
|
|
|
|
|
},
|
|
|
|
|
|
&ModelConfig{
|
|
|
|
|
|
ModelName: "model2",
|
|
|
|
|
|
Model: "openai/model2",
|
|
|
|
|
|
APIKeys: SecureStrings{NewSecureString("key-three")},
|
|
|
|
|
|
},
|
2026-03-23 12:55:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
content := "key-one and key-two and key-three should be filtered"
|
|
|
|
|
|
expected := "[FILTERED] and [FILTERED] and [FILTERED] should be filtered"
|
|
|
|
|
|
if got := cfg.FilterSensitiveData(content); got != expected {
|
|
|
|
|
|
t.Errorf("multiple keys: got %q, want %q", got, expected)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestFilterSensitiveData_AllTokenTypes(t *testing.T) {
|
|
|
|
|
|
cfg := &Config{
|
|
|
|
|
|
// Model API keys
|
2026-03-27 16:03:34 +00:00
|
|
|
|
ModelList: SecureModelList{
|
|
|
|
|
|
&ModelConfig{
|
|
|
|
|
|
ModelName: "test-model",
|
|
|
|
|
|
APIKeys: SecureStrings{NewSecureString("sk-model-key-12345")},
|
|
|
|
|
|
},
|
2026-03-23 12:55:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
// Channel tokens
|
2026-04-11 16:57:26 +00:00
|
|
|
|
Channels: testChannelsConfigWithTokens(),
|
2026-03-27 16:03:34 +00:00
|
|
|
|
Tools: ToolsConfig{
|
|
|
|
|
|
FilterSensitiveData: true,
|
|
|
|
|
|
FilterMinLength: 8,
|
|
|
|
|
|
// Web tool API keys
|
|
|
|
|
|
Web: WebToolsConfig{
|
2026-03-29 11:58:19 +00:00
|
|
|
|
Brave: BraveConfig{APIKeys: SecureStrings{NewSecureString("brave-api-key")}},
|
|
|
|
|
|
Tavily: TavilyConfig{
|
|
|
|
|
|
APIKeys: SecureStrings{NewSecureString("tavily-api-key")},
|
|
|
|
|
|
},
|
|
|
|
|
|
Perplexity: PerplexityConfig{
|
|
|
|
|
|
APIKeys: SecureStrings{NewSecureString("perplexity-api-key")},
|
|
|
|
|
|
},
|
2026-03-27 16:03:34 +00:00
|
|
|
|
GLMSearch: GLMSearchConfig{APIKey: *NewSecureString("glm-search-key")},
|
|
|
|
|
|
BaiduSearch: BaiduSearchConfig{APIKey: *NewSecureString("baidu-search-key")},
|
|
|
|
|
|
},
|
|
|
|
|
|
// Skills tokens
|
|
|
|
|
|
Skills: SkillsToolsConfig{
|
|
|
|
|
|
Github: SkillsGithubConfig{Token: *NewSecureString("github-token-xyz")},
|
|
|
|
|
|
Registries: SkillsRegistriesConfig{
|
2026-04-14 07:14:16 +00:00
|
|
|
|
&SkillRegistryConfig{Name: "clawhub", AuthToken: *NewSecureString("clawhub-auth-token")},
|
2026-03-27 16:03:34 +00:00
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-03-23 12:55:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
content string
|
|
|
|
|
|
want string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "model_api_key",
|
|
|
|
|
|
content: "Using model with key sk-model-key-12345",
|
|
|
|
|
|
want: "Using model with key [FILTERED]",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "telegram_token",
|
|
|
|
|
|
content: "Telegram token: telegram-bot-token-abcdef",
|
|
|
|
|
|
want: "Telegram token: [FILTERED]",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "discord_token",
|
|
|
|
|
|
content: "Discord token: discord-bot-token-xyz789",
|
|
|
|
|
|
want: "Discord token: [FILTERED]",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "slack_tokens",
|
|
|
|
|
|
content: "Slack bot: xoxb-slack-bot-token, app: xapp-slack-app-token",
|
|
|
|
|
|
want: "Slack bot: [FILTERED], app: [FILTERED]",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "matrix_token",
|
|
|
|
|
|
content: "Matrix access token: matrix-access-token-abc",
|
|
|
|
|
|
want: "Matrix access token: [FILTERED]",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "brave_api_key",
|
|
|
|
|
|
content: "Brave key: brave-api-key",
|
|
|
|
|
|
want: "Brave key: [FILTERED]",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "tavily_api_key",
|
|
|
|
|
|
content: "Tavily key: tavily-api-key",
|
|
|
|
|
|
want: "Tavily key: [FILTERED]",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "github_token",
|
|
|
|
|
|
content: "GitHub token: github-token-xyz",
|
|
|
|
|
|
want: "GitHub token: [FILTERED]",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "irc_passwords",
|
|
|
|
|
|
content: "IRC password: irc-password, nickserv: nickserv-pass",
|
|
|
|
|
|
want: "IRC password: [FILTERED], nickserv: [FILTERED]",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "mixed_content",
|
|
|
|
|
|
content: "Model key sk-model-key-12345 and Telegram token telegram-bot-token-abcdef",
|
|
|
|
|
|
want: "Model key [FILTERED] and Telegram token [FILTERED]",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "short_key_not_filtered",
|
|
|
|
|
|
content: "Key abc not filtered because length < 8",
|
|
|
|
|
|
want: "Key abc not filtered because length < 8",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
|
if got := cfg.FilterSensitiveData(tt.content); got != tt.want {
|
|
|
|
|
|
t.Errorf("got %q, want %q", got, tt.want)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-30 06:01:20 +00:00
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-18 05:01:39 +00:00
|
|
|
|
// MakeBackup tests
|
2026-03-30 06:01:20 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
// TestMakeBackup_WithDateSuffix verifies backup files include a date suffix.
|
|
|
|
|
|
func TestMakeBackup_WithDateSuffix(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
if err := os.WriteFile(configPath, []byte(`{"version":2}`), 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteFile: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 05:01:39 +00:00
|
|
|
|
if err := MakeBackup(configPath); err != nil {
|
|
|
|
|
|
t.Fatalf("MakeBackup: %v", err)
|
2026-03-30 06:01:20 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ReadDir: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var hasDatedBackup bool
|
|
|
|
|
|
for _, e := range entries {
|
|
|
|
|
|
if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched {
|
|
|
|
|
|
hasDatedBackup = true
|
|
|
|
|
|
// Verify backup content matches original
|
|
|
|
|
|
bakPath := filepath.Join(dir, e.Name())
|
|
|
|
|
|
data, err := os.ReadFile(bakPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ReadFile backup: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if string(data) != `{"version":2}` {
|
|
|
|
|
|
t.Errorf("backup content = %q, want original content", string(data))
|
|
|
|
|
|
}
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !hasDatedBackup {
|
|
|
|
|
|
t.Error("expected backup file with date suffix pattern config.json.20*.bak")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestMakeBackup_AlsoBacksSecurityFile verifies that the security config file
|
|
|
|
|
|
// is also backed up with the same date suffix.
|
|
|
|
|
|
func TestMakeBackup_AlsoBacksSecurityFile(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
secPath := securityPath(configPath)
|
|
|
|
|
|
|
|
|
|
|
|
os.WriteFile(configPath, []byte(`{"version":2}`), 0o600)
|
|
|
|
|
|
os.WriteFile(secPath, []byte(`model_list:\n test:0:\n api_keys:\n - "sk-test"\n`), 0o600)
|
|
|
|
|
|
|
2026-05-18 05:01:39 +00:00
|
|
|
|
if err := MakeBackup(configPath); err != nil {
|
|
|
|
|
|
t.Fatalf("MakeBackup: %v", err)
|
2026-03-30 06:01:20 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ReadDir: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
configBackups := 0
|
|
|
|
|
|
secBackups := 0
|
|
|
|
|
|
for _, e := range entries {
|
|
|
|
|
|
if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched {
|
|
|
|
|
|
configBackups++
|
|
|
|
|
|
}
|
|
|
|
|
|
if matched, _ := filepath.Match(".security.yml.20*.bak", e.Name()); matched {
|
|
|
|
|
|
secBackups++
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if configBackups != 1 {
|
|
|
|
|
|
t.Errorf("expected 1 config backup, got %d", configBackups)
|
|
|
|
|
|
}
|
|
|
|
|
|
if secBackups != 1 {
|
|
|
|
|
|
t.Errorf("expected 1 security backup, got %d", secBackups)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 05:01:39 +00:00
|
|
|
|
// TestMakeBackup_NonexistentFileSkipsBackup verifies that MakeBackup returns nil
|
2026-03-30 06:01:20 +00:00
|
|
|
|
// when the config file does not exist (no error, no panic).
|
|
|
|
|
|
func TestMakeBackup_NonexistentFileSkipsBackup(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "nonexistent.json")
|
|
|
|
|
|
|
2026-05-18 05:01:39 +00:00
|
|
|
|
if err := MakeBackup(configPath); err != nil {
|
|
|
|
|
|
t.Fatalf("MakeBackup on nonexistent file should return nil, got: %v", err)
|
2026-03-30 06:01:20 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestMakeBackup_OnlyConfigNoSecurity verifies backup succeeds when only
|
|
|
|
|
|
// the config file exists and no security file.
|
|
|
|
|
|
func TestMakeBackup_OnlyConfigNoSecurity(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
os.WriteFile(configPath, []byte(`{"version":2}`), 0o600)
|
|
|
|
|
|
|
2026-05-18 05:01:39 +00:00
|
|
|
|
if err := MakeBackup(configPath); err != nil {
|
|
|
|
|
|
t.Fatalf("MakeBackup: %v", err)
|
2026-03-30 06:01:20 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
entries, _ := os.ReadDir(dir)
|
|
|
|
|
|
configBackups := 0
|
|
|
|
|
|
secBackups := 0
|
|
|
|
|
|
for _, e := range entries {
|
|
|
|
|
|
if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched {
|
|
|
|
|
|
configBackups++
|
|
|
|
|
|
}
|
|
|
|
|
|
if matched, _ := filepath.Match(".security.yml.20*.bak", e.Name()); matched {
|
|
|
|
|
|
secBackups++
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if configBackups != 1 {
|
|
|
|
|
|
t.Errorf("expected 1 config backup, got %d", configBackups)
|
|
|
|
|
|
}
|
|
|
|
|
|
if secBackups != 0 {
|
|
|
|
|
|
t.Errorf("expected 0 security backups when no security file exists, got %d", secBackups)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestMakeBackup_SameDateSuffix verifies that config and security backups
|
2026-05-18 05:01:39 +00:00
|
|
|
|
// share the same date suffix (they are created in the same MakeBackup call).
|
2026-03-30 06:01:20 +00:00
|
|
|
|
func TestMakeBackup_SameDateSuffix(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
configPath := filepath.Join(dir, "config.json")
|
|
|
|
|
|
secPath := securityPath(configPath)
|
|
|
|
|
|
|
|
|
|
|
|
os.WriteFile(configPath, []byte(`{"version":2}`), 0o600)
|
|
|
|
|
|
os.WriteFile(secPath, []byte(`key: value`), 0o600)
|
|
|
|
|
|
|
2026-05-18 05:01:39 +00:00
|
|
|
|
if err := MakeBackup(configPath); err != nil {
|
|
|
|
|
|
t.Fatalf("MakeBackup: %v", err)
|
2026-03-30 06:01:20 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
entries, _ := os.ReadDir(dir)
|
|
|
|
|
|
var configDate, secDate string
|
|
|
|
|
|
for _, e := range entries {
|
|
|
|
|
|
name := e.Name()
|
|
|
|
|
|
// Extract date part: after the last . before .bak
|
|
|
|
|
|
// e.g. config.json.20260330.bak → 20260330
|
|
|
|
|
|
if strings.HasPrefix(name, "config.json.") && strings.HasSuffix(name, ".bak") {
|
|
|
|
|
|
configDate = strings.TrimPrefix(name, "config.json.")
|
|
|
|
|
|
configDate = strings.TrimSuffix(configDate, ".bak")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.HasPrefix(name, ".security.yml.") && strings.HasSuffix(name, ".bak") {
|
|
|
|
|
|
secDate = strings.TrimPrefix(name, ".security.yml.")
|
|
|
|
|
|
secDate = strings.TrimSuffix(secDate, ".bak")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if configDate == "" {
|
|
|
|
|
|
t.Fatal("config backup file not found")
|
|
|
|
|
|
}
|
|
|
|
|
|
if secDate == "" {
|
|
|
|
|
|
t.Fatal("security backup file not found")
|
|
|
|
|
|
}
|
|
|
|
|
|
if configDate != secDate {
|
|
|
|
|
|
t.Errorf("config backup date = %q, security backup date = %q, should match", configDate, secDate)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-11 16:57:26 +00:00
|
|
|
|
|
|
|
|
|
|
func testChannelsConfigWithTokens() ChannelsConfig {
|
|
|
|
|
|
channels := make(ChannelsConfig)
|
|
|
|
|
|
type chDef struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
cfg any
|
|
|
|
|
|
}
|
|
|
|
|
|
defs := []chDef{
|
|
|
|
|
|
{"telegram", TelegramSettings{Token: *NewSecureString("telegram-bot-token-abcdef")}},
|
|
|
|
|
|
{"discord", DiscordSettings{Token: *NewSecureString("discord-bot-token-xyz789")}},
|
|
|
|
|
|
{
|
|
|
|
|
|
"slack",
|
|
|
|
|
|
SlackSettings{
|
|
|
|
|
|
BotToken: *NewSecureString("xoxb-slack-bot-token"),
|
|
|
|
|
|
AppToken: *NewSecureString("xapp-slack-app-token"),
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{"matrix", MatrixSettings{AccessToken: *NewSecureString("matrix-access-token-abc")}},
|
|
|
|
|
|
{
|
|
|
|
|
|
"feishu",
|
|
|
|
|
|
FeishuSettings{
|
|
|
|
|
|
AppSecret: *NewSecureString("feishu-app-secret-123"),
|
|
|
|
|
|
EncryptKey: *NewSecureString("feishu-encrypt-key"),
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{"dingtalk", DingTalkSettings{ClientSecret: *NewSecureString("dingtalk-client-secret")}},
|
|
|
|
|
|
{"onebot", OneBotSettings{AccessToken: *NewSecureString("onebot-access-token")}},
|
|
|
|
|
|
{"wecom", WeComSettings{Secret: *NewSecureString("wecom-secret")}},
|
|
|
|
|
|
{"pico", PicoSettings{Token: *NewSecureString("pico-token-abc123")}},
|
|
|
|
|
|
{
|
|
|
|
|
|
"irc",
|
|
|
|
|
|
IRCSettings{
|
|
|
|
|
|
Password: *NewSecureString("irc-password"),
|
|
|
|
|
|
NickServPassword: *NewSecureString("nickserv-pass"),
|
|
|
|
|
|
SASLPassword: *NewSecureString("sasl-pass"),
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, def := range defs {
|
|
|
|
|
|
// Create Channel directly with settings to preserve SecureString values
|
|
|
|
|
|
bc := &Channel{Type: def.name}
|
|
|
|
|
|
bc.Decode(def.cfg)
|
|
|
|
|
|
channels[def.name] = bc
|
|
|
|
|
|
}
|
|
|
|
|
|
return channels
|
|
|
|
|
|
}
|