Skip to content
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CI

on:
push:
branches:
- main
pull_request:

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Check formatting
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "The following files need gofmt:"
echo "$unformatted"
exit 1
fi

- name: Run tests
run: go test ./...
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"model": "openrouter/auto",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
Expand Down
2 changes: 1 addition & 1 deletion config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"model": "openrouter/auto",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
Expand Down
33 changes: 18 additions & 15 deletions pkg/auth/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,19 @@ package auth
import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
)

func setTestHome(t *testing.T, dir string) {
t.Helper()
t.Setenv("HOME", dir)
t.Setenv("USERPROFILE", dir)
t.Setenv("HOMEDRIVE", "")
t.Setenv("HOMEPATH", "")
}

func TestAuthCredentialIsExpired(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -52,9 +61,7 @@ func TestAuthCredentialNeedsRefresh(t *testing.T) {

func TestStoreRoundtrip(t *testing.T) {
tmpDir := t.TempDir()
origHome := os.Getenv("HOME")
t.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", origHome)
setTestHome(t, tmpDir)

cred := &AuthCredential{
AccessToken: "test-access-token",
Expand Down Expand Up @@ -88,10 +95,12 @@ func TestStoreRoundtrip(t *testing.T) {
}

func TestStoreFilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("file permission bits are not reliable on windows")
}

tmpDir := t.TempDir()
origHome := os.Getenv("HOME")
t.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", origHome)
setTestHome(t, tmpDir)

cred := &AuthCredential{
AccessToken: "secret-token",
Expand All @@ -115,9 +124,7 @@ func TestStoreFilePermissions(t *testing.T) {

func TestStoreMultiProvider(t *testing.T) {
tmpDir := t.TempDir()
origHome := os.Getenv("HOME")
t.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", origHome)
setTestHome(t, tmpDir)

openaiCred := &AuthCredential{AccessToken: "openai-token", Provider: "openai", AuthMethod: "oauth"}
anthropicCred := &AuthCredential{AccessToken: "anthropic-token", Provider: "anthropic", AuthMethod: "token"}
Expand Down Expand Up @@ -148,9 +155,7 @@ func TestStoreMultiProvider(t *testing.T) {

func TestDeleteCredential(t *testing.T) {
tmpDir := t.TempDir()
origHome := os.Getenv("HOME")
t.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", origHome)
setTestHome(t, tmpDir)

cred := &AuthCredential{AccessToken: "to-delete", Provider: "openai", AuthMethod: "oauth"}
if err := SetCredential("openai", cred); err != nil {
Expand All @@ -172,9 +177,7 @@ func TestDeleteCredential(t *testing.T) {

func TestLoadStoreEmpty(t *testing.T) {
tmpDir := t.TempDir()
origHome := os.Getenv("HOME")
t.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", origHome)
setTestHome(t, tmpDir)

store, err := LoadStore()
if err != nil {
Expand Down
20 changes: 17 additions & 3 deletions pkg/channels/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,9 +282,9 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
}

logger.DebugCF("slack", "Received message", map[string]interface{}{
"sender_id": senderID,
"chat_id": chatID,
"preview": utils.Truncate(content, 50),
"sender_id": senderID,
"chat_id": chatID,
"preview": utils.Truncate(content, 50),
"has_thread": threadTS != "",
})

Expand All @@ -295,6 +295,12 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
if ev.User == c.botUserID {
return
}
if !c.IsAllowed(ev.User) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Positive — Allowlist Fix for App Mentions] @jmahotiedu — Correct placement. The IsAllowed() check is placed BEFORE the reaction emoji, pending ack, and HandleMessage() call. This means blocked users get zero feedback — no "eyes" reaction, no processing. This is the right security posture.

Note: BaseChannel.HandleMessage (base.go:86) also has its own IsAllowed() check as defense-in-depth, but this early return avoids unnecessary work (reaction API calls, string processing) for blocked users.

logger.DebugCF("slack", "App mention rejected by allowlist", map[string]interface{}{
"user_id": ev.User,
})
return
}

senderID := ev.User
channelID := ev.Channel
Expand Down Expand Up @@ -346,6 +352,14 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
}

senderID := cmd.UserID
if !c.IsAllowed(senderID) {
logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]interface{}{
"user_id": senderID,
"command": cmd.Command,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Positive — Allowlist Fix for Slash Commands] @jmahotiedu — Same correct pattern as app mentions. The check is early, before any processing or response. The cmd.Command in the debug log is a good touch for audit trails.

})
return
}

channelID := cmd.ChannelID
chatID := channelID
content := cmd.Text
Expand Down
76 changes: 76 additions & 0 deletions pkg/channels/slack_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package channels

import (
"context"
"testing"
"time"

"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/slack-go/slack/socketmode"
)

func TestParseSlackChatID(t *testing.T) {
Expand Down Expand Up @@ -172,3 +177,74 @@ func TestSlackChannelIsAllowed(t *testing.T) {
}
})
}

func TestSlackAppMentionRejectsUsersOutsideAllowlist(t *testing.T) {
msgBus := bus.NewMessageBus()
cfg := config.SlackConfig{
BotToken: "xoxb-test",
AppToken: "xapp-test",
AllowFrom: []string{"U_ALLOWED"},
}

ch, err := NewSlackChannel(cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

ch.botUserID = "U_BOT"
ch.api = nil

ev := &slackevents.AppMentionEvent{
User: "U_BLOCKED",
Channel: "C123456",
TimeStamp: "1700000000.000001",
Text: "<@U_BOT> hello",
}

ch.handleAppMention(ev)

chatID := "C123456/1700000000.000001"
if _, ok := ch.pendingAcks.Load(chatID); ok {
t.Fatalf("blocked user should not create pending ack for chat %s", chatID)
}

assertNoInboundMessage(t, msgBus)
}

func TestSlackSlashCommandRejectsUsersOutsideAllowlist(t *testing.T) {
msgBus := bus.NewMessageBus()
cfg := config.SlackConfig{
BotToken: "xoxb-test",
AppToken: "xapp-test",
AllowFrom: []string{"U_ALLOWED"},
}

ch, err := NewSlackChannel(cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

event := socketmode.Event{
Data: slack.SlashCommand{
UserID: "U_BLOCKED",
ChannelID: "C123456",
Command: "/picoclaw",
Text: "hello",
},
}

ch.handleSlashCommand(event)

assertNoInboundMessage(t, msgBus)
}

func assertNoInboundMessage(t *testing.T, msgBus *bus.MessageBus) {
t.Helper()

ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()

if msg, ok := msgBus.ConsumeInbound(ctx); ok {
t.Fatalf("expected no inbound message, got %+v", msg)
}
}
42 changes: 33 additions & 9 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,16 @@ type QQConfig struct {
}

type DingTalkConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
}

type SlackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
}

Expand Down Expand Up @@ -135,7 +135,7 @@ func DefaultConfig() *Config {
Agents: AgentsConfig{
Defaults: AgentDefaults{
Workspace: "~/.picoclaw/workspace",
Model: "glm-4.7",
Model: "openrouter/auto",
MaxTokens: 8192,
Temperature: 0.7,
MaxToolIterations: 20,
Expand Down Expand Up @@ -233,6 +233,8 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}

normalizeLegacyModelDefaults(cfg)

return cfg, nil
}

Expand All @@ -250,7 +252,7 @@ func SaveConfig(path string, cfg *Config) error {
return err
}

return os.WriteFile(path, data, 0644)
return writePrivateFile(path, data)
}

func (c *Config) WorkspacePath() string {
Expand Down Expand Up @@ -317,3 +319,25 @@ func expandHome(path string) string {
}
return path
}

func writePrivateFile(path string, data []byte) error {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[M1 — MEDIUM: DRY Violation] @jmahotiedu — This exact function is duplicated in pkg/cron/service.go:460. Both are identical:

func writePrivateFile(path string, data []byte) error {
    if err := os.WriteFile(path, data, 0600); err != nil {
        return err
    }
    return os.Chmod(path, 0600)
}

Consider moving it to pkg/utils/ (which already exists with media.go and string.go) and importing from both packages. Not blocking, but worth cleaning up.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e2f3b43: moved the duplicated helper to pkg/utils/file.go as utils.WritePrivateFile and switched both config/cron call sites to use it.

if err := os.WriteFile(path, data, 0600); err != nil {
return err
}
return os.Chmod(path, 0600)
}

func normalizeLegacyModelDefaults(cfg *Config) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Positive + M2 Suggestion] @jmahotiedu — Smart migration function. The logic flow is clear:

  1. Not glm-4.7? → leave it alone (user chose something deliberately)
  2. Has Zhipu key? → leave it (they intend to use Zhipu)
  3. Has OpenRouter key? → migrate to openrouter/auto
  4. Neither key? → do nothing (model stays glm-4.7)

Case 4 is intentionally conservative (we cannot guess the user's intended provider), but it should have a test to document this behavior:

func TestLoadConfigKeepsGLMWhenNoKeysConfigured(t *testing.T) {
    // ... config with model=glm-4.7, no API keys
    // Assert model is still glm-4.7
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e2f3b43: added TestLoadConfigKeepsLegacyGLMModelWhenNoProviderKeysConfigured in pkg/config/config_test.go to document the no-provider-keys glm-4.7 behavior.

if cfg == nil {
return
}
if cfg.Agents.Defaults.Model != "glm-4.7" {
return
}
if cfg.Providers.Zhipu.APIKey != "" {
return
}
if cfg.Providers.OpenRouter.APIKey != "" {
cfg.Agents.Defaults.Model = "openrouter/auto"
}
}
Loading