Files
2026-06-02 23:14:41 +08:00

3181 lines
84 KiB
Go

package config
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/BurntSushi/toml"
)
func TestConfigValidate(t *testing.T) {
tests := []struct {
name string
cfg Config
wantErr string
}{
{
name: "requires at least one project",
cfg: Config{},
wantErr: "at least one [[projects]] entry is required",
},
{
name: "requires project name",
cfg: Config{
Projects: []ProjectConfig{
validProject(""),
},
},
wantErr: `projects[0].name is required`,
},
{
name: "requires agent type",
cfg: Config{
Projects: []ProjectConfig{
func() ProjectConfig {
p := validProject("demo")
p.Agent.Type = ""
return p
}(),
},
},
wantErr: `projects[0].agent.type is required`,
},
{
name: "requires at least one platform",
cfg: Config{
Projects: []ProjectConfig{
func() ProjectConfig {
p := validProject("demo")
p.Platforms = nil
return p
}(),
},
},
wantErr: `projects[0] needs at least one [[projects.platforms]]`,
},
{
name: "requires platform type",
cfg: Config{
Projects: []ProjectConfig{
func() ProjectConfig {
p := validProject("demo")
p.Platforms[0].Type = ""
return p
}(),
},
},
wantErr: `projects[0].platforms[0].type is required`,
},
{
name: "multi workspace requires base dir",
cfg: Config{
Projects: []ProjectConfig{
func() ProjectConfig {
p := validProject("demo")
p.Mode = "multi-workspace"
return p
}(),
},
},
wantErr: `project "demo": multi-workspace mode requires base_dir`,
},
{
name: "multi workspace rejects work dir",
cfg: Config{
Projects: []ProjectConfig{
func() ProjectConfig {
p := validProject("demo")
p.Mode = "multi-workspace"
p.BaseDir = "~/workspace"
p.Agent.Options["work_dir"] = "/tmp/demo"
return p
}(),
},
},
wantErr: `project "demo": multi-workspace mode conflicts with agent work_dir`,
},
{
name: "accepts valid config",
cfg: Config{
Projects: []ProjectConfig{validProject("demo")},
},
},
{
name: "accepts valid references config",
cfg: Config{
Projects: []ProjectConfig{
func() ProjectConfig {
p := validProject("demo")
p.References = ReferenceConfig{
NormalizeAgents: []string{"codex", "claudecode"},
RenderPlatforms: []string{"feishu", "weixin"},
DisplayPath: "dirname_basename",
MarkerStyle: "emoji",
EnclosureStyle: "code",
}
return p
}(),
},
},
},
{
name: "rejects unsupported reference agent",
cfg: Config{
Projects: []ProjectConfig{
func() ProjectConfig {
p := validProject("demo")
p.References.NormalizeAgents = []string{"gemini"}
return p
}(),
},
},
wantErr: `projects[0].references.normalize_agents has unsupported value "gemini"`,
},
{
name: "rejects unsupported reference platform",
cfg: Config{
Projects: []ProjectConfig{
func() ProjectConfig {
p := validProject("demo")
p.References.RenderPlatforms = []string{"telegram"}
return p
}(),
},
},
wantErr: `projects[0].references.render_platforms has unsupported value "telegram"`,
},
{
name: "rejects unsupported reference display path",
cfg: Config{
Projects: []ProjectConfig{
func() ProjectConfig {
p := validProject("demo")
p.References.DisplayPath = "full"
return p
}(),
},
},
wantErr: `projects[0].references.display_path has unsupported value "full"`,
},
{
name: "accepts all shorthand in references scopes",
cfg: Config{
Projects: []ProjectConfig{
func() ProjectConfig {
p := validProject("demo")
p.References.NormalizeAgents = []string{"all"}
p.References.RenderPlatforms = []string{"all"}
return p
}(),
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.cfg.validate()
if tt.wantErr == "" {
if err != nil {
t.Fatalf("validate() unexpected error: %v", err)
}
return
}
assertErrContains(t, err, tt.wantErr)
})
}
}
func TestRunAsEnv_RejectsDangerousVars(t *testing.T) {
dangerous := []string{"PATH", "path", "LD_PRELOAD", "HOME", "USER", "SHELL", "SUDO_USER", "SUDO_COMMAND", "LD_LIBRARY_PATH", "DYLD_INSERT_LIBRARIES"}
for _, v := range dangerous {
err := validateRunAsEnv("projects[0]", []string{v})
if err == nil {
t.Errorf("validateRunAsEnv(%q) = nil, want error", v)
}
}
safe := []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "CUSTOM_VAR"}
for _, v := range safe {
err := validateRunAsEnv("projects[0]", []string{v})
if err != nil {
t.Errorf("validateRunAsEnv(%q) = %v, want nil", v, err)
}
}
}
func TestEffectiveDisplayQuiet(t *testing.T) {
tru, fal := true, false
compact := DisplayModeCompact
quiet := DisplayModeQuiet
tests := []struct {
name string
cfg Config
proj ProjectConfig
wantMode string
wantTM bool
wantTool bool
}{
{
name: "defaults no quiet",
cfg: Config{},
proj: ProjectConfig{},
wantMode: "full",
wantTM: true,
wantTool: true,
},
{
name: "global quiet maps to quiet mode",
cfg: Config{Quiet: &tru},
proj: ProjectConfig{},
wantMode: "quiet",
wantTM: false,
wantTool: false,
},
{
name: "project quiet maps to quiet mode",
cfg: Config{},
proj: ProjectConfig{Quiet: &tru},
wantMode: "quiet",
wantTM: false,
wantTool: false,
},
{
name: "explicit thinking_messages wins over quiet",
cfg: Config{
Quiet: &tru,
Display: DisplayConfig{ThinkingMessages: &tru},
},
proj: ProjectConfig{},
wantMode: "quiet",
wantTM: true,
wantTool: false,
},
{
name: "project quiet false overrides global quiet",
cfg: Config{Quiet: &tru},
proj: ProjectConfig{Quiet: &fal},
wantMode: "full",
wantTM: true,
wantTool: true,
},
{
name: "explicit mode compact",
cfg: Config{Display: DisplayConfig{Mode: &compact}},
proj: ProjectConfig{},
wantMode: "compact",
wantTM: false,
wantTool: false,
},
{
name: "project mode overrides global mode",
cfg: Config{Display: DisplayConfig{Mode: &quiet}},
proj: ProjectConfig{Display: &DisplayConfig{Mode: &compact}},
wantMode: "compact",
wantTM: false,
wantTool: false,
},
{
name: "explicit mode wins over legacy quiet",
cfg: Config{Quiet: &tru, Display: DisplayConfig{Mode: &compact}},
proj: ProjectConfig{},
wantMode: "compact",
wantTM: false,
wantTool: false,
},
{
name: "explicit mode quiet with thinking override",
cfg: Config{
Display: DisplayConfig{Mode: &quiet, ThinkingMessages: &tru},
},
proj: ProjectConfig{},
wantMode: "quiet",
wantTM: true,
wantTool: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mode, tm, tool, _, _, _, _ := EffectiveDisplay(&tt.cfg, &tt.proj)
if mode != tt.wantMode {
t.Fatalf("Mode = %q, want %q", mode, tt.wantMode)
}
if tm != tt.wantTM {
t.Fatalf("ThinkingMessages = %v, want %v", tm, tt.wantTM)
}
if tool != tt.wantTool {
t.Fatalf("ToolMessages = %v, want %v", tool, tt.wantTool)
}
})
}
}
func TestEffectiveDisplay_ProjectOverride(t *testing.T) {
tru, fal := true, false
maxA, maxB := 100, 200
tests := []struct {
name string
cfg Config
proj ProjectConfig
wantTM bool
wantTool bool
wantThinkLen int
wantToolMaxLen int
}{
{
name: "project overrides global thinking_messages",
cfg: Config{
Display: DisplayConfig{ThinkingMessages: &tru, ToolMessages: &tru},
},
proj: ProjectConfig{
Display: &DisplayConfig{ThinkingMessages: &fal},
},
wantTM: false,
wantTool: true,
wantThinkLen: 300,
wantToolMaxLen: 500,
},
{
name: "project unset falls back to global",
cfg: Config{
Display: DisplayConfig{ThinkingMessages: &fal, ToolMessages: &fal},
},
proj: ProjectConfig{
Display: &DisplayConfig{},
},
wantTM: false,
wantTool: false,
wantThinkLen: 300,
wantToolMaxLen: 500,
},
{
name: "both unset falls back to default",
cfg: Config{},
proj: ProjectConfig{
Display: &DisplayConfig{},
},
wantTM: true,
wantTool: true,
wantThinkLen: 300,
wantToolMaxLen: 500,
},
{
name: "project overrides max-len fields",
cfg: Config{
Display: DisplayConfig{ThinkingMaxLen: &maxA, ToolMaxLen: &maxA},
},
proj: ProjectConfig{
Display: &DisplayConfig{ThinkingMaxLen: &maxB, ToolMaxLen: &maxB},
},
wantTM: true,
wantTool: true,
wantThinkLen: 200,
wantToolMaxLen: 200,
},
{
name: "project quiet still respected when project display unset",
cfg: Config{Quiet: &tru},
proj: ProjectConfig{
Display: &DisplayConfig{},
},
wantTM: false,
wantTool: false,
wantThinkLen: 300,
wantToolMaxLen: 500,
},
{
name: "project display.thinking_messages true overrides project quiet",
cfg: Config{Quiet: &tru},
proj: ProjectConfig{
Display: &DisplayConfig{ThinkingMessages: &tru},
},
wantTM: true,
wantTool: false,
wantThinkLen: 300,
wantToolMaxLen: 500,
},
{
name: "nil project display behaves like before",
cfg: Config{
Display: DisplayConfig{ThinkingMessages: &fal},
},
proj: ProjectConfig{},
wantTM: false,
wantTool: true,
wantThinkLen: 300,
wantToolMaxLen: 500,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, tm, tool, thinkLen, toolMaxLen, _, _ := EffectiveDisplay(&tt.cfg, &tt.proj)
if tm != tt.wantTM {
t.Errorf("ThinkingMessages = %v, want %v", tm, tt.wantTM)
}
if tool != tt.wantTool {
t.Errorf("ToolMessages = %v, want %v", tool, tt.wantTool)
}
if thinkLen != tt.wantThinkLen {
t.Errorf("ThinkingMaxLen = %d, want %d", thinkLen, tt.wantThinkLen)
}
if toolMaxLen != tt.wantToolMaxLen {
t.Errorf("ToolMaxLen = %d, want %d", toolMaxLen, tt.wantToolMaxLen)
}
})
}
}
func TestValidateProjectDisplayConfig(t *testing.T) {
mode := "verbose"
cardMode := "modern"
tests := []struct {
name string
display *DisplayConfig
wantErr string
}{
{
name: "invalid project display mode",
display: &DisplayConfig{Mode: &mode},
wantErr: `projects[0].display.mode must be "full", "compact", or "quiet"`,
},
{
name: "invalid project card mode",
display: &DisplayConfig{CardMode: &cardMode},
wantErr: `projects[0].display.card_mode must be "legacy" or "rich"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Config{Projects: []ProjectConfig{validProject("demo")}}
cfg.Projects[0].Display = tt.display
err := cfg.validate()
if err == nil {
t.Fatalf("validate() = nil, want %q", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("validate() = %q, want contains %q", err.Error(), tt.wantErr)
}
})
}
}
func TestLoad_DefaultsDataDir(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
cfgPath := filepath.Join(dir, "config.toml")
if err := os.WriteFile(cfgPath, []byte(baseConfigTOML), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(cfgPath)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
want := filepath.Join(dir, ".cc-connect")
if cfg.DataDir != want {
t.Fatalf("Load() data_dir = %q, want %q", cfg.DataDir, want)
}
}
func TestLoad_ResolvesEnvPlaceholders(t *testing.T) {
root := t.TempDir()
t.Setenv("CC_ROOT", root)
t.Setenv("TG_TOKEN", "tg-secret")
t.Setenv("HOOK_TOKEN", "hook-secret")
t.Setenv("OPENAI_API_KEY", "sk-test")
t.Setenv("HTTP_PROXY", "http://127.0.0.1:7890")
configPath := writeConfigFixture(t, `
data_dir = "${CC_ROOT}/state"
[webhook]
token = "${HOOK_TOKEN}"
[[projects]]
name = "demo"
[projects.agent]
type = "codex"
[projects.agent.options]
work_dir = "${CC_ROOT}/repo"
note = "prefix-${HOOK_TOKEN}-suffix"
retries = 3
[[projects.agent.providers]]
name = "relay"
api_key = "${OPENAI_API_KEY}"
base_url = "https://relay.example/${HOOK_TOKEN}"
[projects.agent.providers.env]
HTTP_PROXY = "${HTTP_PROXY}"
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
token = "${TG_TOKEN}"
chat_id = 12345
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if got, want := cfg.DataDir, filepath.Join(root, "state"); got != want {
t.Fatalf("DataDir = %q, want %q", got, want)
}
if got := cfg.Webhook.Token; got != "hook-secret" {
t.Fatalf("Webhook.Token = %q, want hook-secret", got)
}
if got := stringMapValue(cfg.Projects[0].Agent.Options, "work_dir"); got != filepath.Join(root, "repo") {
t.Fatalf("work_dir = %q, want %q", got, filepath.Join(root, "repo"))
}
if got := stringMapValue(cfg.Projects[0].Agent.Options, "note"); got != "prefix-hook-secret-suffix" {
t.Fatalf("note = %q, want prefix-hook-secret-suffix", got)
}
if got := cfg.Projects[0].Agent.Providers[0].APIKey; got != "sk-test" {
t.Fatalf("provider api_key = %q, want sk-test", got)
}
if got := cfg.Projects[0].Agent.Providers[0].Env["HTTP_PROXY"]; got != "http://127.0.0.1:7890" {
t.Fatalf("provider env HTTP_PROXY = %q, want http://127.0.0.1:7890", got)
}
if got := stringMapValue(cfg.Projects[0].Platforms[0].Options, "token"); got != "tg-secret" {
t.Fatalf("platform token = %q, want tg-secret", got)
}
if _, ok := cfg.Projects[0].Platforms[0].Options["chat_id"].(int64); !ok {
t.Fatalf("chat_id type = %T, want int64", cfg.Projects[0].Platforms[0].Options["chat_id"])
}
}
func TestLoad_MissingEnvPlaceholderBecomesEmptyString(t *testing.T) {
configPath := writeConfigFixture(t, `
[[projects]]
name = "demo"
[projects.agent]
type = "codex"
[projects.agent.options]
work_dir = "/tmp/demo"
retries = 5
[[projects.agent.providers]]
name = "relay"
api_key = "${MISSING_API_KEY}"
[projects.agent.providers.env]
HTTPS_PROXY = "${MISSING_PROXY}"
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
token = "prefix-${MISSING_TOKEN}-suffix"
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if got := cfg.Projects[0].Agent.Providers[0].APIKey; got != "" {
t.Fatalf("provider api_key = %q, want empty", got)
}
if got := cfg.Projects[0].Agent.Providers[0].Env["HTTPS_PROXY"]; got != "" {
t.Fatalf("provider env HTTPS_PROXY = %q, want empty", got)
}
if got := stringMapValue(cfg.Projects[0].Platforms[0].Options, "token"); got != "prefix--suffix" {
t.Fatalf("platform token = %q, want prefix--suffix", got)
}
if _, ok := cfg.Projects[0].Agent.Options["retries"].(int64); !ok {
t.Fatalf("retries type = %T, want int64", cfg.Projects[0].Agent.Options["retries"])
}
}
func TestListProjects(t *testing.T) {
writeTestConfig(t, baseConfigTOML)
names, err := ListProjects()
if err != nil {
t.Fatalf("ListProjects() error: %v", err)
}
if len(names) != 1 || names[0] != "demo" {
t.Fatalf("ListProjects() = %#v, want [demo]", names)
}
}
func TestSaveLanguage(t *testing.T) {
writeTestConfig(t, baseConfigTOML)
if err := SaveLanguage("zh"); err != nil {
t.Fatalf("SaveLanguage() error: %v", err)
}
cfg := readTestConfig(t)
if cfg.Language != "zh" {
t.Fatalf("Language = %q, want zh", cfg.Language)
}
}
func TestProviderConfig_SaveActiveProviderAndGetProjectProviders(t *testing.T) {
writeTestConfig(t, providerConfigTOML)
if err := SaveActiveProvider("demo", "backup"); err != nil {
t.Fatalf("SaveActiveProvider() error: %v", err)
}
providers, active, err := GetProjectProviders("demo")
if err != nil {
t.Fatalf("GetProjectProviders() error: %v", err)
}
if active != "backup" {
t.Fatalf("active provider = %q, want backup", active)
}
if len(providers) != 2 {
t.Fatalf("provider count = %d, want 2", len(providers))
}
}
func TestProviderConfig_AddAndRemove(t *testing.T) {
writeTestConfig(t, providerConfigTOML)
newProvider := ProviderConfig{Name: "relay", APIKey: "sk-relay", BaseURL: "https://example.com"}
if err := AddProviderToConfig("demo", newProvider); err != nil {
t.Fatalf("AddProviderToConfig() error: %v", err)
}
if err := AddProviderToConfig("demo", newProvider); err == nil {
t.Fatal("AddProviderToConfig() duplicate provider: expected error")
}
cfg := readTestConfig(t)
if len(cfg.Projects[0].Agent.Providers) != 3 {
t.Fatalf("provider count after add = %d, want 3", len(cfg.Projects[0].Agent.Providers))
}
if err := RemoveProviderFromConfig("demo", "relay"); err != nil {
t.Fatalf("RemoveProviderFromConfig() error: %v", err)
}
if err := RemoveProviderFromConfig("demo", "relay"); err == nil {
t.Fatal("RemoveProviderFromConfig() missing provider: expected error")
}
}
func TestProviderConfig_SaveProviderModel(t *testing.T) {
writeTestConfig(t, providerConfigTOML)
if err := SaveProviderModel("demo", "primary", "gpt-5.4"); err != nil {
t.Fatalf("SaveProviderModel() error: %v", err)
}
cfg := readTestConfig(t)
if got := cfg.Projects[0].Agent.Providers[0].Model; got != "gpt-5.4" {
t.Fatalf("provider model = %q, want gpt-5.4", got)
}
if err := SaveProviderModel("demo", "missing", "gpt-4.1"); err == nil {
t.Fatal("SaveProviderModel() missing provider: expected error")
}
}
func TestSaveAgentModel(t *testing.T) {
writeTestConfig(t, providerConfigTOML)
if err := SaveAgentModel("demo", "gpt-5.4"); err != nil {
t.Fatalf("SaveAgentModel() error: %v", err)
}
cfg := readTestConfig(t)
if got, _ := cfg.Projects[0].Agent.Options["model"].(string); got != "gpt-5.4" {
t.Fatalf("agent.options.model = %q, want gpt-5.4", got)
}
if got, _ := cfg.Projects[0].Agent.Options["mode"].(string); got != "default" {
t.Fatalf("agent.options.mode = %q, want default", got)
}
if got, _ := cfg.Projects[0].Agent.Options["provider"].(string); got != "primary" {
t.Fatalf("agent.options.provider = %q, want primary", got)
}
if len(cfg.Projects[0].Agent.Providers) != 2 {
t.Fatalf("provider count = %d, want 2", len(cfg.Projects[0].Agent.Providers))
}
}
const providerConfigWithCommentsTOML = `# This is my config file
# Very important - do not lose this!
custom_top = "keep_me"
[[projects]]
name = "demo"
work_dir = "/tmp/demo" # inline comment
[projects.agent]
type = "claudecode"
[projects.agent.options]
mode = "default"
provider = "primary"
custom_option = "still_here" # keep inline comment
[[projects.agent.providers]]
name = "primary"
api_key = "sk-primary"
[[projects.agent.providers]]
name = "backup"
api_key = "sk-backup"
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
token = "test-token"
`
func TestSaveActiveProvider_PreservesCommentsAndUnknownFields(t *testing.T) {
writeTestConfig(t, providerConfigWithCommentsTOML)
if err := SaveActiveProvider("demo", "backup"); err != nil {
t.Fatalf("SaveActiveProvider() error: %v", err)
}
content, err := os.ReadFile(ConfigPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
text := string(content)
if !strings.Contains(text, "# This is my config file") {
t.Fatalf("expected top comment to be preserved, got:\n%s", text)
}
if !strings.Contains(text, "# Very important - do not lose this!") {
t.Fatalf("expected second comment to be preserved, got:\n%s", text)
}
if !strings.Contains(text, `custom_top = "keep_me"`) {
t.Fatalf("expected unknown top-level field to be preserved, got:\n%s", text)
}
if !strings.Contains(text, `custom_option = "still_here"`) {
t.Fatalf("expected unknown options field to be preserved, got:\n%s", text)
}
if !strings.Contains(text, "keep inline comment") {
t.Fatalf("expected inline comment to be preserved, got:\n%s", text)
}
if !strings.Contains(text, `mode = "default"`) {
t.Fatalf("expected mode to be preserved, got:\n%s", text)
}
if !strings.Contains(text, `provider = "backup"`) {
t.Fatalf("expected provider to be updated to backup, got:\n%s", text)
}
if !strings.Contains(text, `work_dir = "/tmp/demo"`) {
t.Fatalf("expected work_dir to be preserved, got:\n%s", text)
}
cfg := readTestConfig(t)
active, _ := cfg.Projects[0].Agent.Options["provider"].(string)
if active != "backup" {
t.Fatalf("active provider = %q, want backup", active)
}
}
func TestSaveAgentModel_PreservesCommentsAndUnknownFields(t *testing.T) {
writeTestConfig(t, providerConfigWithCommentsTOML)
if err := SaveAgentModel("demo", "gpt-5.4"); err != nil {
t.Fatalf("SaveAgentModel() error: %v", err)
}
content, err := os.ReadFile(ConfigPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
text := string(content)
if !strings.Contains(text, "# This is my config file") {
t.Fatalf("expected top comment to be preserved, got:\n%s", text)
}
if !strings.Contains(text, `custom_option = "still_here"`) {
t.Fatalf("expected unknown options field to be preserved, got:\n%s", text)
}
if !strings.Contains(text, `provider = "primary"`) {
t.Fatalf("expected provider to be preserved, got:\n%s", text)
}
if !strings.Contains(text, `model = "gpt-5.4"`) {
t.Fatalf("expected model to be set, got:\n%s", text)
}
}
func TestSaveProviderModel_PreservesCommentsAndUnknownFields(t *testing.T) {
writeTestConfig(t, providerConfigWithCommentsTOML)
if err := SaveProviderModel("demo", "primary", "gpt-5.4"); err != nil {
t.Fatalf("SaveProviderModel() error: %v", err)
}
content, err := os.ReadFile(ConfigPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
text := string(content)
if !strings.Contains(text, "# This is my config file") {
t.Fatalf("expected top comment to be preserved, got:\n%s", text)
}
if !strings.Contains(text, `custom_option = "still_here"`) {
t.Fatalf("expected unknown options field to be preserved, got:\n%s", text)
}
if !strings.Contains(text, `model = "gpt-5.4"`) {
t.Fatalf("expected model to be set in provider, got:\n%s", text)
}
}
func TestSaveLanguage_PreservesComments(t *testing.T) {
writeTestConfig(t, providerConfigWithCommentsTOML)
if err := SaveLanguage("zh"); err != nil {
t.Fatalf("SaveLanguage() error: %v", err)
}
content, err := os.ReadFile(ConfigPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
text := string(content)
if !strings.Contains(text, "# This is my config file") {
t.Fatalf("expected top comment to be preserved, got:\n%s", text)
}
if !strings.Contains(text, `custom_option = "still_here"`) {
t.Fatalf("expected unknown options field to be preserved, got:\n%s", text)
}
if !strings.Contains(text, `language = "zh"`) {
t.Fatalf("expected language to be set, got:\n%s", text)
}
cfg := readTestConfig(t)
if cfg.Language != "zh" {
t.Fatalf("Language = %q, want zh", cfg.Language)
}
}
func TestSaveDisplayConfig_PreservesComments(t *testing.T) {
configWithDisplay := providerConfigWithCommentsTOML + `
[display]
# display settings below
thinking_messages = true
custom_display = "keep" # also keep
`
writeTestConfig(t, configWithDisplay)
thinking := 200
toolShow := false
if err := SaveDisplayConfig(nil, nil, &thinking, nil, &toolShow); err != nil {
t.Fatalf("SaveDisplayConfig() error: %v", err)
}
content, err := os.ReadFile(ConfigPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
text := string(content)
if !strings.Contains(text, "# This is my config file") {
t.Fatalf("expected top comment to be preserved, got:\n%s", text)
}
if !strings.Contains(text, "# display settings below") {
t.Fatalf("expected display comment to be preserved, got:\n%s", text)
}
if !strings.Contains(text, `custom_display = "keep"`) {
t.Fatalf("expected unknown display field to be preserved, got:\n%s", text)
}
if !strings.Contains(text, `thinking_max_len = 200`) {
t.Fatalf("expected thinking_max_len to be set, got:\n%s", text)
}
if !strings.Contains(text, `tool_messages = false`) {
t.Fatalf("expected tool_messages to be set, got:\n%s", text)
}
}
func TestSaveTTSMode_PreservesComments(t *testing.T) {
configWithTTS := providerConfigWithCommentsTOML + `
[tts]
# tts config
tts_mode = "auto"
`
writeTestConfig(t, configWithTTS)
if err := SaveTTSMode("always"); err != nil {
t.Fatalf("SaveTTSMode() error: %v", err)
}
content, err := os.ReadFile(ConfigPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
text := string(content)
if !strings.Contains(text, "# This is my config file") {
t.Fatalf("expected top comment to be preserved, got:\n%s", text)
}
if !strings.Contains(text, "# tts config") {
t.Fatalf("expected tts comment to be preserved, got:\n%s", text)
}
if !strings.Contains(text, `tts_mode = "always"`) {
t.Fatalf("expected tts_mode to be updated, got:\n%s", text)
}
}
const multiProjectConfigTOML = `# multi-project config
[[projects]]
name = "alpha"
work_dir = "/tmp/alpha"
[projects.agent]
type = "codex"
[projects.agent.options]
provider = "openai"
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
token = "alpha-token"
[[projects]]
name = "beta"
work_dir = "/tmp/beta"
[projects.agent]
type = "claudecode"
[projects.agent.options]
provider = "anthropic"
[[projects.platforms]]
type = "feishu"
[projects.platforms.options]
app_id = "beta-app"
`
func TestSaveActiveProvider_MultiProject(t *testing.T) {
writeTestConfig(t, multiProjectConfigTOML)
if err := SaveActiveProvider("beta", "openai"); err != nil {
t.Fatalf("SaveActiveProvider() error: %v", err)
}
content, err := os.ReadFile(ConfigPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
text := string(content)
if !strings.Contains(text, "# multi-project config") {
t.Fatalf("expected top comment preserved, got:\n%s", text)
}
cfg := readTestConfig(t)
alphaProvider, _ := cfg.Projects[0].Agent.Options["provider"].(string)
betaProvider, _ := cfg.Projects[1].Agent.Options["provider"].(string)
if alphaProvider != "openai" {
t.Fatalf("alpha provider = %q, want openai (untouched)", alphaProvider)
}
if betaProvider != "openai" {
t.Fatalf("beta provider = %q, want openai (updated)", betaProvider)
}
}
const globalProviderRefConfigTOML = `# global provider refs
[[providers]]
name = "shared-openai"
api_key = "sk-shared"
model = "gpt-4o"
[[projects]]
name = "demo"
work_dir = "/tmp/demo"
[projects.agent]
type = "codex"
provider_refs = ["shared-openai"]
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
token = "demo-token"
`
func TestSaveProviderModel_GlobalProviderRef(t *testing.T) {
writeTestConfig(t, globalProviderRefConfigTOML)
if err := SaveProviderModel("demo", "shared-openai", "gpt-5"); err != nil {
t.Fatalf("SaveProviderModel() error: %v", err)
}
content, err := os.ReadFile(ConfigPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
text := string(content)
if !strings.Contains(text, "# global provider refs") {
t.Fatalf("expected comment preserved, got:\n%s", text)
}
if !strings.Contains(text, `model = "gpt-5"`) {
t.Fatalf("expected model updated in global provider, got:\n%s", text)
}
if !strings.Contains(text, `api_key = "sk-shared"`) {
t.Fatalf("expected api_key preserved, got:\n%s", text)
}
cfg := readTestConfig(t)
if cfg.Providers[0].Model != "gpt-5" {
t.Fatalf("global provider model = %q, want gpt-5", cfg.Providers[0].Model)
}
}
func TestCommandConfig_AddAndRemove(t *testing.T) {
writeTestConfig(t, baseConfigTOML)
cmd := CommandConfig{Name: "review", Description: "code review", Prompt: "review {{args}}"}
if err := AddCommand(cmd); err != nil {
t.Fatalf("AddCommand() error: %v", err)
}
if err := AddCommand(cmd); err == nil {
t.Fatal("AddCommand() duplicate command: expected error")
}
cfg := readTestConfig(t)
if len(cfg.Commands) != 1 || cfg.Commands[0].Name != "review" {
t.Fatalf("commands after add = %#v, want one review command", cfg.Commands)
}
if err := RemoveCommand("review"); err != nil {
t.Fatalf("RemoveCommand() error: %v", err)
}
if err := RemoveCommand("review"); err == nil {
t.Fatal("RemoveCommand() missing command: expected error")
}
}
func TestAliasConfig_AddAndRemove(t *testing.T) {
writeTestConfig(t, baseConfigTOML)
if err := AddAlias(AliasConfig{Name: "帮助", Command: "/help"}); err != nil {
t.Fatalf("AddAlias() error: %v", err)
}
if err := AddAlias(AliasConfig{Name: "帮助", Command: "/list"}); err != nil {
t.Fatalf("AddAlias() update error: %v", err)
}
cfg := readTestConfig(t)
if len(cfg.Aliases) != 1 || cfg.Aliases[0].Command != "/list" {
t.Fatalf("aliases after update = %#v, want one updated alias", cfg.Aliases)
}
if err := RemoveAlias("帮助"); err != nil {
t.Fatalf("RemoveAlias() error: %v", err)
}
if err := RemoveAlias("帮助"); err == nil {
t.Fatal("RemoveAlias() missing alias: expected error")
}
}
func TestDisplayConfig_Save(t *testing.T) {
writeTestConfig(t, baseConfigTOML)
thinking := 120
tool := 240
showTools := false
if err := SaveDisplayConfig(nil, nil, &thinking, &tool, &showTools); err != nil {
t.Fatalf("SaveDisplayConfig() error: %v", err)
}
cfg := readTestConfig(t)
if cfg.Display.ThinkingMaxLen == nil || *cfg.Display.ThinkingMaxLen != 120 {
t.Fatalf("ThinkingMaxLen = %#v, want 120", cfg.Display.ThinkingMaxLen)
}
if cfg.Display.ToolMaxLen == nil || *cfg.Display.ToolMaxLen != 240 {
t.Fatalf("ToolMaxLen = %#v, want 240", cfg.Display.ToolMaxLen)
}
if cfg.Display.ToolMessages == nil || *cfg.Display.ToolMessages {
t.Fatalf("ToolMessages = %#v, want false", cfg.Display.ToolMessages)
}
thinking = 360
if err := SaveDisplayConfig(nil, nil, &thinking, nil, nil); err != nil {
t.Fatalf("SaveDisplayConfig() second update error: %v", err)
}
cfg = readTestConfig(t)
if cfg.Display.ThinkingMaxLen == nil || *cfg.Display.ThinkingMaxLen != 360 {
t.Fatalf("ThinkingMaxLen after update = %#v, want 360", cfg.Display.ThinkingMaxLen)
}
if cfg.Display.ToolMaxLen == nil || *cfg.Display.ToolMaxLen != 240 {
t.Fatalf("ToolMaxLen after nil update = %#v, want 240", cfg.Display.ToolMaxLen)
}
if cfg.Display.ToolMessages == nil || *cfg.Display.ToolMessages {
t.Fatalf("ToolMessages after nil update = %#v, want false", cfg.Display.ToolMessages)
}
}
func TestTTSConfig_SaveMode(t *testing.T) {
writeTestConfig(t, baseConfigTOML)
if err := SaveTTSMode("always"); err != nil {
t.Fatalf("SaveTTSMode() error: %v", err)
}
cfg := readTestConfig(t)
if cfg.TTS.TTSMode != "always" {
t.Fatalf("TTSMode = %q, want always", cfg.TTS.TTSMode)
}
}
const attachmentSendConfigFixture = `
attachment_send = "off"
[[projects]]
name = "alpha"
[projects.agent]
type = "codex"
[projects.agent.options]
work_dir = "/tmp/alpha"
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
bot_token = "token_xxx"
`
const relayConfigFixture = `
[relay]
timeout_secs = 300
[[projects]]
name = "alpha"
[projects.agent]
type = "codex"
[projects.agent.options]
work_dir = "/tmp/alpha"
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
bot_token = "token_xxx"
`
const relayConfigNegativeFixture = `
[relay]
timeout_secs = -1
[[projects]]
name = "alpha"
[projects.agent]
type = "codex"
[projects.agent.options]
work_dir = "/tmp/alpha"
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
bot_token = "token_xxx"
`
func TestSaveFeishuPlatformCredentials_UpdateFirstCandidateAndAllowFrom(t *testing.T) {
configPath := writeConfigFixture(t, feishuConfigFixture)
patchConfigPath(t, configPath)
result, err := SaveFeishuPlatformCredentials(FeishuCredentialUpdateOptions{
ProjectName: "alpha",
AppID: "cli_new_app",
AppSecret: "sec_new_secret",
OwnerOpenID: "ou_new_owner",
SetAllowFromEmpty: true,
})
if err != nil {
t.Fatalf("SaveFeishuPlatformCredentials returned error: %v", err)
}
if result.ProjectName != "alpha" {
t.Fatalf("result.ProjectName = %q, want %q", result.ProjectName, "alpha")
}
if result.PlatformAbsIndex != 1 {
t.Fatalf("result.PlatformAbsIndex = %d, want 1", result.PlatformAbsIndex)
}
if result.AllowFrom != "ou_new_owner" {
t.Fatalf("result.AllowFrom = %q, want %q", result.AllowFrom, "ou_new_owner")
}
cfg := readConfigFixture(t, configPath)
platform := cfg.Projects[0].Platforms[1]
if platform.Type != "feishu" {
t.Fatalf("platform.Type = %q, want %q", platform.Type, "feishu")
}
if got := stringMapValue(platform.Options, "app_id"); got != "cli_new_app" {
t.Fatalf("app_id = %q, want %q", got, "cli_new_app")
}
if got := stringMapValue(platform.Options, "app_secret"); got != "sec_new_secret" {
t.Fatalf("app_secret = %q, want %q", got, "sec_new_secret")
}
if got := stringMapValue(platform.Options, "allow_from"); got != "ou_new_owner" {
t.Fatalf("allow_from = %q, want %q", got, "ou_new_owner")
}
}
func TestSaveFeishuPlatformCredentials_SelectByIndexAndOverrideType(t *testing.T) {
configPath := writeConfigFixture(t, feishuConfigFixture)
patchConfigPath(t, configPath)
result, err := SaveFeishuPlatformCredentials(FeishuCredentialUpdateOptions{
ProjectName: "alpha",
PlatformIndex: 2,
PlatformType: "feishu",
AppID: "cli_second_app",
AppSecret: "sec_second_secret",
OwnerOpenID: "ou_should_not_override",
SetAllowFromEmpty: true,
})
if err != nil {
t.Fatalf("SaveFeishuPlatformCredentials returned error: %v", err)
}
if result.PlatformAbsIndex != 2 {
t.Fatalf("result.PlatformAbsIndex = %d, want 2", result.PlatformAbsIndex)
}
if result.PlatformType != "feishu" {
t.Fatalf("result.PlatformType = %q, want %q", result.PlatformType, "feishu")
}
if result.AllowFrom != "ou_existing_owner,ou_should_not_override" {
t.Fatalf("result.AllowFrom = %q, want %q", result.AllowFrom, "ou_existing_owner,ou_should_not_override")
}
cfg := readConfigFixture(t, configPath)
platform := cfg.Projects[0].Platforms[2]
if platform.Type != "feishu" {
t.Fatalf("platform.Type = %q, want %q", platform.Type, "feishu")
}
if got := stringMapValue(platform.Options, "app_id"); got != "cli_second_app" {
t.Fatalf("app_id = %q, want %q", got, "cli_second_app")
}
if got := stringMapValue(platform.Options, "app_secret"); got != "sec_second_secret" {
t.Fatalf("app_secret = %q, want %q", got, "sec_second_secret")
}
if got := stringMapValue(platform.Options, "allow_from"); got != "ou_existing_owner,ou_should_not_override" {
t.Fatalf("allow_from = %q, want %q", got, "ou_existing_owner,ou_should_not_override")
}
}
func TestSaveFeishuPlatformCredentials_AppendsOwnerToAllowFrom(t *testing.T) {
configPath := writeConfigFixture(t, feishuConfigFixture)
patchConfigPath(t, configPath)
result, err := SaveFeishuPlatformCredentials(FeishuCredentialUpdateOptions{
ProjectName: "alpha",
PlatformIndex: 2,
PlatformType: "feishu",
AppID: "cli_second_app",
AppSecret: "sec_second_secret",
OwnerOpenID: "ou_new_owner",
SetAllowFromEmpty: true,
})
if err != nil {
t.Fatalf("SaveFeishuPlatformCredentials returned error: %v", err)
}
if result.AllowFrom != "ou_existing_owner,ou_new_owner" {
t.Fatalf("result.AllowFrom = %q, want %q", result.AllowFrom, "ou_existing_owner,ou_new_owner")
}
cfg := readConfigFixture(t, configPath)
platform := cfg.Projects[0].Platforms[2]
if got := stringMapValue(platform.Options, "allow_from"); got != "ou_existing_owner,ou_new_owner" {
t.Fatalf("allow_from = %q, want %q", got, "ou_existing_owner,ou_new_owner")
}
}
func TestSaveFeishuPlatformCredentials_LeavesWildcardAllowFromUnchanged(t *testing.T) {
configPath := writeConfigFixture(t, strings.Replace(feishuConfigFixture, `allow_from = "ou_existing_owner"`, `allow_from = "*"`, 1))
patchConfigPath(t, configPath)
result, err := SaveFeishuPlatformCredentials(FeishuCredentialUpdateOptions{
ProjectName: "alpha",
PlatformIndex: 2,
OwnerOpenID: "ou_new_owner",
AppID: "cli_second_app",
AppSecret: "sec_second_secret",
SetAllowFromEmpty: true,
})
if err != nil {
t.Fatalf("SaveFeishuPlatformCredentials returned error: %v", err)
}
if result.AllowFrom != "*" {
t.Fatalf("result.AllowFrom = %q, want %q", result.AllowFrom, "*")
}
cfg := readConfigFixture(t, configPath)
platform := cfg.Projects[0].Platforms[2]
if got := stringMapValue(platform.Options, "allow_from"); got != "*" {
t.Fatalf("allow_from = %q, want %q", got, "*")
}
}
func TestSaveFeishuPlatformCredentials_ReturnsIndexRangeError(t *testing.T) {
configPath := writeConfigFixture(t, feishuConfigFixture)
patchConfigPath(t, configPath)
_, err := SaveFeishuPlatformCredentials(FeishuCredentialUpdateOptions{
ProjectName: "alpha",
PlatformIndex: 3,
AppID: "cli_any",
AppSecret: "sec_any",
})
if err == nil {
t.Fatal("expected error for out-of-range platform index, got nil")
}
if !strings.Contains(err.Error(), "out of range") {
t.Fatalf("error = %q, want contains %q", err.Error(), "out of range")
}
}
func TestEnsureProjectWithFeishuPlatform_CreatesMissingProject(t *testing.T) {
configPath := writeConfigFixture(t, feishuConfigFixture)
patchConfigPath(t, configPath)
result, err := EnsureProjectWithFeishuPlatform(EnsureProjectWithFeishuOptions{
ProjectName: "gamma",
PlatformType: "lark",
WorkDir: "/tmp/gamma",
})
if err != nil {
t.Fatalf("EnsureProjectWithFeishuPlatform returned error: %v", err)
}
if !result.Created {
t.Fatal("result.Created = false, want true")
}
if result.AddedPlatform {
t.Fatal("result.AddedPlatform = true, want false")
}
cfg := readConfigFixture(t, configPath)
if len(cfg.Projects) != 2 {
t.Fatalf("len(cfg.Projects) = %d, want 2", len(cfg.Projects))
}
proj := cfg.Projects[1]
if proj.Name != "gamma" {
t.Fatalf("proj.Name = %q, want %q", proj.Name, "gamma")
}
if len(proj.Platforms) != 1 {
t.Fatalf("len(proj.Platforms) = %d, want 1", len(proj.Platforms))
}
if proj.Platforms[0].Type != "lark" {
t.Fatalf("platform type = %q, want %q", proj.Platforms[0].Type, "lark")
}
if got := stringMapValue(proj.Agent.Options, "work_dir"); got != "/tmp/gamma" {
t.Fatalf("work_dir = %q, want explicit override %q", got, "/tmp/gamma")
}
}
func TestEnsureProjectWithFeishuPlatform_AddsPlatformWhenProjectExistsWithoutFeishu(t *testing.T) {
configPath := writeConfigFixture(t, projectWithoutFeishuFixture)
patchConfigPath(t, configPath)
result, err := EnsureProjectWithFeishuPlatform(EnsureProjectWithFeishuOptions{
ProjectName: "beta",
PlatformType: "feishu",
})
if err != nil {
t.Fatalf("EnsureProjectWithFeishuPlatform returned error: %v", err)
}
if result.Created {
t.Fatal("result.Created = true, want false")
}
if !result.AddedPlatform {
t.Fatal("result.AddedPlatform = false, want true")
}
cfg := readConfigFixture(t, configPath)
proj := cfg.Projects[0]
if len(proj.Platforms) != 2 {
t.Fatalf("len(proj.Platforms) = %d, want 2", len(proj.Platforms))
}
if proj.Platforms[1].Type != "feishu" {
t.Fatalf("platform type = %q, want %q", proj.Platforms[1].Type, "feishu")
}
}
func TestSaveFeishuPlatformCredentials_PreservesCommentsAndUnknownFields(t *testing.T) {
configPath := writeConfigFixture(t, preserveFormatFixture)
patchConfigPath(t, configPath)
_, err := SaveFeishuPlatformCredentials(FeishuCredentialUpdateOptions{
ProjectName: "alpha",
AppID: "cli_new_app",
AppSecret: "sec_new_secret",
})
if err != nil {
t.Fatalf("SaveFeishuPlatformCredentials returned error: %v", err)
}
content, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read config fixture: %v", err)
}
text := string(content)
if !strings.Contains(text, "# top comment should stay") {
t.Fatalf("expected top comment to be preserved, got:\n%s", text)
}
if !strings.Contains(text, `custom_top = "keep_me"`) {
t.Fatalf("expected unknown top-level field to be preserved, got:\n%s", text)
}
if !strings.Contains(text, `custom_option = "still_here"`) {
t.Fatalf("expected unknown options field to be preserved, got:\n%s", text)
}
if !strings.Contains(text, "keep inline comment") {
t.Fatalf("expected inline comment to be preserved, got:\n%s", text)
}
}
func TestLoad_DefaultsAttachmentSendToOn(t *testing.T) {
configPath := writeConfigFixture(t, projectWithoutFeishuFixture)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.AttachmentSend != "on" {
t.Fatalf("cfg.AttachmentSend = %q, want %q", cfg.AttachmentSend, "on")
}
}
func TestLoad_DefaultsAutoCompressDisabled(t *testing.T) {
configPath := writeConfigFixture(t, projectWithoutFeishuFixture)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if len(cfg.Projects) == 0 {
t.Fatalf("expected at least one project")
}
if cfg.Projects[0].AutoCompress.Enabled != nil {
t.Fatalf("expected auto_compress.enabled to default to nil")
}
}
func TestLoad_ParsesResetOnIdleMins(t *testing.T) {
configPath := writeConfigFixture(t, projectWithResetOnIdleFixture)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.Projects[0].ResetOnIdleMins == nil {
t.Fatal("expected reset_on_idle_mins to be parsed")
}
if got := *cfg.Projects[0].ResetOnIdleMins; got != 60 {
t.Fatalf("reset_on_idle_mins = %d, want 60", got)
}
}
func TestLoad_RejectsNegativeResetOnIdleMins(t *testing.T) {
configPath := writeConfigFixture(t, projectWithNegativeResetOnIdleFixture)
_, err := Load(configPath)
if err == nil {
t.Fatal("expected error for negative reset_on_idle_mins")
}
if !strings.Contains(err.Error(), "reset_on_idle_mins") {
t.Fatalf("error = %q, want reset_on_idle_mins validation", err.Error())
}
}
func TestLoad_ParsesRunAsUser(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("run_as_user is only supported on Linux/macOS")
}
configPath := writeConfigFixture(t, projectWithRunAsUserFixture)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if got := cfg.Projects[0].RunAsUser; got != "partseeker-coder" {
t.Fatalf("run_as_user = %q, want %q", got, "partseeker-coder")
}
if got := cfg.Projects[0].RunAsEnv; len(got) != 2 || got[0] != "PGSSLROOTCERT" || got[1] != "PGSSLMODE" {
t.Fatalf("run_as_env = %v, want [PGSSLROOTCERT PGSSLMODE]", got)
}
}
func TestLoad_RejectsRunAsUserRoot(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("run_as_user is only supported on Linux/macOS")
}
configPath := writeConfigFixture(t, projectWithRunAsUserRootFixture)
_, err := Load(configPath)
if err == nil {
t.Fatal("expected error for run_as_user = root")
}
if !strings.Contains(err.Error(), "must not be root") {
t.Fatalf("error = %q, want 'must not be root' validation", err.Error())
}
}
func TestLoad_RejectsRunAsUserInvalidChars(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("run_as_user is only supported on Linux/macOS")
}
configPath := writeConfigFixture(t, projectWithRunAsUserInvalidFixture)
_, err := Load(configPath)
if err == nil {
t.Fatal("expected error for invalid run_as_user")
}
if !strings.Contains(err.Error(), "invalid characters") {
t.Fatalf("error = %q, want 'invalid characters' validation", err.Error())
}
}
func TestValidateRunAsUser_ValidNames(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("run_as_user is only supported on Linux/macOS")
}
valid := []string{"leigh", "partseeker-coder", "user_name", "user.name", "u1", "_internal"}
for _, name := range valid {
if err := validateRunAsUser("projects[0]", name); err != nil {
t.Errorf("validateRunAsUser(%q) = %v, want nil", name, err)
}
}
}
func TestValidateRunAsUser_InvalidNames(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("run_as_user is only supported on Linux/macOS")
}
invalid := []string{
"-leading-dash",
"1leading-digit",
"has space",
"has/slash",
"has;semi",
"has$dollar",
"has`tick",
strings.Repeat("a", 33), // too long
}
for _, name := range invalid {
if err := validateRunAsUser("projects[0]", name); err == nil {
t.Errorf("validateRunAsUser(%q) = nil, want error", name)
}
}
}
func TestLoad_ParsesAttachmentSendOff(t *testing.T) {
configPath := writeConfigFixture(t, attachmentSendConfigFixture)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.AttachmentSend != "off" {
t.Fatalf("cfg.AttachmentSend = %q, want %q", cfg.AttachmentSend, "off")
}
}
func TestLoad_FilterExternalSessionsDefault(t *testing.T) {
configPath := writeConfigFixture(t, attachmentSendConfigFixture)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load: %v", err)
}
proj := cfg.Projects[0]
if proj.FilterExternalSessions != nil {
t.Fatalf("FilterExternalSessions should be nil by default, got %v", *proj.FilterExternalSessions)
}
}
func TestLoad_FilterExternalSessionsTrue(t *testing.T) {
fixture := `
[[projects]]
name = "beta"
filter_external_sessions = true
[projects.agent]
type = "codex"
[projects.agent.options]
work_dir = "/tmp/beta"
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
token = "test"
`
configPath := writeConfigFixture(t, fixture)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load: %v", err)
}
proj := cfg.Projects[0]
if proj.FilterExternalSessions == nil || !*proj.FilterExternalSessions {
t.Fatalf("FilterExternalSessions should be true, got %v", proj.FilterExternalSessions)
}
}
func TestLoad_FilterExternalSessionsFalse(t *testing.T) {
fixture := `
[[projects]]
name = "gamma"
filter_external_sessions = false
[projects.agent]
type = "codex"
[projects.agent.options]
work_dir = "/tmp/gamma"
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
token = "test"
`
configPath := writeConfigFixture(t, fixture)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load: %v", err)
}
proj := cfg.Projects[0]
if proj.FilterExternalSessions == nil || *proj.FilterExternalSessions {
t.Fatalf("FilterExternalSessions should be false, got %v", proj.FilterExternalSessions)
}
}
func validProject(name string) ProjectConfig {
return ProjectConfig{
Name: name,
Agent: AgentConfig{
Type: "claudecode",
Options: map[string]any{"mode": "default"},
},
Platforms: []PlatformConfig{
{Type: "telegram", Options: map[string]any{"token": "test-token"}},
},
}
}
func assertErrContains(t *testing.T, err error, want string) {
t.Helper()
if err == nil {
t.Fatalf("expected error containing %q, got nil", want)
}
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %q, want substring %q", err.Error(), want)
}
}
func writeTestConfig(t *testing.T, content string) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
oldPath := ConfigPath
ConfigPath = path
t.Cleanup(func() {
ConfigPath = oldPath
})
}
func readTestConfig(t *testing.T) Config {
t.Helper()
data, err := os.ReadFile(ConfigPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg Config
if err := toml.Unmarshal(data, &cfg); err != nil {
t.Fatalf("parse config: %v", err)
}
return cfg
}
func TestLoadRelayTimeoutConfig(t *testing.T) {
configPath := writeConfigFixture(t, relayConfigFixture)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.Relay.TimeoutSecs == nil {
t.Fatal("cfg.Relay.TimeoutSecs = nil, want non-nil")
}
if *cfg.Relay.TimeoutSecs != 300 {
t.Fatalf("cfg.Relay.TimeoutSecs = %d, want 300", *cfg.Relay.TimeoutSecs)
}
}
func TestLoadRejectsNegativeRelayTimeout(t *testing.T) {
configPath := writeConfigFixture(t, relayConfigNegativeFixture)
_, err := Load(configPath)
if err == nil {
t.Fatal("expected error for negative relay timeout, got nil")
}
if !strings.Contains(err.Error(), "relay.timeout_secs must be >= 0") {
t.Fatalf("error = %q, want contains %q", err.Error(), "relay.timeout_secs must be >= 0")
}
}
func writeConfigFixture(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write config fixture: %v", err)
}
return path
}
func patchConfigPath(t *testing.T, path string) {
t.Helper()
prev := ConfigPath
ConfigPath = path
t.Cleanup(func() {
ConfigPath = prev
})
}
func readConfigFixture(t *testing.T, path string) *Config {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config fixture: %v", err)
}
cfg := &Config{}
if err := toml.Unmarshal(data, cfg); err != nil {
t.Fatalf("parse config fixture: %v", err)
}
return cfg
}
func stringMapValue(m map[string]any, key string) string {
if m == nil {
return ""
}
if v, ok := m[key].(string); ok {
return v
}
return ""
}
const baseConfigTOML = `
[[projects]]
name = "demo"
[projects.agent]
type = "claudecode"
[projects.agent.options]
mode = "default"
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
token = "test-token"
`
const providerConfigTOML = `
[[projects]]
name = "demo"
[projects.agent]
type = "claudecode"
[projects.agent.options]
mode = "default"
provider = "primary"
[[projects.agent.providers]]
name = "primary"
api_key = "sk-primary"
[[projects.agent.providers]]
name = "backup"
api_key = "sk-backup"
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
token = "test-token"
`
const feishuConfigFixture = `
[[projects]]
name = "alpha"
[projects.agent]
type = "codex"
[projects.agent.options]
work_dir = "/tmp/alpha"
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
bot_token = "token_xxx"
[[projects.platforms]]
type = "feishu"
[projects.platforms.options]
app_id = "old_feishu_app"
app_secret = "old_feishu_secret"
[[projects.platforms]]
type = "lark"
[projects.platforms.options]
app_id = "old_lark_app"
app_secret = "old_lark_secret"
allow_from = "ou_existing_owner"
`
const projectWithoutFeishuFixture = `
[[projects]]
name = "beta"
[projects.agent]
type = "codex"
[projects.agent.options]
work_dir = "/tmp/beta"
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
bot_token = "token_xxx"
`
const projectWithResetOnIdleFixture = `
[[projects]]
name = "beta"
reset_on_idle_mins = 60
[projects.agent]
type = "codex"
[projects.agent.options]
work_dir = "/tmp/beta"
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
bot_token = "token_xxx"
`
const projectWithNegativeResetOnIdleFixture = `
[[projects]]
name = "beta"
reset_on_idle_mins = -1
[projects.agent]
type = "codex"
[projects.agent.options]
work_dir = "/tmp/beta"
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
bot_token = "token_xxx"
`
const projectWithRunAsUserFixture = `
[[projects]]
name = "sandboxed"
run_as_user = "partseeker-coder"
run_as_env = ["PGSSLROOTCERT", "PGSSLMODE"]
[projects.agent]
type = "claudecode"
[projects.agent.options]
work_dir = "/tmp/sandboxed"
[[projects.platforms]]
type = "slack"
[projects.platforms.options]
app_token = "xapp-token"
bot_token = "xoxb-token"
`
const projectWithRunAsUserRootFixture = `
[[projects]]
name = "bad"
run_as_user = "root"
[projects.agent]
type = "claudecode"
[projects.agent.options]
work_dir = "/tmp/bad"
[[projects.platforms]]
type = "slack"
[projects.platforms.options]
app_token = "xapp-token"
bot_token = "xoxb-token"
`
const projectWithRunAsUserInvalidFixture = `
[[projects]]
name = "bad"
run_as_user = "has space"
[projects.agent]
type = "claudecode"
[projects.agent.options]
work_dir = "/tmp/bad"
[[projects.platforms]]
type = "slack"
[projects.platforms.options]
app_token = "xapp-token"
bot_token = "xoxb-token"
`
const weixinConfigFixture = `
[[projects]]
name = "alpha"
[projects.agent]
type = "codex"
[projects.agent.options]
work_dir = "/tmp/alpha"
[[projects.platforms]]
type = "weixin"
[projects.platforms.options]
token = "old_weixin_token"
base_url = "https://ilink.example"
`
const preserveFormatFixture = `# top comment should stay
custom_top = "keep_me"
[[projects]]
name = "alpha"
[projects.agent]
type = "codex"
[projects.agent.options]
work_dir = "/tmp/alpha"
[[projects.platforms]]
type = "feishu"
[projects.platforms.options]
app_id = "old_app" # keep inline comment
app_secret = "old_secret"
custom_option = "still_here"
`
// --- validateUsersConfig tests ---
func TestValidateUsersConfig(t *testing.T) {
tests := []struct {
name string
cfg Config
wantErr string
}{
{
name: "nil users is valid",
cfg: Config{
Projects: []ProjectConfig{{
Name: "p1",
Agent: AgentConfig{Type: "codex"},
Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}},
Users: nil,
}},
},
wantErr: "",
},
{
name: "empty roles",
cfg: Config{
Projects: []ProjectConfig{{
Name: "p1",
Agent: AgentConfig{Type: "codex"},
Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}},
Users: &UsersConfig{Roles: map[string]RoleConfig{}},
}},
},
wantErr: `no roles defined`,
},
{
name: "empty user_ids in role",
cfg: Config{
Projects: []ProjectConfig{{
Name: "p1",
Agent: AgentConfig{Type: "codex"},
Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}},
Users: &UsersConfig{
Roles: map[string]RoleConfig{
"admin": {UserIDs: []string{}},
},
},
}},
},
wantErr: `empty user_ids`,
},
{
name: "duplicate user in different roles",
cfg: Config{
Projects: []ProjectConfig{{
Name: "p1",
Agent: AgentConfig{Type: "codex"},
Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}},
Users: &UsersConfig{
Roles: map[string]RoleConfig{
"admin": {UserIDs: []string{"user1"}},
"member": {UserIDs: []string{"user1"}},
},
},
}},
},
wantErr: `appears in both role`,
},
{
name: "wildcard in multiple roles",
cfg: Config{
Projects: []ProjectConfig{{
Name: "p1",
Agent: AgentConfig{Type: "codex"},
Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}},
Users: &UsersConfig{
Roles: map[string]RoleConfig{
"admin": {UserIDs: []string{"*"}},
"member": {UserIDs: []string{"*"}},
},
},
}},
},
wantErr: `wildcard`,
},
{
name: "default_role not matching any role",
cfg: Config{
Projects: []ProjectConfig{{
Name: "p1",
Agent: AgentConfig{Type: "codex"},
Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}},
Users: &UsersConfig{
DefaultRole: "superadmin",
Roles: map[string]RoleConfig{
"admin": {UserIDs: []string{"u1"}},
},
},
}},
},
wantErr: `default_role`,
},
{
name: "valid users config",
cfg: Config{
Projects: []ProjectConfig{{
Name: "p1",
Agent: AgentConfig{Type: "codex"},
Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}},
Users: &UsersConfig{
DefaultRole: "member",
Roles: map[string]RoleConfig{
"admin": {UserIDs: []string{"admin1"}},
"member": {UserIDs: []string{"*"}},
},
},
}},
},
wantErr: "",
},
{
name: "valid with wildcard in one role only",
cfg: Config{
Projects: []ProjectConfig{{
Name: "p1",
Agent: AgentConfig{Type: "codex"},
Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}},
Users: &UsersConfig{
Roles: map[string]RoleConfig{
"admin": {UserIDs: []string{"u1"}},
"member": {UserIDs: []string{"*", "u2"}},
},
},
}},
},
wantErr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateUsersConfig("projects[0]", tt.cfg.Projects[0].Users)
if tt.wantErr == "" {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
} else {
if err == nil {
t.Error("expected error, got nil")
} else if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErr)
}
}
})
}
}
// --- cloneStringMap tests ---
func TestCloneStringMap(t *testing.T) {
// nil map
if got := cloneStringMap(nil); got != nil {
t.Errorf("cloneStringMap(nil) = %v, want nil", got)
}
// empty map
empty := cloneStringMap(map[string]string{})
if got := cloneStringMap(empty); got == nil || len(got) != 0 {
t.Errorf("cloneStringMap(empty) = %v, want empty non-nil map", got)
}
// populated map
orig := map[string]string{"key1": "val1", "key2": "val2"}
cloned := cloneStringMap(orig)
if len(cloned) != len(orig) {
t.Errorf("length mismatch: got %d, want %d", len(cloned), len(orig))
}
for k, v := range orig {
if cloned[k] != v {
t.Errorf("cloneStringMap[%q] = %q, want %q", k, cloned[k], v)
}
}
// verify it's a deep copy
delete(cloned, "key1")
if _, ok := orig["key1"]; !ok {
t.Error("cloneStringMap returned same map reference, not a copy")
}
}
// --- pickAgentTemplateForNewProject tests ---
func TestPickAgentTemplateForNewProject(t *testing.T) {
baseProj := ProjectConfig{
Name: "base",
Agent: AgentConfig{
Type: "claudecode",
Options: map[string]any{"mode": "yolo"},
Providers: []ProviderConfig{{
Name: "openai",
APIKey: "sk-test",
Model: "gpt-4",
}},
},
Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}},
}
t.Run("clone from existing project", func(t *testing.T) {
cfg := &Config{Projects: []ProjectConfig{baseProj}}
opts := EnsureProjectWithFeishuOptions{CloneFromProject: "base"}
got := pickAgentTemplateForNewProject(cfg, opts)
if got.Type != "claudecode" {
t.Errorf("Type = %q, want claudecode", got.Type)
}
if len(got.Providers) != 1 || got.Providers[0].APIKey != "sk-test" {
t.Errorf("Providers not cloned correctly")
}
})
t.Run("no clone but has projects", func(t *testing.T) {
cfg := &Config{Projects: []ProjectConfig{baseProj}}
opts := EnsureProjectWithFeishuOptions{}
got := pickAgentTemplateForNewProject(cfg, opts)
if got.Type != "claudecode" {
t.Errorf("Type = %q, want claudecode", got.Type)
}
})
t.Run("no projects uses default codex", func(t *testing.T) {
cfg := &Config{Projects: []ProjectConfig{}}
opts := EnsureProjectWithFeishuOptions{}
got := pickAgentTemplateForNewProject(cfg, opts)
if got.Type != "codex" {
t.Errorf("Type = %q, want codex", got.Type)
}
if got.Options == nil {
t.Error("Options should not be nil")
}
})
t.Run("no projects with explicit agent type", func(t *testing.T) {
cfg := &Config{Projects: []ProjectConfig{}}
opts := EnsureProjectWithFeishuOptions{AgentType: "gemini"}
got := pickAgentTemplateForNewProject(cfg, opts)
if got.Type != "gemini" {
t.Errorf("Type = %q, want gemini", got.Type)
}
})
t.Run("explicit agent type overrides clone from first project", func(t *testing.T) {
cfg := &Config{Projects: []ProjectConfig{baseProj}}
opts := EnsureProjectWithFeishuOptions{AgentType: "cursor"}
got := pickAgentTemplateForNewProject(cfg, opts)
if got.Type != "cursor" {
t.Errorf("Type = %q, want cursor (explicit AgentType should take priority over cloning first project)", got.Type)
}
})
}
// --- cloneAgentConfig tests ---
func TestCloneAgentConfig(t *testing.T) {
t.Run("without providers", func(t *testing.T) {
in := AgentConfig{
Type: "codex",
Options: map[string]any{"mode": "default"},
}
got := cloneAgentConfig(in)
if got.Type != "codex" {
t.Errorf("Type = %q, want codex", got.Type)
}
if got.Options["mode"] != "default" {
t.Errorf("Options not cloned")
}
if len(got.Providers) != 0 {
t.Errorf("Providers length = %d, want 0", len(got.Providers))
}
})
t.Run("with providers", func(t *testing.T) {
in := AgentConfig{
Type: "claudecode",
Options: map[string]any{"work_dir": "/tmp/test"},
Providers: []ProviderConfig{
{
Name: "openai",
APIKey: "sk-test",
BaseURL: "https://api.openai.com",
Model: "gpt-4",
Thinking: "on",
Env: map[string]string{"DEBUG": "1"},
},
},
}
got := cloneAgentConfig(in)
if len(got.Providers) != 1 {
t.Fatalf("Providers length = %d, want 1", len(got.Providers))
}
p := got.Providers[0]
if p.Name != "openai" || p.APIKey != "sk-test" || p.BaseURL != "https://api.openai.com" || p.Model != "gpt-4" {
t.Errorf("Provider fields not cloned correctly: %+v", p)
}
if p.Env["DEBUG"] != "1" {
t.Errorf("Provider Env not cloned correctly")
}
// Verify deep copy of Options
got.Options["mode"] = "changed"
if in.Options["mode"] == "changed" {
t.Error("Options is same reference, not a deep copy")
}
// Verify deep copy of Provider Env
delete(got.Providers[0].Env, "DEBUG")
if in.Providers[0].Env["DEBUG"] == "" {
t.Error("Provider Env is same reference, not a deep copy")
}
})
}
func TestEnsureProjectWithWeixinPlatform_CreatesMissingProject(t *testing.T) {
configPath := writeConfigFixture(t, feishuConfigFixture)
patchConfigPath(t, configPath)
result, err := EnsureProjectWithWeixinPlatform(EnsureProjectWithWeixinOptions{
ProjectName: "gamma",
WorkDir: "/tmp/gamma",
})
if err != nil {
t.Fatalf("EnsureProjectWithWeixinPlatform returned error: %v", err)
}
if !result.Created {
t.Fatal("result.Created = false, want true")
}
if result.AddedPlatform {
t.Fatal("result.AddedPlatform = true, want false")
}
cfg := readConfigFixture(t, configPath)
if len(cfg.Projects) != 2 {
t.Fatalf("len(cfg.Projects) = %d, want 2", len(cfg.Projects))
}
proj := cfg.Projects[1]
if proj.Name != "gamma" {
t.Fatalf("proj.Name = %q, want %q", proj.Name, "gamma")
}
if len(proj.Platforms) != 1 {
t.Fatalf("len(proj.Platforms) = %d, want 1", len(proj.Platforms))
}
if proj.Platforms[0].Type != "weixin" {
t.Fatalf("platform type = %q, want weixin", proj.Platforms[0].Type)
}
}
func TestEnsureProjectWithWeixinPlatform_AddsPlatformWhenMissing(t *testing.T) {
configPath := writeConfigFixture(t, projectWithoutFeishuFixture)
patchConfigPath(t, configPath)
result, err := EnsureProjectWithWeixinPlatform(EnsureProjectWithWeixinOptions{
ProjectName: "beta",
})
if err != nil {
t.Fatalf("EnsureProjectWithWeixinPlatform returned error: %v", err)
}
if result.Created {
t.Fatal("result.Created = true, want false")
}
if !result.AddedPlatform {
t.Fatal("result.AddedPlatform = false, want true")
}
cfg := readConfigFixture(t, configPath)
proj := cfg.Projects[0]
if len(proj.Platforms) != 2 {
t.Fatalf("len(proj.Platforms) = %d, want 2", len(proj.Platforms))
}
if proj.Platforms[1].Type != "weixin" {
t.Fatalf("platform type = %q, want weixin", proj.Platforms[1].Type)
}
}
func TestSaveWeixinPlatformCredentials_UpdateToken(t *testing.T) {
configPath := writeConfigFixture(t, weixinConfigFixture)
patchConfigPath(t, configPath)
_, err := SaveWeixinPlatformCredentials(WeixinCredentialUpdateOptions{
ProjectName: "alpha",
Token: "new_weixin_token",
BaseURL: "https://ilinkai.weixin.qq.com",
})
if err != nil {
t.Fatalf("SaveWeixinPlatformCredentials returned error: %v", err)
}
cfg := readConfigFixture(t, configPath)
tok, _ := cfg.Projects[0].Platforms[0].Options["token"].(string)
if tok != "new_weixin_token" {
t.Fatalf("token = %q, want new_weixin_token", tok)
}
bu, _ := cfg.Projects[0].Platforms[0].Options["base_url"].(string)
if bu != "https://ilinkai.weixin.qq.com" {
t.Fatalf("base_url = %q", bu)
}
}
func TestSaveWeixinPlatformCredentials_AppendsScannedUserToAllowFrom(t *testing.T) {
configPath := writeConfigFixture(t, strings.Replace(weixinConfigFixture, `base_url = "https://ilink.example"`, "base_url = \"https://ilink.example\"\nallow_from = \"wx_user_1\"", 1))
patchConfigPath(t, configPath)
result, err := SaveWeixinPlatformCredentials(WeixinCredentialUpdateOptions{
ProjectName: "alpha",
Token: "new_weixin_token",
ScannedUserID: "wx_user_2",
SetAllowFromEmpty: true,
})
if err != nil {
t.Fatalf("SaveWeixinPlatformCredentials returned error: %v", err)
}
if result.AllowFrom != "wx_user_1,wx_user_2" {
t.Fatalf("result.AllowFrom = %q, want %q", result.AllowFrom, "wx_user_1,wx_user_2")
}
cfg := readConfigFixture(t, configPath)
if got := stringMapValue(cfg.Projects[0].Platforms[0].Options, "allow_from"); got != "wx_user_1,wx_user_2" {
t.Fatalf("allow_from = %q, want %q", got, "wx_user_1,wx_user_2")
}
}
func TestSaveWeixinPlatformCredentials_LeavesWildcardAllowFromUnchanged(t *testing.T) {
configPath := writeConfigFixture(t, strings.Replace(weixinConfigFixture, `base_url = "https://ilink.example"`, "base_url = \"https://ilink.example\"\nallow_from = \"*\"", 1))
patchConfigPath(t, configPath)
result, err := SaveWeixinPlatformCredentials(WeixinCredentialUpdateOptions{
ProjectName: "alpha",
Token: "new_weixin_token",
ScannedUserID: "wx_user_2",
SetAllowFromEmpty: true,
})
if err != nil {
t.Fatalf("SaveWeixinPlatformCredentials returned error: %v", err)
}
if result.AllowFrom != "*" {
t.Fatalf("result.AllowFrom = %q, want %q", result.AllowFrom, "*")
}
cfg := readConfigFixture(t, configPath)
if got := stringMapValue(cfg.Projects[0].Platforms[0].Options, "allow_from"); got != "*" {
t.Fatalf("allow_from = %q, want %q", got, "*")
}
}
func TestSaveProjectSettings_ExtraFields(t *testing.T) {
configPath := writeConfigFixture(t, feishuConfigFixture)
patchConfigPath(t, configPath)
show := true
wd := "/tmp/patched"
mode := "yolo"
err := SaveProjectSettings("alpha", ProjectSettingsUpdate{
WorkDir: &wd,
Mode: &mode,
ShowContextIndicator: &show,
PlatformAllowFrom: map[string]string{"telegram": "u1", "Feishu": "u2"},
})
if err != nil {
t.Fatalf("SaveProjectSettings: %v", err)
}
cfg := readConfigFixture(t, configPath)
proj := cfg.Projects[0]
if stringMapValue(proj.Agent.Options, "work_dir") != wd {
t.Fatalf("work_dir = %q, want %q", stringMapValue(proj.Agent.Options, "work_dir"), wd)
}
if stringMapValue(proj.Agent.Options, "mode") != mode {
t.Fatalf("mode = %q, want %q", stringMapValue(proj.Agent.Options, "mode"), mode)
}
if proj.ShowContextIndicator == nil || !*proj.ShowContextIndicator {
t.Fatalf("ShowContextIndicator = %v, want true", proj.ShowContextIndicator)
}
if stringMapValue(proj.Platforms[0].Options, "allow_from") != "u1" {
t.Fatalf("telegram allow_from = %q, want u1", stringMapValue(proj.Platforms[0].Options, "allow_from"))
}
if stringMapValue(proj.Platforms[1].Options, "allow_from") != "u2" {
t.Fatalf("feishu allow_from = %q, want u2", stringMapValue(proj.Platforms[1].Options, "allow_from"))
}
}
func TestGetProjectConfigDetails(t *testing.T) {
configPath := writeConfigFixture(t, feishuConfigFixture)
patchConfigPath(t, configPath)
details := GetProjectConfigDetails("alpha")
if details == nil {
t.Fatal("GetProjectConfigDetails returned nil")
}
if details["work_dir"] != "/tmp/alpha" {
t.Fatalf("work_dir = %v", details["work_dir"])
}
pcs, ok := details["platform_configs"].([]map[string]any)
if !ok || len(pcs) < 2 {
t.Fatalf("platform_configs = %#v", details["platform_configs"])
}
}
func TestAddPlatformToProject_NewProjectWithAgentTypeAndWorkDir(t *testing.T) {
configPath := writeConfigFixture(t, feishuConfigFixture)
patchConfigPath(t, configPath)
err := AddPlatformToProject("sigma", PlatformConfig{Type: "slack", Options: map[string]any{"token": "x"}}, "/sigma", "gemini")
if err != nil {
t.Fatalf("AddPlatformToProject: %v", err)
}
cfg := readConfigFixture(t, configPath)
if len(cfg.Projects) != 2 {
t.Fatalf("len(projects) = %d, want 2", len(cfg.Projects))
}
proj := cfg.Projects[1]
if proj.Name != "sigma" {
t.Fatalf("name = %q", proj.Name)
}
if proj.Agent.Type != "gemini" {
t.Fatalf("agent type = %q, want gemini", proj.Agent.Type)
}
if stringMapValue(proj.Agent.Options, "work_dir") != "/sigma" {
t.Fatalf("work_dir = %q", stringMapValue(proj.Agent.Options, "work_dir"))
}
if len(proj.Platforms) != 1 || proj.Platforms[0].Type != "slack" {
t.Fatalf("platforms = %#v", proj.Platforms)
}
}
func TestAddPlatformToProject_NewProjectClonesAgentWhenAgentTypeEmpty(t *testing.T) {
configPath := writeConfigFixture(t, feishuConfigFixture)
patchConfigPath(t, configPath)
err := AddPlatformToProject("tau", PlatformConfig{Type: "slack", Options: map[string]any{"token": "x"}}, "", "")
if err != nil {
t.Fatalf("AddPlatformToProject: %v", err)
}
cfg := readConfigFixture(t, configPath)
proj := cfg.Projects[len(cfg.Projects)-1]
if proj.Agent.Type != "codex" {
t.Fatalf("agent type = %q, want codex (cloned)", proj.Agent.Type)
}
if stringMapValue(proj.Agent.Options, "work_dir") != "/tmp/alpha" {
t.Fatalf("cloned work_dir = %q, want /tmp/alpha", stringMapValue(proj.Agent.Options, "work_dir"))
}
}
func TestFormatTOML(t *testing.T) {
tests := []struct {
name, input, want string
}{
{
name: "collapse multiple blank lines",
input: "a = 1\n\n\n\nb = 2\n",
want: "a = 1\n\nb = 2\n",
},
{
name: "blank line before section header",
input: "a = 1\n[section]\nb = 2\n",
want: "a = 1\n\n[section]\nb = 2\n",
},
{
name: "strip trailing whitespace",
input: "a = 1 \nb = 2\t\n",
want: "a = 1\nb = 2\n",
},
{
name: "remove empty section",
input: "[empty]\n\n[real]\nk = 1\n",
want: "[real]\nk = 1\n",
},
{
name: "already formatted",
input: "[section]\na = 1\n",
want: "[section]\na = 1\n",
},
{
name: "preserves comments",
input: "# comment\na = 1\n\n[section]\n# inline\nb = 2\n",
want: "# comment\na = 1\n\n[section]\n# inline\nb = 2\n",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := formatTOML(tc.input)
if got != tc.want {
t.Errorf("formatTOML:\n input: %q\n got: %q\n want: %q", tc.input, got, tc.want)
}
})
}
}
func TestFormatConfigFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
messy := "language = \"en\" \n\n\n\n[[projects]]\nname = \"test\"\n\n\n[projects.agent]\ntype = \"codex\"\n\n[projects.agent.options]\n\n[[projects.platforms]]\ntype = \"telegram\"\n\n[projects.platforms.options]\ntoken = \"abc\"\n"
os.WriteFile(path, []byte(messy), 0o644)
if err := FormatConfigFile(path); err != nil {
t.Fatalf("FormatConfigFile: %v", err)
}
data, _ := os.ReadFile(path)
content := string(data)
if strings.Contains(content, " \n") {
t.Error("trailing whitespace not stripped")
}
if strings.Contains(content, "\n\n\n") {
t.Error("consecutive blank lines not collapsed")
}
cfg := &Config{}
if _, err := toml.Decode(content, cfg); err != nil {
t.Fatalf("formatted config is invalid TOML: %v", err)
}
if len(cfg.Projects) != 1 || cfg.Projects[0].Name != "test" {
t.Error("formatting corrupted config content")
}
t.Run("no-op when already formatted", func(t *testing.T) {
before, _ := os.ReadFile(path)
if err := FormatConfigFile(path); err != nil {
t.Fatalf("second FormatConfigFile: %v", err)
}
after, _ := os.ReadFile(path)
if string(before) != string(after) {
t.Error("idempotent format produced different output")
}
})
t.Run("rejects invalid TOML", func(t *testing.T) {
badPath := filepath.Join(dir, "bad.toml")
os.WriteFile(badPath, []byte("[invalid\n"), 0o644)
if err := FormatConfigFile(badPath); err == nil {
t.Error("expected error for invalid TOML")
}
})
}
func TestResolveProviderRefs(t *testing.T) {
cfg := &Config{
Providers: []ProviderConfig{
{Name: "global-a", APIKey: "key-a", BaseURL: "https://a.com"},
{Name: "global-b", APIKey: "key-b", BaseURL: "https://b.com"},
},
Projects: []ProjectConfig{
{
Name: "proj-with-refs",
Agent: AgentConfig{
Type: "claudecode",
ProviderRefs: []string{"global-a", "global-b"},
},
},
{
Name: "proj-inline-only",
Agent: AgentConfig{
Type: "codex",
Providers: []ProviderConfig{
{Name: "inline-p", APIKey: "inline-key"},
},
},
},
{
Name: "proj-mixed",
Agent: AgentConfig{
Type: "claudecode",
ProviderRefs: []string{"global-a", "global-b"},
Providers: []ProviderConfig{
{Name: "global-a", APIKey: "override-key", BaseURL: "https://override.com"},
},
},
},
},
}
cfg.ResolveProviderRefs()
// proj-with-refs: should have both global providers
p0 := cfg.Projects[0].Agent.Providers
if len(p0) != 2 {
t.Fatalf("proj-with-refs: expected 2 providers, got %d", len(p0))
}
if p0[0].Name != "global-a" || p0[0].APIKey != "key-a" {
t.Errorf("proj-with-refs[0]: expected global-a/key-a, got %s/%s", p0[0].Name, p0[0].APIKey)
}
if p0[1].Name != "global-b" || p0[1].APIKey != "key-b" {
t.Errorf("proj-with-refs[1]: expected global-b/key-b, got %s/%s", p0[1].Name, p0[1].APIKey)
}
// proj-inline-only: should remain unchanged
p1 := cfg.Projects[1].Agent.Providers
if len(p1) != 1 || p1[0].Name != "inline-p" {
t.Errorf("proj-inline-only: expected 1 inline provider, got %d", len(p1))
}
// proj-mixed: inline override takes precedence for global-a, global-b from ref
p2 := cfg.Projects[2].Agent.Providers
if len(p2) != 2 {
t.Fatalf("proj-mixed: expected 2 providers, got %d", len(p2))
}
// global-b is resolved from ref (since no inline override)
if p2[0].Name != "global-b" || p2[0].APIKey != "key-b" {
t.Errorf("proj-mixed[0]: expected global-b from ref, got %s/%s", p2[0].Name, p2[0].APIKey)
}
// global-a is from inline override
if p2[1].Name != "global-a" || p2[1].APIKey != "override-key" {
t.Errorf("proj-mixed[1]: expected global-a override, got %s/%s", p2[1].Name, p2[1].APIKey)
}
}
func TestResolveProviderRefs_MissingRef(t *testing.T) {
cfg := &Config{
Providers: []ProviderConfig{
{Name: "exists", APIKey: "key"},
},
Projects: []ProjectConfig{
{
Name: "proj",
Agent: AgentConfig{
Type: "claudecode",
ProviderRefs: []string{"exists", "nonexistent"},
},
},
},
}
cfg.ResolveProviderRefs()
providers := cfg.Projects[0].Agent.Providers
if len(providers) != 1 || providers[0].Name != "exists" {
t.Errorf("expected 1 resolved provider 'exists', got %d: %+v", len(providers), providers)
}
}
func TestResolveProviderRefs_AgentTypeFiltering(t *testing.T) {
cfg := &Config{
Providers: []ProviderConfig{
{Name: "claude-only", APIKey: "key-c", AgentTypes: []string{"claudecode"}},
{Name: "codex-only", APIKey: "key-x", AgentTypes: []string{"codex"}},
{Name: "universal", APIKey: "key-u"}, // no agent_types = works for all
},
Projects: []ProjectConfig{
{
Name: "proj-claude",
Agent: AgentConfig{
Type: "claudecode",
ProviderRefs: []string{"claude-only", "codex-only", "universal"},
},
},
{
Name: "proj-codex",
Agent: AgentConfig{
Type: "codex",
ProviderRefs: []string{"claude-only", "codex-only", "universal"},
},
},
},
}
cfg.ResolveProviderRefs()
// claudecode project: gets claude-only + universal, skips codex-only
p0 := cfg.Projects[0].Agent.Providers
if len(p0) != 2 {
t.Fatalf("proj-claude: expected 2 providers, got %d: %+v", len(p0), p0)
}
if p0[0].Name != "claude-only" {
t.Errorf("proj-claude[0]: expected claude-only, got %s", p0[0].Name)
}
if p0[1].Name != "universal" {
t.Errorf("proj-claude[1]: expected universal, got %s", p0[1].Name)
}
// codex project: gets codex-only + universal, skips claude-only
p1 := cfg.Projects[1].Agent.Providers
if len(p1) != 2 {
t.Fatalf("proj-codex: expected 2 providers, got %d: %+v", len(p1), p1)
}
if p1[0].Name != "codex-only" {
t.Errorf("proj-codex[0]: expected codex-only, got %s", p1[0].Name)
}
if p1[1].Name != "universal" {
t.Errorf("proj-codex[1]: expected universal, got %s", p1[1].Name)
}
}
func TestResolveProviderRefs_NoGlobalProviders(t *testing.T) {
cfg := &Config{
Projects: []ProjectConfig{
{
Name: "proj",
Agent: AgentConfig{
Type: "claudecode",
ProviderRefs: []string{"foo"},
Providers: []ProviderConfig{
{Name: "bar", APIKey: "key"},
},
},
},
},
}
cfg.ResolveProviderRefs()
providers := cfg.Projects[0].Agent.Providers
if len(providers) != 1 || providers[0].Name != "bar" {
t.Errorf("expected only inline provider 'bar', got %+v", providers)
}
}
func TestResolveProviderRefs_Basic(t *testing.T) {
cfg := &Config{
Providers: []ProviderConfig{
{Name: "global1", APIKey: "key1", BaseURL: "https://example.com", Model: "model-a"},
},
Projects: []ProjectConfig{{
Name: "proj",
Agent: AgentConfig{
Type: "claudecode",
ProviderRefs: []string{"global1"},
},
}},
}
cfg.ResolveProviderRefs()
ps := cfg.Projects[0].Agent.Providers
if len(ps) != 1 || ps[0].Name != "global1" || ps[0].BaseURL != "https://example.com" {
t.Fatalf("expected resolved global1, got %+v", ps)
}
}
func TestResolveProviderRefs_AgentTypesFilter(t *testing.T) {
cfg := &Config{
Providers: []ProviderConfig{
{Name: "claude-only", AgentTypes: []string{"claudecode"}},
{Name: "codex-only", AgentTypes: []string{"codex"}},
{Name: "universal"},
},
Projects: []ProjectConfig{{
Name: "codex-proj",
Agent: AgentConfig{
Type: "codex",
ProviderRefs: []string{"claude-only", "codex-only", "universal"},
},
}},
}
cfg.ResolveProviderRefs()
ps := cfg.Projects[0].Agent.Providers
names := make([]string, len(ps))
for i, p := range ps {
names[i] = p.Name
}
if len(ps) != 2 {
t.Fatalf("expected 2 providers (codex-only + universal), got %v", names)
}
if names[0] != "codex-only" || names[1] != "universal" {
t.Fatalf("unexpected providers: %v", names)
}
}
func TestResolveProviderRefs_EndpointsOverride(t *testing.T) {
cfg := &Config{
Providers: []ProviderConfig{{
Name: "multi",
BaseURL: "https://provider.com/api",
Model: "claude-sonnet-4",
Endpoints: map[string]string{
"codex": "https://provider.com/api/v1",
},
AgentModels: map[string]string{
"codex": "openai/gpt-5.3-codex",
},
}},
Projects: []ProjectConfig{
{
Name: "claude-proj",
Agent: AgentConfig{
Type: "claudecode",
ProviderRefs: []string{"multi"},
},
},
{
Name: "codex-proj",
Agent: AgentConfig{
Type: "codex",
ProviderRefs: []string{"multi"},
},
},
},
}
cfg.ResolveProviderRefs()
// claudecode project: should keep original base_url and model
cp := cfg.Projects[0].Agent.Providers
if len(cp) != 1 {
t.Fatalf("claude-proj: expected 1 provider, got %d", len(cp))
}
if cp[0].BaseURL != "https://provider.com/api" {
t.Errorf("claude-proj: base_url = %q, want original", cp[0].BaseURL)
}
if cp[0].Model != "claude-sonnet-4" {
t.Errorf("claude-proj: model = %q, want original", cp[0].Model)
}
// codex project: should have overridden base_url and model
xp := cfg.Projects[1].Agent.Providers
if len(xp) != 1 {
t.Fatalf("codex-proj: expected 1 provider, got %d", len(xp))
}
if xp[0].BaseURL != "https://provider.com/api/v1" {
t.Errorf("codex-proj: base_url = %q, want codex endpoint", xp[0].BaseURL)
}
if xp[0].Model != "openai/gpt-5.3-codex" {
t.Errorf("codex-proj: model = %q, want codex model", xp[0].Model)
}
}
func TestResolveProviderRefs_SplitProviderPattern(t *testing.T) {
cfg := &Config{
Providers: []ProviderConfig{
{
Name: "ssy",
APIKey: "key-xxx",
BaseURL: "https://router.example.com/api",
Model: "claude-sonnet-4-6",
AgentTypes: []string{"claudecode", "gemini"},
Models: []ProviderModelConfig{
{Model: "claude-sonnet-4-6"},
{Model: "claude-opus-4"},
},
},
{
Name: "ssy-codex",
APIKey: "key-xxx",
BaseURL: "https://router.example.com/api/v1",
Model: "openai/gpt-5.3-codex",
AgentTypes: []string{"codex"},
Models: []ProviderModelConfig{
{Model: "openai/gpt-5.3-codex"},
{Model: "openai/gpt-5.4"},
},
Codex: &CodexProviderConfig{WireAPI: "responses"},
},
},
Projects: []ProjectConfig{
{
Name: "my-claude",
Agent: AgentConfig{
Type: "claudecode",
ProviderRefs: []string{"ssy", "ssy-codex"},
},
},
{
Name: "my-codex",
Agent: AgentConfig{
Type: "codex",
ProviderRefs: []string{"ssy", "ssy-codex"},
},
},
},
}
cfg.ResolveProviderRefs()
// claudecode project should only get "ssy" (not ssy-codex)
cp := cfg.Projects[0].Agent.Providers
if len(cp) != 1 || cp[0].Name != "ssy" {
names := make([]string, len(cp))
for i, p := range cp {
names[i] = p.Name
}
t.Fatalf("claude project: expected [ssy], got %v", names)
}
if len(cp[0].Models) != 2 || cp[0].Models[0].Model != "claude-sonnet-4-6" {
t.Errorf("claude project: unexpected models: %+v", cp[0].Models)
}
// codex project should only get "ssy-codex" (not ssy)
xp := cfg.Projects[1].Agent.Providers
if len(xp) != 1 || xp[0].Name != "ssy-codex" {
names := make([]string, len(xp))
for i, p := range xp {
names[i] = p.Name
}
t.Fatalf("codex project: expected [ssy-codex], got %v", names)
}
if xp[0].BaseURL != "https://router.example.com/api/v1" {
t.Errorf("codex project: base_url = %q", xp[0].BaseURL)
}
if xp[0].Model != "openai/gpt-5.3-codex" {
t.Errorf("codex project: model = %q", xp[0].Model)
}
if xp[0].Codex == nil || xp[0].Codex.WireAPI != "responses" {
t.Errorf("codex project: codex config missing or wrong: %+v", xp[0].Codex)
}
}
func TestResolveProviderRefs_InlineOverridesGlobal(t *testing.T) {
cfg := &Config{
Providers: []ProviderConfig{
{Name: "global1", BaseURL: "https://global.com", Model: "global-model"},
},
Projects: []ProjectConfig{{
Name: "proj",
Agent: AgentConfig{
Type: "claudecode",
ProviderRefs: []string{"global1"},
Providers: []ProviderConfig{
{Name: "global1", BaseURL: "https://override.com", Model: "override-model"},
},
},
}},
}
cfg.ResolveProviderRefs()
ps := cfg.Projects[0].Agent.Providers
if len(ps) != 1 {
t.Fatalf("expected 1 provider (inline override), got %d", len(ps))
}
if ps[0].BaseURL != "https://override.com" {
t.Errorf("inline override not applied: base_url = %q", ps[0].BaseURL)
}
}
func TestResolveProviderRefs_TOMLParsing(t *testing.T) {
input := `
[[providers]]
name = "ssy"
api_key = "key123"
base_url = "https://router.example.com/api"
model = "claude-sonnet-4-6"
agent_types = ["claudecode", "gemini"]
[[providers.models]]
model = "claude-sonnet-4-6"
[[providers]]
name = "ssy-codex"
api_key = "key123"
base_url = "https://router.example.com/api/v1"
model = "openai/gpt-5.3-codex"
agent_types = ["codex"]
[providers.endpoints]
codex = "https://router.example.com/api/v1"
[providers.agent_models]
codex = "openai/gpt-5.3-codex"
[[providers.models]]
model = "openai/gpt-5.3-codex"
[providers.codex]
wire_api = "responses"
[[projects]]
name = "test-codex"
[projects.agent]
type = "codex"
provider_refs = ["ssy", "ssy-codex"]
[[projects.platforms]]
type = "feishu"
[projects.platforms.options]
app_id = "test"
app_secret = "test"
`
var cfg Config
if _, err := toml.Decode(input, &cfg); err != nil {
t.Fatalf("TOML decode: %v", err)
}
if len(cfg.Providers) != 2 {
t.Fatalf("expected 2 global providers, got %d", len(cfg.Providers))
}
codexProv := cfg.Providers[1]
if codexProv.Codex == nil {
t.Fatal("ssy-codex: codex config not parsed")
}
if codexProv.Codex.WireAPI != "responses" {
t.Errorf("ssy-codex: wire_api = %q, want responses", codexProv.Codex.WireAPI)
}
if codexProv.Endpoints["codex"] != "https://router.example.com/api/v1" {
t.Errorf("ssy-codex: endpoints not parsed: %+v", codexProv.Endpoints)
}
cfg.ResolveProviderRefs()
ps := cfg.Projects[0].Agent.Providers
if len(ps) != 1 || ps[0].Name != "ssy-codex" {
names := make([]string, len(ps))
for i, p := range ps {
names[i] = p.Name
}
t.Fatalf("expected [ssy-codex], got %v", names)
}
}
func TestRemoveGlobalProvider_CleansUpProviderRefs(t *testing.T) {
input := `
[[providers]]
name = "prov-a"
api_key = "key-a"
[[providers]]
name = "prov-b"
api_key = "key-b"
[[projects]]
name = "proj1"
[projects.agent]
type = "claudecode"
provider_refs = ["prov-a", "prov-b"]
[[projects.platforms]]
type = "feishu"
[projects.platforms.options]
app_id = "x"
app_secret = "y"
[[projects]]
name = "proj2"
[projects.agent]
type = "codex"
provider_refs = ["prov-a"]
[[projects.platforms]]
type = "telegram"
[projects.platforms.options]
token = "t"
`
writeTestConfig(t, input)
if err := RemoveGlobalProvider("prov-a"); err != nil {
t.Fatalf("RemoveGlobalProvider: %v", err)
}
cfg, err := loadLocked()
if err != nil {
t.Fatalf("loadLocked: %v", err)
}
if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "prov-b" {
t.Fatalf("expected only prov-b remaining, got %v", cfg.Providers)
}
refs1 := cfg.Projects[0].Agent.ProviderRefs
if len(refs1) != 1 || refs1[0] != "prov-b" {
t.Errorf("proj1 provider_refs: want [prov-b], got %v", refs1)
}
refs2 := cfg.Projects[1].Agent.ProviderRefs
if len(refs2) != 0 {
t.Errorf("proj2 provider_refs: want [], got %v", refs2)
}
}