diff --git a/Makefile b/Makefile index 2f673d3b97..6f8b74484e 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ LDFLAGS=-ldflags "-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit # Go variables GO?=CGO_ENABLED=0 go -GOFLAGS?=-v -tags stdjson +GOFLAGS?=-v -tags stdjson -buildvcs=false # Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600). # diff --git a/README.md b/README.md index 16252f83bd..8fc791cdd5 100644 --- a/README.md +++ b/README.md @@ -179,10 +179,16 @@ docker compose -f docker/docker-compose.yml --profile gateway up # 3. Set your API keys vim docker/data/config.json # Set provider API keys, bot tokens, etc. +# Optional: add custom skills for this compose setup +mkdir -p docker/data/workspace/skills/my-skill +# put your SKILL.md at docker/data/workspace/skills/my-skill/SKILL.md + # 4. Start docker compose -f docker/docker-compose.yml --profile gateway up -d ``` +In this compose setup, PicoClaw reads the workspace from `docker/data/workspace` on the host, so local skills must live under `docker/data/workspace/skills`. The repo checkout's `workspace/skills` directory is not mounted into the container. + > [!TIP] > **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`. @@ -808,6 +814,8 @@ For advanced/test setups, you can override the builtin skills root with: export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` +For a concrete example of a skill that executes a local script, see [workspace/skills/python-script/SKILL.md](workspace/skills/python-script/SKILL.md). It shows the recommended pattern for calling an existing `python3` script through the `exec` tool and returning structured output. + ### Unified Command Execution Policy - Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`. diff --git a/cmd/picoclaw/internal/onboard/helpers.go b/cmd/picoclaw/internal/onboard/helpers.go index 4db8bdc8ba..365dcffa5c 100644 --- a/cmd/picoclaw/internal/onboard/helpers.go +++ b/cmd/picoclaw/internal/onboard/helpers.go @@ -8,23 +8,63 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/credential" + "golang.org/x/term" ) func onboard() { configPath := internal.GetConfigPath() + configExists := false if _, err := os.Stat(configPath); err == nil { - fmt.Printf("Config already exists at %s\n", configPath) - fmt.Print("Overwrite? (y/n): ") - var response string - fmt.Scanln(&response) - if response != "y" { - fmt.Println("Aborted.") - return + configExists = true + // Only ask for confirmation when *both* config and SSH key already exist, + // indicating a full re-onboard that would reset the config to defaults. + sshKeyPath, _ := credential.DefaultSSHKeyPath() + if _, err := os.Stat(sshKeyPath); err == nil { + // Both exist — confirm a full reset. + fmt.Printf("Config already exists at %s\n", configPath) + fmt.Print("Overwrite config with defaults? (y/n): ") + var response string + fmt.Scanln(&response) + if response != "y" { + fmt.Println("Aborted.") + return + } + configExists = false // user agreed to reset; treat as fresh } + // Config exists but SSH key is missing — keep existing config, only add SSH key. + } + + fmt.Println("\nSet up credential encryption") + fmt.Println("-----------------------------") + passphrase, err := promptPassphrase() + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + // Expose the passphrase to credential.PassphraseProvider (which calls + // os.Getenv by default) so that SaveConfig can encrypt api_keys. + // This process is a one-shot CLI tool; the env var is never exposed outside + // the current process and disappears when it exits. + os.Setenv(credential.PassphraseEnvVar, passphrase) + + if err := setupSSHKey(); err != nil { + fmt.Printf("Error generating SSH key: %v\n", err) + os.Exit(1) } - cfg := config.DefaultConfig() + var cfg *config.Config + if configExists { + // Preserve the existing config; SaveConfig will re-encrypt api_keys with the new passphrase. + cfg, err = config.LoadConfig(configPath) + if err != nil { + fmt.Printf("Error loading existing config: %v\n", err) + os.Exit(1) + } + } else { + cfg = config.DefaultConfig() + } if err := config.SaveConfig(configPath, cfg); err != nil { fmt.Printf("Error saving config: %v\n", err) os.Exit(1) @@ -33,9 +73,13 @@ func onboard() { workspace := cfg.WorkspacePath() createWorkspaceTemplates(workspace) - fmt.Printf("%s picoclaw is ready!\n", internal.Logo) + fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo) fmt.Println("\nNext steps:") - fmt.Println(" 1. Add your API key to", configPath) + fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:") + fmt.Println(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS") + fmt.Println(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd") + fmt.Println("") + fmt.Println(" 2. Add your API key to", configPath) fmt.Println("") fmt.Println(" Recommended:") fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)") @@ -43,7 +87,62 @@ func onboard() { fmt.Println("") fmt.Println(" See README.md for 17+ supported providers.") fmt.Println("") - fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") + fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"") +} + +// promptPassphrase reads the encryption passphrase twice from the terminal +// (with echo disabled) and returns it. Returns an error if the passphrase is +// empty or if the two inputs do not match. +func promptPassphrase() (string, error) { + fmt.Print("Enter passphrase for credential encryption: ") + p1, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("reading passphrase: %w", err) + } + if len(p1) == 0 { + return "", fmt.Errorf("passphrase must not be empty") + } + + fmt.Print("Confirm passphrase: ") + p2, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("reading passphrase confirmation: %w", err) + } + + if string(p1) != string(p2) { + return "", fmt.Errorf("passphrases do not match") + } + return string(p1), nil +} + +// setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key. +// If the key already exists the user is warned and asked to confirm overwrite. +// Answering anything other than "y" keeps the existing key (not an error). +func setupSSHKey() error { + keyPath, err := credential.DefaultSSHKeyPath() + if err != nil { + return fmt.Errorf("cannot determine SSH key path: %w", err) + } + + if _, err := os.Stat(keyPath); err == nil { + fmt.Printf("\n⚠️ WARNING: %s already exists.\n", keyPath) + fmt.Println(" Overwriting will invalidate any credentials previously encrypted with this key.") + fmt.Print(" Overwrite? (y/n): ") + var response string + fmt.Scanln(&response) + if response != "y" { + fmt.Println("Keeping existing SSH key.") + return nil + } + } + + if err := credential.GenerateSSHKey(keyPath); err != nil { + return err + } + fmt.Printf("SSH key generated: %s\n", keyPath) + return nil } func createWorkspaceTemplates(workspace string) { diff --git a/cmd/picoclaw/internal/skills/helpers.go b/cmd/picoclaw/internal/skills/helpers.go index a59a2013a2..012606bdb6 100644 --- a/cmd/picoclaw/internal/skills/helpers.go +++ b/cmd/picoclaw/internal/skills/helpers.go @@ -22,6 +22,7 @@ func skillsListCmd(loader *skills.SkillsLoader) { if len(allSkills) == 0 { fmt.Println("No skills installed.") + printSkillSearchRoots(loader) return } @@ -35,6 +36,19 @@ func skillsListCmd(loader *skills.SkillsLoader) { } } +func printSkillSearchRoots(loader *skills.SkillsLoader) { + roots := loader.SkillRoots() + if len(roots) == 0 { + return + } + + fmt.Println("Scanned skill roots:") + for i, root := range roots { + fmt.Printf(" %d. %s\n", i+1, root) + } + fmt.Printf("Install local skills under %s//SKILL.md\n", roots[0]) +} + func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error { fmt.Printf("Installing skill from %s...\n", repo) diff --git a/cmd/picoclaw/internal/skills/helpers_test.go b/cmd/picoclaw/internal/skills/helpers_test.go new file mode 100644 index 0000000000..5147623ddf --- /dev/null +++ b/cmd/picoclaw/internal/skills/helpers_test.go @@ -0,0 +1,73 @@ +package skills + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + skillspkg "github.com/sipeed/picoclaw/pkg/skills" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + + fn() + + require.NoError(t, w.Close()) + os.Stdout = oldStdout + + var buf bytes.Buffer + _, err = io.Copy(&buf, r) + require.NoError(t, err) + require.NoError(t, r.Close()) + return buf.String() +} + +func TestSkillsListCmd_EmptyOutputIncludesSearchRoots(t *testing.T) { + tmp := t.TempDir() + workspace := filepath.Join(tmp, "workspace") + global := filepath.Join(tmp, "global") + builtin := filepath.Join(tmp, "builtin") + loader := skillspkg.NewSkillsLoader(workspace, global, builtin) + + output := captureStdout(t, func() { + skillsListCmd(loader) + }) + + assert.Contains(t, output, "No skills installed.") + assert.Contains(t, output, "Scanned skill roots:") + assert.Contains(t, output, filepath.Join(workspace, "skills")) + assert.Contains(t, output, global) + assert.Contains(t, output, builtin) + assert.Contains(t, output, filepath.Join(workspace, "skills", "", "SKILL.md")) +} + +func TestSkillsListCmd_ShowsInstalledSkills(t *testing.T) { + tmp := t.TempDir() + workspace := filepath.Join(tmp, "workspace") + skillDir := filepath.Join(workspace, "skills", "weather") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + + content := "---\nname: weather\ndescription: Weather lookup\n---\n\n# Weather\n" + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + loader := skillspkg.NewSkillsLoader(workspace, filepath.Join(tmp, "global"), filepath.Join(tmp, "builtin")) + output := captureStdout(t, func() { + skillsListCmd(loader) + }) + + assert.Contains(t, output, "Installed Skills:") + assert.Contains(t, output, "weather (workspace)") + assert.Contains(t, output, "Weather lookup") + assert.NotContains(t, output, "Scanned skill roots:") +} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b26cf4199b..b4a48b9c11 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -12,6 +12,9 @@ services: #extra_hosts: # - "host.docker.internal:host-gateway" volumes: + # Persistent PicoClaw home. Custom local skills should be placed under + # ./data/workspace/skills on the host so they appear at + # /root/.picoclaw/workspace/skills inside the container. - ./data:/root/.picoclaw entrypoint: ["picoclaw", "agent"] stdin_open: true @@ -31,6 +34,9 @@ services: #extra_hosts: # - "host.docker.internal:host-gateway" volumes: + # Persistent PicoClaw home. Custom local skills should be placed under + # ./data/workspace/skills on the host so they appear at + # /root/.picoclaw/workspace/skills inside the container. - ./data:/root/.picoclaw # ───────────────────────────────────────────── @@ -49,4 +55,7 @@ services: - "127.0.0.1:18800:18800" - "127.0.0.1:18790:18790" volumes: + # Persistent PicoClaw home. Custom local skills should be placed under + # ./data/workspace/skills on the host so they appear at + # /root/.picoclaw/workspace/skills inside the container. - ./data:/root/.picoclaw diff --git a/docs/credential_encryption.md b/docs/credential_encryption.md new file mode 100644 index 0000000000..448eaaa102 --- /dev/null +++ b/docs/credential_encryption.md @@ -0,0 +1,168 @@ +# Credential Encryption + +PicoClaw supports encrypting `api_key` values in `model_list` configuration entries. +Encrypted keys are stored as `enc://` strings and decrypted automatically at startup. + +--- + +## Quick Start + +**1. Set your passphrase** + +```bash +export PICOCLAW_KEY_PASSPHRASE="your-passphrase" +``` + +**2. Encrypt an API key** + +Run `picoclaw onboard` — it prompts for your passphrase and generates the SSH key, +then automatically re-encrypts any plaintext `api_key` entries in your config on +the next `SaveConfig` call. The resulting `enc://` value will look like: + +``` +enc://AAAA...base64... +``` + +**3. Paste the output into your config** + +```json +{ + "model_list": [ + { + "model_name": "gpt-4o", + "api_key": "enc://AAAA...base64...", + "base_url": "https://api.openai.com/v1" + } + ] +} +``` + +--- + +## Supported `api_key` Formats + +| Format | Example | Behaviour | +|--------|---------|-----------| +| Plaintext | `sk-abc123` | Used as-is | +| File reference | `file://openai.key` | Content read from the same directory as the config file | +| Encrypted | `enc://` | Decrypted at startup using `PICOCLAW_KEY_PASSPHRASE` | +| Empty | `""` | Passed through unchanged (used with `auth_method: oauth`) | + +--- + +## Cryptographic Design + +### Key Derivation + +Encryption uses **HKDF-SHA256** with an optional SSH private key as a second factor. + +``` +Without SSH key (passphrase only): + + ikm = SHA256(passphrase) + aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) + + +With SSH key (recommended): + + sshHash = SHA256(ssh_private_key_file_bytes) + ikm = HMAC-SHA256(key=sshHash, message=passphrase) + aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +``` + +### Encryption + +``` +AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key) +``` + +### Wire Format + +``` +enc:// +``` + +| Field | Size | Description | +|-------|------|-------------| +| `salt` | 16 bytes | Random per encryption; fed into HKDF | +| `nonce` | 12 bytes | Random per encryption; AES-GCM IV | +| `ciphertext` | variable | AES-256-GCM ciphertext + 16-byte authentication tag | + +The GCM authentication tag is appended to the ciphertext automatically. Any tampering causes decryption to fail with an error rather than returning corrupt plaintext. + +### Performance + +| Operation | Time (ARM Cortex-A) | +|-----------|---------------------| +| Key derivation (HKDF) | < 1 ms | +| AES-256-GCM decrypt | < 1 ms | +| **Total startup overhead** | **< 2 ms per key** | + +--- + +## Two-Factor Security with SSH Key + +When a SSH private key is provided, breaking the encryption requires **both**: + +1. The **passphrase** (`PICOCLAW_KEY_PASSPHRASE`) +2. The **SSH private key file** + +This means a leaked config file alone is not sufficient to recover the API key, even if the passphrase is weak. The SSH key contributes 256 bits of entropy (Ed25519) regardless of passphrase strength. + +### Threat Model + +| Attacker Has | Can Decrypt? | +|---|---| +| Config file only | No — needs passphrase + SSH key | +| SSH key only | No — needs passphrase | +| Passphrase only | No — needs SSH key | +| Config file + SSH key + passphrase | Yes — full compromise | + +--- + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `PICOCLAW_KEY_PASSPHRASE` | Yes (for `enc://`) | Passphrase used for key derivation | +| `PICOCLAW_SSH_KEY_PATH` | No | Path to SSH private key. Set to `""` to disable auto-detection and use passphrase-only mode | + +### SSH Key Auto-Detection + +If `PICOCLAW_SSH_KEY_PATH` is not set, PicoClaw looks for the picoclaw-specific key: + +``` +~/.ssh/picoclaw_ed25519.key +``` + +This dedicated file avoids conflicts with the user's existing SSH keys. +Run `picoclaw onboard` to generate it automatically. + +`os.UserHomeDir()` is used for cross-platform home directory resolution (reads `USERPROFILE` on Windows, `HOME` on Unix/macOS). + +To explicitly disable SSH key usage and use passphrase-only mode: + +```bash +export PICOCLAW_SSH_KEY_PATH="" +``` + +--- + +## Migration + +Because the only secret material is `PICOCLAW_KEY_PASSPHRASE` and the SSH private key file, migration is straightforward: + +1. Copy the config file to the new machine. +2. Set `PICOCLAW_KEY_PASSPHRASE` to the same value. +3. Copy the SSH private key file to the same path (or set `PICOCLAW_SSH_KEY_PATH` to its new location). + +No re-encryption is needed. + +--- + +## Security Considerations + +- **Passphrase strength matters in passphrase-only mode.** Without an SSH key, a weak passphrase can be brute-forced offline. Use `PICOCLAW_SSH_KEY_PATH=""` only in environments where no SSH key is available and the passphrase is sufficiently strong (≥ 32 random characters). +- **The SSH key is read-only at runtime.** PicoClaw never writes to or modifies the SSH key file. +- **Plaintext keys remain supported.** Existing configs without `enc://` are unaffected. +- **The `enc://` format is versioned** via the HKDF `info` field (`picoclaw-credential-v1`), allowing future algorithm upgrades without breaking existing encrypted values. diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index f20a56b9c4..5bc7a328e3 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -233,6 +233,7 @@ func registerSharedTools( return registry.CanSpawnSubagent(currentAgentID, targetAgentID) }) agent.Tools.Register(spawnTool) + agent.Tools.Register(tools.NewSpawnStatusTool(subagentManager)) } else { logger.WarnCF("agent", "spawn tool requires subagent to be enabled", nil) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 1903412248..b19684e75d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,14 +1,17 @@ package config import ( + "bytes" "encoding/json" "fmt" "os" + "path/filepath" "strings" "sync/atomic" "github.com/caarlos0/env/v11" + "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/pkg/fileutil" ) @@ -837,10 +840,22 @@ func LoadConfig(path string) (*Config, error) { return nil, err } + if passphrase := credential.PassphraseProvider(); passphrase != "" { + for _, m := range cfg.ModelList { + if m.APIKey != "" && !strings.HasPrefix(m.APIKey, "enc://") && !strings.HasPrefix(m.APIKey, "file://") { + fmt.Fprintf(os.Stderr, "picoclaw: warning: model %q has a plaintext api_key; call SaveConfig to encrypt it\n", m.ModelName) + } + } + } + if err := env.Parse(cfg); err != nil { return nil, err } + if err := resolveAPIKeys(cfg.ModelList, filepath.Dir(path)); err != nil { + return nil, err + } + // Migrate legacy channel config fields to new unified structures cfg.migrateChannelConfigs() @@ -857,6 +872,46 @@ func LoadConfig(path string) (*Config, error) { return cfg, nil } +// encryptPlaintextAPIKeys rewrites plaintext api_key values in raw JSON using +// the entries already parsed into models. Returns the rewritten bytes when at +// least one key was sealed, or nil when nothing changed. +func encryptPlaintextAPIKeys(models []ModelConfig, passphrase string, raw []byte) []byte { + result := raw + changed := false + for _, m := range models { + if m.APIKey == "" || strings.HasPrefix(m.APIKey, "enc://") || strings.HasPrefix(m.APIKey, "file://") { + continue + } + encrypted, err := credential.Encrypt(passphrase, "", m.APIKey) + if err != nil { + fmt.Fprintf(os.Stderr, "picoclaw: warning: cannot seal api_key in config: %v\n", err) + continue + } + oldJSON, _ := json.Marshal(m.APIKey) + newJSON, _ := json.Marshal(encrypted) + result = bytes.Replace(result, oldJSON, newJSON, 1) + changed = true + } + if !changed { + return nil + } + return result +} + +// resolveAPIKeys decrypts or dereferences each api_key in models in-place. +// Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt). +func resolveAPIKeys(models []ModelConfig, configDir string) error { + cr := credential.NewResolver(configDir) + for i := range models { + resolved, err := cr.Resolve(models[i].APIKey) + if err != nil { + return fmt.Errorf("model_list[%d] (%s): %w", i, models[i].ModelName, err) + } + models[i].APIKey = resolved + } + return nil +} + func (c *Config) migrateChannelConfigs() { // Discord: mention_only -> group_trigger.mention_only if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly { @@ -876,7 +931,12 @@ func SaveConfig(path string, cfg *Config) error { return err } - // Use unified atomic write utility with explicit sync for flash storage reliability. + if passphrase := credential.PassphraseProvider(); passphrase != "" { + if sealed := encryptPlaintextAPIKeys(cfg.ModelList, passphrase, data); sealed != nil { + data = sealed + } + } + return fileutil.WriteFileAtomic(path, data, 0o600) } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index c5bdbf3c34..a1cdbadb24 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -7,6 +7,8 @@ import ( "runtime" "strings" "testing" + + "github.com/sipeed/picoclaw/pkg/credential" ) func TestAgentModelConfig_UnmarshalString(t *testing.T) { @@ -482,13 +484,19 @@ func TestDefaultConfig_DMScope(t *testing.T) { } func TestDefaultConfig_WorkspacePath_Default(t *testing.T) { - // Unset to ensure we test the default t.Setenv("PICOCLAW_HOME", "") - // Set a known home for consistent test results - t.Setenv("HOME", "/tmp/home") + + var fakeHome string + if runtime.GOOS == "windows" { + fakeHome = `C:\tmp\home` + t.Setenv("USERPROFILE", fakeHome) + } else { + fakeHome = "/tmp/home" + t.Setenv("HOME", fakeHome) + } cfg := DefaultConfig() - want := filepath.Join("/tmp/home", ".picoclaw", "workspace") + want := filepath.Join(fakeHome, ".picoclaw", "workspace") if cfg.Agents.Defaults.Workspace != want { t.Errorf("Default workspace path = %q, want %q", cfg.Agents.Defaults.Workspace, want) @@ -499,7 +507,7 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) { t.Setenv("PICOCLAW_HOME", "/custom/picoclaw/home") cfg := DefaultConfig() - want := "/custom/picoclaw/home/workspace" + want := filepath.Join("/custom/picoclaw/home", "workspace") if cfg.Agents.Defaults.Workspace != want { t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want) @@ -621,3 +629,338 @@ func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) { } }) } + +// TestLoadConfig_WarnsForPlaintextAPIKey verifies that LoadConfig resolves a plaintext +// api_key into memory but does NOT rewrite the config file. File writes are the sole +// responsibility of SaveConfig. +func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + const original = `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}` + 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. + if cfg.ModelList[0].APIKey != "sk-plaintext" { + t.Errorf("in-memory api_key = %q, want %q", cfg.ModelList[0].APIKey, "sk-plaintext") + } + // The file on disk must remain unchanged — LoadConfig must not write anything. + 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") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + cfg := DefaultConfig() + cfg.ModelList = []ModelConfig{ + {ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"}, + } + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + // Disk must contain enc://, not the raw key. + raw, _ := os.ReadFile(cfgPath) + 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) + } + if cfg2.ModelList[0].APIKey != "sk-plaintext" { + t.Errorf("loaded api_key = %q, want %q", cfg2.ModelList[0].APIKey, "sk-plaintext") + } +} + +// 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) + } + data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"file://openai.key"}]}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-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), "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") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + // Pre-encrypt one key so we have a genuine enc:// value to put in the config. + if err := SaveConfig(cfgPath, &Config{ + ModelList: []ModelConfig{ + {ModelName: "pre", Model: "openai/gpt-4", APIKey: "sk-already-plain"}, + }, + }); err != nil { + t.Fatalf("setup SaveConfig: %v", err) + } + raw, _ := os.ReadFile(cfgPath) + // Extract the enc:// value from the saved file. + var tmp struct { + ModelList []struct { + APIKey string `json:"api_key"` + } `json:"model_list"` + } + if err := json.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 { + t.Fatalf("setup: could not parse saved config: %v", err) + } + alreadyEncrypted := tmp.ModelList[0].APIKey + 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{ + ModelList: []ModelConfig{ + {ModelName: "plain", Model: "openai/gpt-4", APIKey: "sk-new-plaintext"}, + {ModelName: "enc", Model: "openai/gpt-4", APIKey: alreadyEncrypted}, + {ModelName: "file", Model: "openai/gpt-4", APIKey: "file://api.key"}, + }, + } + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + raw, _ = os.ReadFile(cfgPath) + s := string(raw) + + // 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 { + byName[m.ModelName] = m.APIKey + } + 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") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + if err := SaveConfig(cfgPath, &Config{ + ModelList: []ModelConfig{ + {ModelName: "m", Model: "openai/gpt-4", APIKey: "sk-secret"}, + }, + }); err != nil { + t.Fatalf("setup SaveConfig: %v", err) + } + raw, _ := os.ReadFile(cfgPath) + var tmp struct { + ModelList []struct { + APIKey string `json:"api_key"` + } `json:"model_list"` + } + if err := json.Unmarshal(raw, &tmp); err != nil { + t.Fatalf("setup parse: %v", err) + } + encValue := tmp.ModelList[0].APIKey + + // Write a mixed config: enc:// + plaintext + file:// + keyFile := filepath.Join(dir, "api.key") + if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { + 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"}, + }, + }) + if err := os.WriteFile(cfgPath, mixed, 0o600); err != nil { + t.Fatalf("setup write: %v", err) + } + + // Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + + _, err := LoadConfig(cfgPath) + if err == nil { + 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", "") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + // 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() + cfg.ModelList = []ModelConfig{ + {ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"}, + } + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + raw, _ := os.ReadFile(cfgPath) + if !strings.Contains(string(raw), "enc://") { + t.Errorf("SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\n%s", raw) + } +} + +// 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", "") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + 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 }) + + cfg, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.ModelList[0].APIKey != plainKey { + t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey, plainKey) + } +} diff --git a/pkg/credential/credential.go b/pkg/credential/credential.go new file mode 100644 index 0000000000..e584d24159 --- /dev/null +++ b/pkg/credential/credential.go @@ -0,0 +1,334 @@ +// Package credential resolves API credential values for model_list entries. +// +// An API key is a form of authorization credential. This package centralises +// how raw credential strings—plaintext or file references—are resolved into +// their actual values, keeping that logic out of the config loader. +// +// Supported formats for the api_key field: +// +// - Plaintext: "sk-abc123" → returned as-is +// - File ref: "file://filename.key" → content read from configDir/filename.key +// - Encrypted: "enc://" → AES-256-GCM decrypt via PICOCLAW_KEY_PASSPHRASE +// - Empty: "" → returned as-is (auth_method=oauth etc.) +// +// Encryption uses AES-256-GCM with HKDF-SHA256 key derivation (< 1ms, safe for embedded Linux). +// Key derivation: +// +// With SSH key: HKDF-SHA256(ikm=HMAC-SHA256(SHA256(sshKeyBytes), passphrase), salt, info) +// Without: HKDF-SHA256(ikm=SHA256(passphrase), salt, info) +// +// SSH key path resolution priority: +// +// 1. sshKeyPath argument to Encrypt (explicit) +// 2. PICOCLAW_SSH_KEY_PATH env var (set to "" to disable auto-detection) +// 3. ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform) +package credential + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// PassphraseEnvVar is the environment variable that holds the encryption passphrase. +// Other packages (e.g. config) reference this constant to avoid duplicating the string. +const PassphraseEnvVar = "PICOCLAW_KEY_PASSPHRASE" + +// PassphraseProvider is the function used to retrieve the passphrase for enc:// +// credential decryption. It defaults to reading PICOCLAW_KEY_PASSPHRASE from the +// process environment. Replace it at startup to use a different source, such as +// an in-memory SecureStore, so that all LoadConfig() calls everywhere share the +// same passphrase source without needing os.Environ. +// +// Example (launcher main.go): +// +// credential.PassphraseProvider = apiHandler.passphraseStore.Get +var PassphraseProvider func() string = func() string { + return os.Getenv(PassphraseEnvVar) +} + +// ErrPassphraseRequired is returned when an enc:// credential is encountered but +// no passphrase is available from PassphraseProvider. Callers can detect this +// with errors.Is to distinguish a missing-passphrase condition from other errors. +var ErrPassphraseRequired = errors.New("credential: enc:// passphrase required") + +// ErrDecryptionFailed is returned when an enc:// credential cannot be decrypted, +// indicating a wrong passphrase or SSH key. Callers can detect this with errors.Is. +var ErrDecryptionFailed = errors.New("credential: enc:// decryption failed (wrong passphrase or SSH key?)") + +const ( + fileScheme = "file://" + encScheme = "enc://" + hkdfInfo = "picoclaw-credential-v1" + saltLen = 16 + nonceLen = 12 + keyLen = 32 + sshKeyEnv = "PICOCLAW_SSH_KEY_PATH" +) + +// Resolver resolves raw credential strings for model_list api_key fields. +// File references are resolved relative to the directory of the config file. +type Resolver struct { + configDir string + resolvedConfigDir string // symlink-resolved form of configDir +} + +// NewResolver returns a Resolver that resolves file:// references relative to +// configDir (typically filepath.Dir of the config file path). +func NewResolver(configDir string) *Resolver { + resolved := configDir + if configDir != "" { + if real, err := filepath.EvalSymlinks(configDir); err == nil { + resolved = real + } + } + return &Resolver{configDir: configDir, resolvedConfigDir: resolved} +} + +// Resolve returns the actual credential value for raw: +// +// - "" → "" (no error; auth_method=oauth needs no key) +// - "file://name.key" → trimmed content of configDir/name.key +// - anything else → raw unchanged (plaintext credential) +func (r *Resolver) Resolve(raw string) (string, error) { + if raw == "" { + return "", nil + } + + if strings.HasPrefix(raw, fileScheme) { + fileName := strings.TrimSpace(strings.TrimPrefix(raw, fileScheme)) + if fileName == "" { + return "", fmt.Errorf("credential: file:// reference has no filename") + } + + baseDir := r.resolvedConfigDir + if baseDir == "" { + baseDir = r.configDir + } + keyPath := filepath.Join(baseDir, fileName) + // Resolve symlinks before enforcing containment to prevent escaping via symlinks. + realKeyPath, err := filepath.EvalSymlinks(keyPath) + if err != nil { + return "", fmt.Errorf("credential: failed to resolve credential file path %q: %w", keyPath, err) + } + if !isWithinDir(realKeyPath, baseDir) { + return "", fmt.Errorf("credential: file:// path escapes config directory") + } + data, err := os.ReadFile(realKeyPath) + if err != nil { + return "", fmt.Errorf("credential: failed to read credential file %q: %w", realKeyPath, err) + } + + value := strings.TrimSpace(string(data)) + if value == "" { + return "", fmt.Errorf("credential: credential file %q is empty", realKeyPath) + } + + return value, nil + } + + if strings.HasPrefix(raw, encScheme) { + return resolveEncrypted(raw) + } + + // Plaintext credential — return unchanged. + return raw, nil +} + +// resolveEncrypted decrypts an enc:// credential using PassphraseProvider. +func resolveEncrypted(raw string) (string, error) { + passphrase := PassphraseProvider() + if passphrase == "" { + return "", ErrPassphraseRequired + } + + sshKeyPath := pickSSHKeyPath("") // override="": consult env then auto-detect + + b64 := strings.TrimPrefix(raw, encScheme) + blob, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return "", fmt.Errorf("credential: enc:// invalid base64: %w", err) + } + if len(blob) < saltLen+nonceLen+1 { + return "", fmt.Errorf("credential: enc:// payload too short") + } + + salt := blob[:saltLen] + nonce := blob[saltLen : saltLen+nonceLen] + ciphertext := blob[saltLen+nonceLen:] + + key, err := deriveKey(passphrase, sshKeyPath, salt) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("credential: enc:// cipher init: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("credential: enc:// gcm init: %w", err) + } + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrDecryptionFailed, err) + } + return string(plaintext), nil +} + +// Encrypt encrypts plaintext and returns an enc:// credential string. +// +// passphrase is required (PICOCLAW_KEY_PASSPHRASE value). +// sshKeyPath is the SSH private key file to incorporate; pass "" to use +// PICOCLAW_SSH_KEY_PATH env var or ~/.ssh/ auto-detection, or set +// PICOCLAW_SSH_KEY_PATH="" before calling to force passphrase-only mode. +func Encrypt(passphrase, sshKeyPath, plaintext string) (string, error) { + if passphrase == "" { + return "", fmt.Errorf("credential: passphrase must not be empty") + } + sshKeyPath = pickSSHKeyPath(sshKeyPath) + + salt := make([]byte, saltLen) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return "", fmt.Errorf("credential: failed to generate salt: %w", err) + } + + key, err := deriveKey(passphrase, sshKeyPath, salt) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("credential: cipher init: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("credential: gcm init: %w", err) + } + + nonce := make([]byte, nonceLen) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("credential: failed to generate nonce: %w", err) + } + + ciphertext := gcm.Seal(nil, nonce, []byte(plaintext), nil) + blob := make([]byte, 0, saltLen+nonceLen+len(ciphertext)) + blob = append(blob, salt...) + blob = append(blob, nonce...) + blob = append(blob, ciphertext...) + return encScheme + base64.StdEncoding.EncodeToString(blob), nil +} + +// isWithinDir reports whether path is contained within (or equal to) dir. +// Uses filepath.IsLocal on the relative path for robust cross-platform traversal detection. +func isWithinDir(path, dir string) bool { + rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(path)) + return err == nil && filepath.IsLocal(rel) +} + +// allowedSSHKeyPath reports whether path is in a permitted location for SSH key files: +// - exact match with PICOCLAW_SSH_KEY_PATH env var +// - within the PICOCLAW_HOME env var directory +// - within ~/.ssh/ +func allowedSSHKeyPath(path string) bool { + if path == "" { + return true // passphrase-only mode; no file will be read + } + clean := filepath.Clean(path) + + // Exact match with PICOCLAW_SSH_KEY_PATH. + if envPath, ok := os.LookupEnv(sshKeyEnv); ok && envPath != "" { + if clean == filepath.Clean(envPath) { + return true + } + } + + // Within PICOCLAW_HOME. + if picoHome := os.Getenv("PICOCLAW_HOME"); picoHome != "" { + if isWithinDir(clean, picoHome) { + return true + } + } + + // Within ~/.ssh/. + if userHome, err := os.UserHomeDir(); err == nil { + if isWithinDir(clean, filepath.Join(userHome, ".ssh")) { + return true + } + } + + return false +} + +// deriveKey derives a 32-byte AES-256 key from passphrase and optional SSH key. +// +// With SSH key: ikm = HMAC-SHA256(key=SHA256(sshKeyBytes), msg=passphrase) +// Without: ikm = SHA256(passphrase) +// Final key: HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +func deriveKey(passphrase, sshKeyPath string, salt []byte) ([]byte, error) { + var ikm []byte + if sshKeyPath != "" { + if !allowedSSHKeyPath(sshKeyPath) { + return nil, fmt.Errorf("credential: SSH key path %q is not in an allowed location (PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/)", sshKeyPath) + } + sshBytes, err := os.ReadFile(sshKeyPath) + if err != nil { + return nil, fmt.Errorf("credential: cannot read SSH key %q: %w", sshKeyPath, err) + } + sshHash := sha256.Sum256(sshBytes) + mac := hmac.New(sha256.New, sshHash[:]) + mac.Write([]byte(passphrase)) + ikm = mac.Sum(nil) + } else { + h := sha256.Sum256([]byte(passphrase)) + ikm = h[:] + } + + key, err := hkdf.Key(sha256.New, ikm, salt, hkdfInfo, keyLen) + if err != nil { + return nil, fmt.Errorf("credential: HKDF expand failed: %w", err) + } + return key, nil +} + +// pickSSHKeyPath returns the SSH private key path to use for encryption/decryption. +// +// Priority: +// 1. override (non-empty explicit argument) +// 2. PICOCLAW_SSH_KEY_PATH env var — if the variable is set (even to ""), auto-detection +// is skipped; set it to "" to force passphrase-only mode +// 3. ~/.ssh/picoclaw_ed25519.key (auto-detection) +// +// Returns "" when no key is found (passphrase-only mode). +func pickSSHKeyPath(override string) string { + if override != "" { + return override + } + if p, ok := os.LookupEnv(sshKeyEnv); ok { + return p // respect explicit setting, even if "" + } + return findDefaultSSHKey() +} + +// findDefaultSSHKey returns the picoclaw-specific SSH key path if it exists. +func findDefaultSSHKey() string { + p, err := DefaultSSHKeyPath() + if err != nil { + return "" + } + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} diff --git a/pkg/credential/credential_test.go b/pkg/credential/credential_test.go new file mode 100644 index 0000000000..53e4480f40 --- /dev/null +++ b/pkg/credential/credential_test.go @@ -0,0 +1,268 @@ +package credential_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/credential" +) + +func TestResolve_PlainKey(t *testing.T) { + r := credential.NewResolver(t.TempDir()) + got, err := r.Resolve("sk-plaintext-key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sk-plaintext-key" { + t.Fatalf("got %q, want %q", got, "sk-plaintext-key") + } +} + +func TestResolve_FileKey_Success(t *testing.T) { + dir := t.TempDir() + keyFile := "openai_plain.key" + if err := os.WriteFile(filepath.Join(dir, keyFile), []byte("sk-from-file\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + r := credential.NewResolver(dir) + got, err := r.Resolve("file://" + keyFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sk-from-file" { + t.Fatalf("got %q, want %q", got, "sk-from-file") + } +} + +func TestResolve_FileKey_NotFound(t *testing.T) { + r := credential.NewResolver(t.TempDir()) + _, err := r.Resolve("file://missing.key") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestResolve_FileKey_Empty(t *testing.T) { + dir := t.TempDir() + keyFile := "empty.key" + if err := os.WriteFile(filepath.Join(dir, keyFile), []byte(" \n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + r := credential.NewResolver(dir) + _, err := r.Resolve("file://" + keyFile) + if err == nil { + t.Fatal("expected error for empty credential file, got nil") + } +} + +// TestResolve_EncKey_PassphraseOnly tests encryption/decryption with passphrase alone. +// PICOCLAW_SSH_KEY_PATH is set to "" to disable auto-detection and force passphrase-only mode. +func TestResolve_EncKey_PassphraseOnly(t *testing.T) { + const passphrase = "test-passphrase-32bytes-long-ok!" + const plaintext = "sk-encrypted-secret" + + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") // disable SSH key auto-detection + + enc, err := credential.Encrypt(passphrase, "", plaintext) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", passphrase) + + r := credential.NewResolver(t.TempDir()) + got, err := r.Resolve(enc) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != plaintext { + t.Fatalf("got %q, want %q", got, plaintext) + } +} + +// TestResolve_EncKey_WithSSHKey tests that the SSH key file is incorporated into key derivation. +func TestResolve_EncKey_WithSSHKey(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-private-key-material\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + const passphrase = "test-passphrase" + const plaintext = "sk-ssh-protected-secret" + + // Set PICOCLAW_SSH_KEY_PATH before Encrypt so the path passes allowedSSHKeyPath validation. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", passphrase) + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt(passphrase, sshKeyPath, plaintext) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + r := credential.NewResolver(t.TempDir()) + got, err := r.Resolve(enc) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != plaintext { + t.Fatalf("got %q, want %q", got, plaintext) + } +} + +func TestResolve_EncKey_NoPassphrase(t *testing.T) { + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + enc, err := credential.Encrypt("some-passphrase", "", "sk-secret") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + + r := credential.NewResolver(t.TempDir()) + _, err = r.Resolve(enc) + if err == nil { + t.Fatal("expected error when PICOCLAW_KEY_PASSPHRASE is unset, got nil") + } +} + +func TestResolve_EncKey_BadCiphertext(t *testing.T) { + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "some-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + r := credential.NewResolver(t.TempDir()) + _, err := r.Resolve("enc://!!not-valid-base64!!") + if err == nil { + t.Fatal("expected error for invalid enc:// payload, got nil") + } +} + +func TestResolve_EncKey_PayloadTooShort(t *testing.T) { + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "some-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + // Valid base64 but fewer bytes than salt(16)+nonce(12)+1 minimum. + import64 := "dG9vc2hvcnQ=" // "tooshort" = 8 bytes + r := credential.NewResolver(t.TempDir()) + _, err := r.Resolve("enc://" + import64) + if err == nil { + t.Fatal("expected error for too-short enc:// payload, got nil") + } +} + +func TestResolve_EncKey_WrongPassphrase(t *testing.T) { + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + enc, err := credential.Encrypt("correct-passphrase", "", "sk-secret") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "wrong-passphrase") + + r := credential.NewResolver(t.TempDir()) + _, err = r.Resolve(enc) + if err == nil { + t.Fatal("expected decryption error for wrong passphrase, got nil") + } +} + +func TestEncrypt_EmptyPassphrase(t *testing.T) { + _, err := credential.Encrypt("", "", "sk-secret") + if err == nil { + t.Fatal("expected error for empty passphrase, got nil") + } +} + +func TestDeriveKey_SSHKeyNotFound(t *testing.T) { + // Encrypt with a real SSH key path, then try to decrypt with a missing path. + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + // Register the real key path so allowedSSHKeyPath validation passes for Encrypt. + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt("passphrase", sshKeyPath, "sk-secret") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + // Point to a non-existent SSH key so deriveKey's ReadFile fails. + // The path is still under the same dir, so allowedSSHKeyPath passes (exact env match). + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", filepath.Join(dir, "nonexistent_key")) + + r := credential.NewResolver(t.TempDir()) + _, err = r.Resolve(enc) + if err == nil { + t.Fatal("expected error when SSH key file is missing, got nil") + } +} + +// TestResolve_FileRef_PathTraversal verifies that file:// references cannot escape configDir +// via relative traversal ("../../etc/passwd") or absolute paths ("/abs/path"). +func TestResolve_FileRef_PathTraversal(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + // Create a file outside configDir that the traversal would point to. + outsideFile := filepath.Join(t.TempDir(), "secret.key") + if err := os.WriteFile(outsideFile, []byte("stolen"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + r := credential.NewResolver(filepath.Dir(cfgPath)) + + cases := []string{ + "file://../../secret.key", + "file://../secret.key", + "file://" + outsideFile, // absolute path + } + for _, raw := range cases { + _, err := r.Resolve(raw) + if err == nil { + t.Errorf("Resolve(%q): expected path traversal error, got nil", raw) + } + } +} + +// TestResolve_FileRef_withinConfigDir verifies that a legitimate relative file:// ref works. +func TestResolve_FileRef_withinConfigDir(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "my.key"), []byte("sk-valid\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + r := credential.NewResolver(dir) + got, err := r.Resolve("file://my.key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sk-valid" { + t.Fatalf("got %q, want %q", got, "sk-valid") + } +} + +// TestEncrypt_SSHKeyOutsideAllowedDirs verifies that Encrypt rejects SSH key paths +// that are not under PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/. +func TestEncrypt_SSHKeyOutsideAllowedDirs(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + // Make sure none of the allowed env vars point here. + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + t.Setenv("PICOCLAW_HOME", "") + + _, err := credential.Encrypt("passphrase", sshKeyPath, "sk-secret") + if err == nil { + t.Fatal("expected error for SSH key outside allowed directories, got nil") + } +} diff --git a/pkg/credential/keygen.go b/pkg/credential/keygen.go new file mode 100644 index 0000000000..a97bd3f277 --- /dev/null +++ b/pkg/credential/keygen.go @@ -0,0 +1,62 @@ +package credential + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "os" + "path/filepath" + + "golang.org/x/crypto/ssh" +) + +// DefaultSSHKeyPath returns the canonical path for the picoclaw-specific SSH key. +// The path is always ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform). +func DefaultSSHKeyPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("credential: cannot determine home directory: %w", err) + } + return filepath.Join(home, ".ssh", "picoclaw_ed25519.key"), nil +} + +// GenerateSSHKey generates an Ed25519 SSH key pair and writes the private key +// to path (permissions 0600) and the public key to path+".pub" (permissions 0644). +// The ~/.ssh/ directory is created with 0700 if it does not exist. +// If the files already exist they are overwritten. +func GenerateSSHKey(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("credential: keygen: cannot create directory %q: %w", filepath.Dir(path), err) + } + + pubRaw, privRaw, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return fmt.Errorf("credential: keygen: ed25519 key generation failed: %w", err) + } + + // Marshal private key as OpenSSH PEM. + block, err := ssh.MarshalPrivateKey(privRaw, "") + if err != nil { + return fmt.Errorf("credential: keygen: marshal private key: %w", err) + } + privPEM := pem.EncodeToMemory(block) + + if err := os.WriteFile(path, privPEM, 0o600); err != nil { + return fmt.Errorf("credential: keygen: write private key %q: %w", path, err) + } + + // Marshal public key as authorized_keys line. + sshPub, err := ssh.NewPublicKey(pubRaw) + if err != nil { + return fmt.Errorf("credential: keygen: marshal public key: %w", err) + } + pubLine := ssh.MarshalAuthorizedKey(sshPub) + + pubPath := path + ".pub" + if err := os.WriteFile(pubPath, pubLine, 0o644); err != nil { + return fmt.Errorf("credential: keygen: write public key %q: %w", pubPath, err) + } + + return nil +} diff --git a/pkg/credential/keygen_test.go b/pkg/credential/keygen_test.go new file mode 100644 index 0000000000..736b68f35d --- /dev/null +++ b/pkg/credential/keygen_test.go @@ -0,0 +1,109 @@ +package credential + +import ( + "crypto/ed25519" + "os" + "path/filepath" + "runtime" + "testing" + + "golang.org/x/crypto/ssh" +) + +func TestGenerateSSHKey_CreatesFiles(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "test_ed25519.key") + + if err := GenerateSSHKey(keyPath); err != nil { + t.Fatalf("GenerateSSHKey() error = %v", err) + } + + // Private key must exist. + privInfo, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("private key file missing: %v", err) + } + + // Check permissions on non-Windows (Windows does not support Unix permission bits). + if runtime.GOOS != "windows" { + if got := privInfo.Mode().Perm(); got != 0o600 { + t.Errorf("private key permissions = %04o, want 0600", got) + } + } + + // Public key must exist. + pubPath := keyPath + ".pub" + pubInfo, err := os.Stat(pubPath) + if err != nil { + t.Fatalf("public key file missing: %v", err) + } + if runtime.GOOS != "windows" { + if got := pubInfo.Mode().Perm(); got != 0o644 { + t.Errorf("public key permissions = %04o, want 0644", got) + } + } + + // Private key must be parseable as an OpenSSH ed25519 key. + privPEM, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read private key: %v", err) + } + privKey, err := ssh.ParseRawPrivateKey(privPEM) + if err != nil { + t.Fatalf("parse private key: %v", err) + } + if _, ok := privKey.(*ed25519.PrivateKey); !ok { + t.Errorf("private key type = %T, want *ed25519.PrivateKey", privKey) + } + + // Public key must be parseable as authorized_keys line. + pubBytes, err := os.ReadFile(pubPath) + if err != nil { + t.Fatalf("read public key: %v", err) + } + _, _, _, _, err = ssh.ParseAuthorizedKey(pubBytes) + if err != nil { + t.Fatalf("parse public key: %v", err) + } +} + +func TestGenerateSSHKey_OverwritesExisting(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "test_ed25519.key") + + // Generate twice; second call must not error and must produce a different key. + if err := GenerateSSHKey(keyPath); err != nil { + t.Fatalf("first GenerateSSHKey() error = %v", err) + } + first, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read first key: %v", err) + } + + if err := GenerateSSHKey(keyPath); err != nil { + t.Fatalf("second GenerateSSHKey() error = %v", err) + } + second, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read second key: %v", err) + } + + // Two independently generated Ed25519 keys must differ. + if string(first) == string(second) { + t.Error("expected overwritten key to differ from original") + } +} + +func TestGenerateSSHKey_CreatesDirectory(t *testing.T) { + dir := t.TempDir() + // Nested directory that does not yet exist. + keyPath := filepath.Join(dir, "subdir", ".ssh", "picoclaw_ed25519.key") + + if err := GenerateSSHKey(keyPath); err != nil { + t.Fatalf("GenerateSSHKey() error = %v", err) + } + + if _, err := os.Stat(keyPath); err != nil { + t.Fatalf("private key not created: %v", err) + } +} diff --git a/pkg/credential/store.go b/pkg/credential/store.go new file mode 100644 index 0000000000..9c72974b0c --- /dev/null +++ b/pkg/credential/store.go @@ -0,0 +1,44 @@ +package credential + +import "sync/atomic" + +// SecureStore holds a passphrase in memory. +// +// Uses atomic.Pointer so reads and writes are lock-free. +// The passphrase is never written to disk; callers decide how to +// transport it outside this store (e.g., via cmd.Env or os.Environ). +type SecureStore struct { + val atomic.Pointer[string] +} + +// NewSecureStore creates an empty SecureStore. +func NewSecureStore() *SecureStore { + return &SecureStore{} +} + +// SetString stores the passphrase. An empty string clears the store. +func (s *SecureStore) SetString(passphrase string) { + if passphrase == "" { + s.val.Store(nil) + return + } + s.val.Store(&passphrase) +} + +// Get returns the stored passphrase, or "" if not set. +func (s *SecureStore) Get() string { + if p := s.val.Load(); p != nil { + return *p + } + return "" +} + +// IsSet reports whether a passphrase is currently stored. +func (s *SecureStore) IsSet() bool { + return s.val.Load() != nil +} + +// Clear removes the stored passphrase. +func (s *SecureStore) Clear() { + s.val.Store(nil) +} diff --git a/pkg/credential/store_test.go b/pkg/credential/store_test.go new file mode 100644 index 0000000000..63299743a6 --- /dev/null +++ b/pkg/credential/store_test.go @@ -0,0 +1,81 @@ +package credential + +import ( + "sync" + "testing" +) + +func TestSecureStore_SetGet(t *testing.T) { + s := NewSecureStore() + if s.IsSet() { + t.Error("expected empty store") + } + + s.SetString("hunter2") + if !s.IsSet() { + t.Error("expected store to be set") + } + if got := s.Get(); got != "hunter2" { + t.Errorf("Get() = %q, want %q", got, "hunter2") + } +} + +func TestSecureStore_Clear(t *testing.T) { + s := NewSecureStore() + s.SetString("secret") + s.Clear() + + if s.IsSet() { + t.Error("expected store to be empty after Clear()") + } + if got := s.Get(); got != "" { + t.Errorf("Get() after Clear() = %q, want empty", got) + } +} + +func TestSecureStore_SetOverwrites(t *testing.T) { + s := NewSecureStore() + s.SetString("first") + s.SetString("second") + + if got := s.Get(); got != "second" { + t.Errorf("Get() = %q, want %q", got, "second") + } +} + +func TestSecureStore_EmptyPassphrase(t *testing.T) { + s := NewSecureStore() + s.SetString("") // empty → should not mark as set + + if s.IsSet() { + t.Error("empty passphrase should not mark store as set") + } +} + +func TestSecureStore_ConcurrentSetGet(t *testing.T) { + s := NewSecureStore() + const goroutines = 10 + const iterations = 1000 + + var wg sync.WaitGroup + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + if id%2 == 0 { + s.SetString("even") + } else { + s.SetString("odd") + } + _ = s.Get() + } + }(i) + } + wg.Wait() + + final := s.Get() + if final != "" && final != "even" && final != "odd" { + t.Errorf("Get() returned unexpected value %q after concurrent Set/Get", final) + } +} diff --git a/pkg/skills/repo_skill_examples_test.go b/pkg/skills/repo_skill_examples_test.go new file mode 100644 index 0000000000..c2e487c331 --- /dev/null +++ b/pkg/skills/repo_skill_examples_test.go @@ -0,0 +1,24 @@ +package skills + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRepoPythonScriptSkillMetadataIsValid(t *testing.T) { + sl := &SkillsLoader{} + skillPath := filepath.Join("..", "..", "workspace", "skills", "python-script", "SKILL.md") + + meta := sl.getSkillMetadata(skillPath) + require.NotNil(t, meta) + require.Equal(t, "python-script", meta.Name) + require.NotEmpty(t, meta.Description) + + info := SkillInfo{ + Name: meta.Name, + Description: meta.Description, + } + require.NoError(t, info.validate()) +} diff --git a/pkg/tools/spawn_status.go b/pkg/tools/spawn_status.go new file mode 100644 index 0000000000..7a1872eda5 --- /dev/null +++ b/pkg/tools/spawn_status.go @@ -0,0 +1,127 @@ +package tools + +import ( + "context" + "fmt" + "sort" + "strings" + "time" +) + +// SpawnStatusTool reports the status of subagents that were spawned via the +// spawn tool. It can query a specific task by ID, or list every known task with +// a summary count broken-down by status. +type SpawnStatusTool struct { + manager *SubagentManager +} + +// NewSpawnStatusTool creates a SpawnStatusTool backed by the given manager. +func NewSpawnStatusTool(manager *SubagentManager) *SpawnStatusTool { + return &SpawnStatusTool{manager: manager} +} + +func (t *SpawnStatusTool) Name() string { + return "spawn_status" +} + +func (t *SpawnStatusTool) Description() string { + return "Get the status of spawned subagents. " + + "Returns a list of all subagents and their current state " + + "(running, completed, failed, or canceled), or retrieves details " + + "for a specific subagent task when task_id is provided." +} + +func (t *SpawnStatusTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "task_id": map[string]any{ + "type": "string", + "description": "Optional task ID (e.g. \"subagent-1\") to inspect a specific " + + "subagent. When omitted, all known subagents are listed.", + }, + }, + "required": []string{}, + } +} + +func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + if t.manager == nil { + return ErrorResult("Subagent manager not configured") + } + + taskID, _ := args["task_id"].(string) + taskID = strings.TrimSpace(taskID) + + if taskID != "" { + task, ok := t.manager.GetTask(taskID) + if !ok { + return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) + } + return NewToolResult(spawnStatusFormatTask(task)) + } + + tasks := t.manager.ListTasks() + if len(tasks) == 0 { + return NewToolResult("No subagents have been spawned yet.") + } + + // Deterministic ordering: sort by ID string (e.g. "subagent-1" < "subagent-2"). + sort.Slice(tasks, func(i, j int) bool { + return tasks[i].ID < tasks[j].ID + }) + + counts := map[string]int{} + for _, task := range tasks { + counts[task.Status]++ + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Subagent status report (%d total):\n", len(tasks))) + for _, status := range []string{"running", "completed", "failed", "canceled"} { + if n := counts[status]; n > 0 { + label := strings.ToUpper(status[:1]) + status[1:] + ":" + sb.WriteString(fmt.Sprintf(" %-10s %d\n", label, n)) + } + } + sb.WriteString("\n") + + for _, task := range tasks { + sb.WriteString(spawnStatusFormatTask(task)) + sb.WriteString("\n\n") + } + + return NewToolResult(strings.TrimRight(sb.String(), "\n")) +} + +// spawnStatusFormatTask renders a single SubagentTask as a human-readable block. +func spawnStatusFormatTask(task *SubagentTask) string { + var sb strings.Builder + + header := fmt.Sprintf("[%s] status=%s", task.ID, task.Status) + if task.Label != "" { + header += fmt.Sprintf(" label=%q", task.Label) + } + if task.AgentID != "" { + header += fmt.Sprintf(" agent=%s", task.AgentID) + } + if task.Created > 0 { + created := time.UnixMilli(task.Created).UTC().Format("2006-01-02 15:04:05 UTC") + header += fmt.Sprintf(" created=%s", created) + } + sb.WriteString(header) + + if task.Task != "" { + sb.WriteString(fmt.Sprintf("\n task: %s", task.Task)) + } + if task.Result != "" { + result := task.Result + const maxResultLen = 300 + if len(result) > maxResultLen { + result = result[:maxResultLen] + "…" + } + sb.WriteString(fmt.Sprintf("\n result: %s", result)) + } + + return sb.String() +} diff --git a/pkg/tools/spawn_status_test.go b/pkg/tools/spawn_status_test.go new file mode 100644 index 0000000000..491fd39c85 --- /dev/null +++ b/pkg/tools/spawn_status_test.go @@ -0,0 +1,237 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "testing" + "time" +) + +func TestSpawnStatusTool_Name(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + tool := NewSpawnStatusTool(manager) + + if tool.Name() != "spawn_status" { + t.Errorf("Expected name 'spawn_status', got '%s'", tool.Name()) + } +} + +func TestSpawnStatusTool_Description(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + tool := NewSpawnStatusTool(manager) + + desc := tool.Description() + if desc == "" { + t.Error("Description should not be empty") + } + if !strings.Contains(strings.ToLower(desc), "subagent") { + t.Errorf("Description should mention 'subagent', got: %s", desc) + } +} + +func TestSpawnStatusTool_Parameters(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + tool := NewSpawnStatusTool(manager) + + params := tool.Parameters() + if params["type"] != "object" { + t.Errorf("Expected type 'object', got: %v", params["type"]) + } + props, ok := params["properties"].(map[string]any) + if !ok { + t.Fatal("Expected 'properties' to be a map") + } + if _, hasTaskID := props["task_id"]; !hasTaskID { + t.Error("Expected 'task_id' parameter in properties") + } +} + +func TestSpawnStatusTool_NilManager(t *testing.T) { + tool := &SpawnStatusTool{manager: nil} + result := tool.Execute(context.Background(), map[string]any{}) + if !result.IsError { + t.Error("Expected error result when manager is nil") + } +} + +func TestSpawnStatusTool_Empty(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + tool := NewSpawnStatusTool(manager) + + result := tool.Execute(context.Background(), map[string]any{}) + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "No subagents") { + t.Errorf("Expected 'No subagents' message, got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_ListAll(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + now := time.Now().UnixMilli() + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", + Task: "Do task A", + Label: "task-a", + Status: "running", + Created: now, + } + manager.tasks["subagent-2"] = &SubagentTask{ + ID: "subagent-2", + Task: "Do task B", + Label: "task-b", + Status: "completed", + Result: "Done successfully", + Created: now, + } + manager.tasks["subagent-3"] = &SubagentTask{ + ID: "subagent-3", + Task: "Do task C", + Status: "failed", + Result: "Error: something went wrong", + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{}) + + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + + // Summary header + if !strings.Contains(result.ForLLM, "3 total") { + t.Errorf("Expected total count in header, got: %s", result.ForLLM) + } + + // Individual task IDs + for _, id := range []string{"subagent-1", "subagent-2", "subagent-3"} { + if !strings.Contains(result.ForLLM, id) { + t.Errorf("Expected task %s in output, got:\n%s", id, result.ForLLM) + } + } + + // Status values + for _, status := range []string{"running", "completed", "failed"} { + if !strings.Contains(result.ForLLM, status) { + t.Errorf("Expected status '%s' in output, got:\n%s", status, result.ForLLM) + } + } + + // Result content + if !strings.Contains(result.ForLLM, "Done successfully") { + t.Errorf("Expected result text in output, got:\n%s", result.ForLLM) + } +} + +func TestSpawnStatusTool_GetByID(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + manager.mu.Lock() + manager.tasks["subagent-42"] = &SubagentTask{ + ID: "subagent-42", + Task: "Specific task", + Label: "my-task", + Status: "failed", + Result: "Something went wrong", + Created: time.Now().UnixMilli(), + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-42"}) + + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "subagent-42") { + t.Errorf("Expected task ID in output, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "failed") { + t.Errorf("Expected status 'failed' in output, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Something went wrong") { + t.Errorf("Expected result text in output, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "my-task") { + t.Errorf("Expected label in output, got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_GetByID_NotFound(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + tool := NewSpawnStatusTool(manager) + + result := tool.Execute(context.Background(), map[string]any{"task_id": "nonexistent-999"}) + if !result.IsError { + t.Errorf("Expected error for nonexistent task, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "nonexistent-999") { + t.Errorf("Expected task ID in error message, got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_ResultTruncation(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + longResult := strings.Repeat("X", 500) + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", + Task: "Long task", + Status: "completed", + Result: longResult, + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-1"}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + // Output should be shorter than the raw result due to truncation + if len(result.ForLLM) >= len(longResult) { + t.Errorf("Expected result to be truncated, but ForLLM is %d chars", len(result.ForLLM)) + } + if !strings.Contains(result.ForLLM, "…") { + t.Errorf("Expected truncation indicator '…' in output, got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_StatusCounts(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + manager.mu.Lock() + for i, status := range []string{"running", "running", "completed", "failed", "canceled"} { + id := fmt.Sprintf("subagent-%d", i+1) + manager.tasks[id] = &SubagentTask{ID: id, Task: "t", Status: status} + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + // The summary line should mention all statuses that have counts + for _, want := range []string{"Running:", "Completed:", "Failed:", "Canceled:"} { + if !strings.Contains(result.ForLLM, want) { + t.Errorf("Expected %q in summary, got:\n%s", want, result.ForLLM) + } + } +} diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 1813cac926..cfe81d7dd3 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -3,6 +3,7 @@ package api import ( "bufio" "encoding/json" + "errors" "fmt" "io" "log" @@ -18,6 +19,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/web/backend/utils" ) @@ -74,7 +76,15 @@ func (h *Handler) TryAutoStartGateway() { ready, reason, err := h.gatewayStartReady() if err != nil { - log.Printf("Skip auto-starting gateway: %v", err) + if errors.Is(err, credential.ErrPassphraseRequired) { + log.Printf("Skip auto-starting gateway: encrypted credentials require a passphrase. "+ + "Enter it on the Credentials page to unlock.", ) + } else if errors.Is(err, credential.ErrDecryptionFailed) { + log.Printf("Skip auto-starting gateway: failed to decrypt credentials. "+ + "Check the passphrase and SSH key on the Credentials page.") + } else { + log.Printf("Skip auto-starting gateway: %v", err) + } return } if !ready { @@ -91,6 +101,8 @@ func (h *Handler) TryAutoStartGateway() { } // gatewayStartReady validates whether current config can start the gateway. +// LoadConfig uses credential.PassphraseProvider (set to SecureStore.Get at +// startup) so enc:// credentials are resolved correctly without os.Environ. func (h *Handler) gatewayStartReady() (bool, string, error) { cfg, err := config.LoadConfig(h.configPath) if err != nil { @@ -256,7 +268,18 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { execPath := utils.FindPicoclawBinary() cmd := exec.Command(execPath, "gateway") - cmd.Env = os.Environ() + + // Build a clean environment for the child process. + // Start from the launcher's current environment, but explicitly strip + // PICOCLAW_KEY_PASSPHRASE so it cannot leak from the parent env. + // The passphrase is then injected directly from the in-memory SecureStore + // (child-only; never stored in the launcher's own os.Environ). + childEnv := filterEnv(os.Environ(), credential.PassphraseEnvVar) + if passphrase := h.passphraseStore.Get(); passphrase != "" { + childEnv = append(childEnv, credential.PassphraseEnvVar+"="+passphrase) + } + cmd.Env = childEnv + // Forward the launcher's config path via the environment variable that // GetConfigPath() already reads, so the gateway sub-process uses the same // config file without requiring a --config flag on the gateway subcommand. @@ -311,8 +334,9 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { // Wait for exit in background and clean up go func() { - if err := cmd.Wait(); err != nil { - log.Printf("Gateway process exited: %v", err) + exitErr := cmd.Wait() + if exitErr != nil { + log.Printf("Gateway process exited: %v", exitErr) } else { log.Printf("Gateway process exited normally") } @@ -329,6 +353,25 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { } gateway.mu.Unlock() + // If we had an active passphrase attempt and the gateway crashed, + // mark passphrase as failed so the frontend can show an error. + if exitErr != nil { + h.passphraseMu.Lock() + if h.passphraseLastState == passphraseStatePending { + h.passphraseLastState = passphraseStateFailed + // Clear the bad passphrase so user must re-enter + h.passphraseStore.Clear() + } + h.passphraseMu.Unlock() + } else { + // Clean normal exit + h.passphraseMu.Lock() + if h.passphraseLastState == passphraseStatePending { + h.passphraseLastState = passphraseStateNone + } + h.passphraseMu.Unlock() + } + if shouldBroadcastStopped { gateway.events.Broadcast(GatewayEvent{ Status: "stopped", @@ -662,6 +705,13 @@ func (h *Handler) gatewayStatusData() map[string]any { } } + // Expose passphrase state so the frontend can distinguish + // "never entered" vs "wrong passphrase" vs "pending start". + h.passphraseMu.Lock() + ps := h.passphraseLastState + h.passphraseMu.Unlock() + data["passphrase_state"] = string(ps) + return data } @@ -772,3 +822,17 @@ func scanPipe(r io.Reader, buf *LogBuffer) { buf.Append(scanner.Text()) } } + +// filterEnv returns a copy of environ with all entries whose key matches +// the supplied key removed. Used to strip the passphrase from the +// inherited environment before assembling the child-process environ. +func filterEnv(environ []string, key string) []string { + prefix := key + "=" + result := make([]string, 0, len(environ)) + for _, e := range environ { + if !strings.HasPrefix(e, prefix) { + result = append(result, e) + } + } + return result +} diff --git a/web/backend/api/passphrase.go b/web/backend/api/passphrase.go new file mode 100644 index 0000000000..7602b5a77d --- /dev/null +++ b/web/backend/api/passphrase.go @@ -0,0 +1,80 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" +) + +// registerPassphraseRoutes binds the passphrase management endpoints. +func (h *Handler) registerPassphraseRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /api/credential/passphrase", h.handleSetPassphrase) + mux.HandleFunc("GET /api/credential/passphrase/status", h.handlePassphraseStatus) +} + +// handleSetPassphrase stores the supplied passphrase in the in-memory +// SecureStore, then attempts to auto-start the gateway if it is not running. +// +// POST /api/credential/passphrase +// Body: {"passphrase": "..."} +func (h *Handler) handleSetPassphrase(w http.ResponseWriter, r *http.Request) { + var body struct { + Passphrase string `json:"passphrase"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + if body.Passphrase == "" { + http.Error(w, "passphrase must not be empty", http.StatusBadRequest) + return + } + + h.passphraseStore.SetString(body.Passphrase) + + // Mark state as pending before launching gateway + h.passphraseMu.Lock() + h.passphraseLastState = passphraseStatePending + h.passphraseMu.Unlock() + + // Try to start the gateway now that the passphrase is available. + // credential.PassphraseProvider points to passphraseStore.Get, so + // gatewayStartReady() (and all LoadConfig calls) will resolve enc:// + // credentials correctly using the newly stored passphrase. + go func() { + gateway.mu.Lock() + defer gateway.mu.Unlock() + if isGatewayProcessAliveLocked() { + return + } + pid, err := h.startGatewayLocked("starting") + if err != nil { + log.Printf("Failed to start gateway after passphrase unlock: %v", err) + // startGatewayLocked failed before spawning the process, so the exit + // goroutine will never run. Transition pending → failed manually. + h.passphraseMu.Lock() + if h.passphraseLastState == passphraseStatePending { + h.passphraseLastState = passphraseStateFailed + h.passphraseStore.Clear() + } + h.passphraseMu.Unlock() + return + } + log.Printf("Gateway started after passphrase unlock (PID: %d)", pid) + }() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + }) +} + +// handlePassphraseStatus reports whether a passphrase is currently stored. +// +// GET /api/credential/passphrase/status +func (h *Handler) handlePassphraseStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "passphrase_set": h.passphraseStore.IsSet(), + }) +} diff --git a/web/backend/api/router.go b/web/backend/api/router.go index 5f081dee9d..02dc52116e 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -4,9 +4,22 @@ import ( "net/http" "sync" + "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/web/backend/launcherconfig" ) +// passphraseState tracks what happened with the last passphrase attempt. +// "" → no passphrase submitted yet (or just cleared) +// "pending" → passphrase set, gateway starting +// "failed" → gateway exited; passphrase likely wrong +type passphraseState string + +const ( + passphraseStateNone passphraseState = "" + passphraseStatePending passphraseState = "pending" + passphraseStateFailed passphraseState = "failed" +) + // Handler serves HTTP API requests. type Handler struct { configPath string @@ -17,15 +30,19 @@ type Handler struct { oauthMu sync.Mutex oauthFlows map[string]*oauthFlow oauthState map[string]string + passphraseStore *credential.SecureStore + passphraseMu sync.Mutex + passphraseLastState passphraseState } // NewHandler creates an instance of the API handler. func NewHandler(configPath string) *Handler { return &Handler{ - configPath: configPath, - serverPort: launcherconfig.DefaultPort, - oauthFlows: make(map[string]*oauthFlow), - oauthState: make(map[string]string), + configPath: configPath, + serverPort: launcherconfig.DefaultPort, + oauthFlows: make(map[string]*oauthFlow), + oauthState: make(map[string]string), + passphraseStore: credential.NewSecureStore(), } } @@ -37,6 +54,21 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a h.serverCIDRs = append([]string(nil), allowedCIDRs...) } +// SeedPassphrase pre-loads the passphrase into the in-memory SecureStore. +// Call this at startup when the passphrase was supplied via an environment +// variable; after seeding, the caller should clear the env var so it is no +// longer visible in the process environment. +func (h *Handler) SeedPassphrase(passphrase string) { + h.passphraseStore.SetString(passphrase) +} + +// GetPassphrase returns the currently stored passphrase, or "" if not set. +// This satisfies the credential.PassphraseProvider signature so all LoadConfig +// calls in the launcher automatically use the in-memory store. +func (h *Handler) GetPassphrase() string { + return h.passphraseStore.Get() +} + // RegisterRoutes binds all API endpoint handlers to the ServeMux. func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Config CRUD @@ -54,6 +86,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // OAuth login and credential management h.registerOAuthRoutes(mux) + // Passphrase management (in-memory store for encrypted credentials) + h.registerPassphraseRoutes(mux) + // Model list management h.registerModelRoutes(mux) diff --git a/web/backend/dist/.gitkeep b/web/backend/dist/.gitkeep deleted file mode 100644 index 4b533f03aa..0000000000 --- a/web/backend/dist/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# Keep the embedded web backend dist directory in version control. diff --git a/web/backend/main.go b/web/backend/main.go index 650540ea88..11fc1aefc3 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -22,6 +22,7 @@ import ( "strconv" "time" + "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/web/backend/api" "github.com/sipeed/picoclaw/web/backend/launcherconfig" "github.com/sipeed/picoclaw/web/backend/middleware" @@ -115,6 +116,21 @@ func main() { // API Routes (e.g. /api/status) apiHandler := api.NewHandler(absPath) apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) + + // If PICOCLAW_KEY_PASSPHRASE is set in the environment at startup, seed it + // into the in-memory SecureStore and then remove it from the process + // environment so it is no longer visible via /proc//environ or similar. + if envPassphrase := os.Getenv(credential.PassphraseEnvVar); envPassphrase != "" { + apiHandler.SeedPassphrase(envPassphrase) + os.Unsetenv(credential.PassphraseEnvVar) + log.Printf("Seeded passphrase from %s environment variable (env var cleared)", credential.PassphraseEnvVar) + } + + // Point the credential package at the in-memory store so that all + // LoadConfig() calls in the launcher (config API, models API, gateway + // readiness check, etc.) use the same passphrase source. + credential.PassphraseProvider = apiHandler.GetPassphrase + apiHandler.RegisterRoutes(mux) // Frontend Embedded Assets diff --git a/web/frontend/src/api/gateway.ts b/web/frontend/src/api/gateway.ts index 9e02a02b52..ba99dbedcb 100644 --- a/web/frontend/src/api/gateway.ts +++ b/web/frontend/src/api/gateway.ts @@ -4,6 +4,7 @@ interface GatewayStatusResponse { gateway_status: "running" | "starting" | "restarting" | "stopped" | "error" gateway_start_allowed?: boolean gateway_start_reason?: string + passphrase_state?: "" | "pending" | "failed" gateway_restart_required?: boolean pid?: number boot_default_model?: string diff --git a/web/frontend/src/api/passphrase.ts b/web/frontend/src/api/passphrase.ts new file mode 100644 index 0000000000..47115ff780 --- /dev/null +++ b/web/frontend/src/api/passphrase.ts @@ -0,0 +1,30 @@ +// API client for in-memory passphrase management. + +const BASE_URL = "" + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE_URL}${path}`, options) + if (!res.ok) { + const text = await res.text().catch(() => res.statusText) + throw new Error(text || `API error: ${res.status}`) + } + return res.json() as Promise +} + +export interface PassphraseStatusResponse { + passphrase_set: boolean +} + +/** Returns whether a passphrase is currently held in the launcher. */ +export async function getPassphraseStatus(): Promise { + return request("/api/credential/passphrase/status") +} + +/** Stores the passphrase in the launcher's in-memory SecureStore. */ +export async function setPassphrase(passphrase: string): Promise<{ status: string }> { + return request<{ status: string }>("/api/credential/passphrase", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passphrase }), + }) +} diff --git a/web/frontend/src/components/chat/chat-empty-state.tsx b/web/frontend/src/components/chat/chat-empty-state.tsx index 624ff9c590..3387b7cc9a 100644 --- a/web/frontend/src/components/chat/chat-empty-state.tsx +++ b/web/frontend/src/components/chat/chat-empty-state.tsx @@ -1,26 +1,118 @@ import { + IconKey, + IconLoader2, + IconLock, IconPlugConnectedX, IconRobot, IconRobotOff, IconStar, } from "@tabler/icons-react" import { Link } from "@tanstack/react-router" +import { useEffect, useState } from "react" import { useTranslation } from "react-i18next" +import { setPassphrase } from "@/api/passphrase" import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" interface ChatEmptyStateProps { hasConfiguredModels: boolean defaultModelName: string isConnected: boolean + gatewayStartReason?: string + passphraseState?: "" | "pending" | "failed" } export function ChatEmptyState({ hasConfiguredModels, defaultModelName, isConnected, + gatewayStartReason = "", + passphraseState = "", }: ChatEmptyStateProps) { const { t } = useTranslation() + const needsPassphrase = gatewayStartReason.toLowerCase().includes("passphrase") + || passphraseState === "failed" + || passphraseState === "pending" + + const [passphrase, setPassphraseValue] = useState("") + const [saving, setSaving] = useState(false) + const [error, setError] = useState("") + const [saved, setSaved] = useState(false) + + // When backend signals failure, reset the saved flag and show error. + useEffect(() => { + if (passphraseState === "failed") { + setSaved(false) + setError(t("credentials.passphrase.errorWrongPassphrase")) + } + }, [passphraseState, t]) + + async function handleUnlock() { + if (!passphrase.trim()) return + setSaving(true) + setError("") + try { + await setPassphrase(passphrase.trim()) + setSaved(true) + setPassphraseValue("") + } catch { + setError(t("credentials.passphrase.errorSave")) + } finally { + setSaving(false) + } + } + + // Passphrase unlock takes priority — models/config can't load without it + if (!isConnected && needsPassphrase) { + return ( +
+
+ +
+

+ {t("credentials.passphrase.title")} +

+

+ {t("credentials.passphrase.description")} +

+ {saved ? ( +

+ {t("credentials.passphrase.successMessage")} +

+ ) : ( +
+
+ setPassphraseValue(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") void handleUnlock() }} + disabled={saving} + autoFocus + /> + +
+ {error && ( +

{error}

+ )} +
+ )} +
+ ) + } if (!hasConfiguredModels) { return ( @@ -85,3 +177,4 @@ export function ChatEmptyState({ ) } + diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 1906a0367d..cbdcb9777f 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -33,7 +33,7 @@ export function ChatPage() { newChat, } = usePicoChat() - const { state: gwState } = useGateway() + const { state: gwState, startReason, passphraseState } = useGateway() const isConnected = gwState === "running" const { @@ -144,6 +144,8 @@ export function ChatPage() { hasConfiguredModels={hasConfiguredModels} defaultModelName={defaultModelName} isConnected={isConnected} + gatewayStartReason={startReason} + passphraseState={passphraseState} /> )} diff --git a/web/frontend/src/components/credentials/credentials-page.tsx b/web/frontend/src/components/credentials/credentials-page.tsx index 04aceb002e..e37d3f9d95 100644 --- a/web/frontend/src/components/credentials/credentials-page.tsx +++ b/web/frontend/src/components/credentials/credentials-page.tsx @@ -9,6 +9,7 @@ import { AntigravityCredentialCard } from "./antigravity-credential-card" import { DeviceCodeSheet } from "./device-code-sheet" import { LogoutConfirmDialog } from "./logout-confirm-dialog" import { OpenAICredentialCard } from "./openai-credential-card" +import { PassphraseCard } from "./passphrase-card" export function CredentialsPage() { const { t } = useTranslation() @@ -51,6 +52,11 @@ export function CredentialsPage() {

+ {/* Passphrase card is always visible — independent of OAuth loading state */} +
+ +
+ {error && (
{error} diff --git a/web/frontend/src/components/credentials/passphrase-card.tsx b/web/frontend/src/components/credentials/passphrase-card.tsx new file mode 100644 index 0000000000..2657fd56a7 --- /dev/null +++ b/web/frontend/src/components/credentials/passphrase-card.tsx @@ -0,0 +1,114 @@ +import { IconKey, IconLock, IconLockOpen, IconLoader2 } from "@tabler/icons-react" +import { useEffect, useState } from "react" +import { useTranslation } from "react-i18next" + +import { getPassphraseStatus, setPassphrase } from "@/api/passphrase" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +export function PassphraseCard() { + const { t } = useTranslation() + const [value, setValue] = useState("") + const [isSet, setIsSet] = useState(null) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState<{ text: string; error: boolean } | null>(null) + + useEffect(() => { + getPassphraseStatus() + .then((res) => setIsSet(res.passphrase_set)) + .catch(() => setIsSet(false)) + }, []) + + async function handleSave() { + if (!value.trim()) { + setMessage({ text: t("credentials.passphrase.errorEmpty"), error: true }) + return + } + setSaving(true) + setMessage(null) + try { + await setPassphrase(value.trim()) + setIsSet(true) + setValue("") + setMessage({ text: t("credentials.passphrase.successMessage"), error: false }) + } catch { + setMessage({ text: t("credentials.passphrase.errorSave"), error: true }) + } finally { + setSaving(false) + } + } + + return ( +
+
+

+ + + + {t("credentials.passphrase.title")} +

+

+ {t("credentials.passphrase.description")} +

+
+ +
+ {isSet === null ? ( + + ) : isSet ? ( + <> + + + {t("credentials.passphrase.statusSet")} + + + ) : ( + <> + + + {t("credentials.passphrase.statusNotSet")} + + + )} +
+ +
+
+
+
+ setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSave() + }} + type="password" + placeholder={t("credentials.passphrase.placeholder")} + disabled={saving} + /> + +
+ {message && ( +

+ {message.text} +

+ )} +
+
+
+
+
+ ) +} diff --git a/web/frontend/src/hooks/use-gateway.ts b/web/frontend/src/hooks/use-gateway.ts index 848f4d59c4..43ee2fe4e8 100644 --- a/web/frontend/src/hooks/use-gateway.ts +++ b/web/frontend/src/hooks/use-gateway.ts @@ -19,7 +19,7 @@ let sseInitialized = false export function useGateway() { const gateway = useAtomValue(gatewayAtom) - const { status: state, canStart, restartRequired } = gateway + const { status: state, canStart, startReason, passphraseState, restartRequired } = gateway const [loading, setLoading] = useState(false) const applyGatewayStatus = useCallback((data: GatewayStatusResponse) => { @@ -37,6 +37,8 @@ export function useGateway() { updateGatewayStore({ status: "unknown", canStart: true, + startReason: "", + passphraseState: "", restartRequired: false, }) }) @@ -144,5 +146,5 @@ export function useGateway() { } }, [applyGatewayStatus, canStart, restartRequired, state]) - return { state, loading, canStart, restartRequired, start, stop, restart } + return { state, loading, canStart, startReason, passphraseState, restartRequired, start, stop, restart } } diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index b099dec13c..e4b6266df1 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -81,6 +81,19 @@ "credentials": { "description": "Manage OAuth and token-based credentials for supported providers.", "loading": "Loading credentials...", + "passphrase": { + "title": "Encryption Passphrase", + "description": "Required to decrypt enc:// API keys in your config. Stored only in memory — never written to disk.", + "statusSet": "Passphrase loaded", + "statusNotSet": "No passphrase set", + "placeholder": "Enter passphrase", + "save": "Unlock", + "saving": "Unlocking...", + "successMessage": "Passphrase stored. Gateway is starting...", + "errorEmpty": "Passphrase must not be empty.", + "errorSave": "Failed to store passphrase.", + "errorWrongPassphrase": "Wrong passphrase — gateway failed to start. Please try again." + }, "providers": { "openai": { "description": "Supports browser OAuth, device code, and token login." diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 78093e5c7c..fac9eacfa4 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -81,6 +81,19 @@ "credentials": { "description": "管理已支持服务商的 OAuth 与 Token 凭据。", "loading": "正在加载凭据...", + "passphrase": { + "title": "加密密码", + "description": "用于解密配置中的 enc:// API Key,仅存储在内存中,不会写入磁盘。", + "statusSet": "密码已加载", + "statusNotSet": "未设置密码", + "placeholder": "输入密码", + "save": "解锁", + "saving": "解锁中...", + "successMessage": "密码已存储,网关正在启动...", + "errorEmpty": "密码不能为空。", + "errorSave": "存储密码失败。", + "errorWrongPassphrase": "密码错误,网关启动失败,请重试。" + }, "providers": { "openai": { "description": "支持浏览器 OAuth、设备码和 Token 登录。" diff --git a/web/frontend/src/store/gateway.ts b/web/frontend/src/store/gateway.ts index b7655839cf..f3d94a2fcb 100644 --- a/web/frontend/src/store/gateway.ts +++ b/web/frontend/src/store/gateway.ts @@ -13,6 +13,8 @@ export type GatewayState = export interface GatewayStoreState { status: GatewayState canStart: boolean + startReason: string + passphraseState: "" | "pending" | "failed" restartRequired: boolean } @@ -21,6 +23,8 @@ type GatewayStorePatch = Partial const DEFAULT_GATEWAY_STATE: GatewayStoreState = { status: "unknown", canStart: true, + startReason: "", + passphraseState: "", restartRequired: false, } @@ -49,13 +53,19 @@ export function applyGatewayStatusToStore( data: Partial< Pick< GatewayStatusResponse, - "gateway_status" | "gateway_start_allowed" | "gateway_restart_required" + | "gateway_status" + | "gateway_start_allowed" + | "gateway_start_reason" + | "gateway_restart_required" + | "passphrase_state" > >, ) { updateGatewayStore((prev) => ({ status: data.gateway_status ?? prev.status, canStart: data.gateway_start_allowed ?? prev.canStart, + startReason: data.gateway_start_reason ?? prev.startReason, + passphraseState: data.passphrase_state ?? prev.passphraseState, restartRequired: data.gateway_restart_required ?? (data.gateway_status && data.gateway_status !== "running" diff --git a/workspace/skills/python-script/SKILL.md b/workspace/skills/python-script/SKILL.md new file mode 100644 index 0000000000..0823101e7c --- /dev/null +++ b/workspace/skills/python-script/SKILL.md @@ -0,0 +1,48 @@ +--- +name: python-script +description: Run an existing local Python script through the exec tool and return the result clearly. +metadata: {"nanobot":{"emoji":"🐍","requires":{"bins":["python3"]}}} +--- + +# Python Script + +Use this skill when the user wants PicoClaw to run an existing Python script in the workspace or another explicitly allowed path. + +## Rules + +- Prefer `python3`, not `python`. +- Treat the script path and arguments as required inputs. If they are missing, ask for them. +- If the script is unfamiliar, inspect it with `read_file` before executing it. +- Prefer machine-readable output. If the script supports `--json`, use it. +- Do not invent results. Report stdout, stderr, exit code, and any missing dependency errors exactly. +- If the script writes files or updates state, tell the user what changed and where. + +## Command Patterns + +Run a script: +```bash +python3 path/to/script.py +``` + +Run with arguments: +```bash +python3 path/to/script.py --input data.json --limit 10 +``` + +Request JSON output when supported: +```bash +python3 path/to/script.py --json +``` + +Pass temporary environment variables inline: +```bash +FOO=bar python3 path/to/script.py --json +``` + +## Response Pattern + +1. Restate the script path and arguments you will run. +2. Use the `exec` tool to execute the command. +3. Summarize stdout briefly. +4. If stderr is non-empty or the exit code is non-zero, surface that clearly. +5. If stdout is JSON, extract the important fields instead of dumping raw output unless the user asks for the full payload.