Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions cmd/late/bootstrap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package main

import (
"errors"
"fmt"
"strings"
"testing"
)

// TestInitialBootstrapStatus guards the pre-program status-bar decision:
// a failed app-config load surfaces as a "config error: ..." warning (Step 1
// wraps the error with the exact config path, so the path must survive
// verbatim), while a clean load returns "" so main() falls back to the
// plain "Starting..." text.
func TestInitialBootstrapStatus(t *testing.T) {
tests := []struct {
name string
loadErr error
want string
}{
{
name: "clean load defers to the plain startup status",
loadErr: nil,
want: "",
},
{
name: "load error becomes a config error warning",
loadErr: errors.New("/Users/u/Library/Application Support/late/config.json: trailing comma at line 3"),
want: "config error: /Users/u/Library/Application Support/late/config.json: trailing comma at line 3",
},
{
name: "wrapped error keeps its full cause text",
loadErr: fmt.Errorf("reading config %s: %w", "/Users/u/Library/Application Support/late/config.json", errors.New("invalid character '}' looking for beginning of value")),
want: "config error: reading config /Users/u/Library/Application Support/late/config.json: invalid character '}' looking for beginning of value",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := initialBootstrapStatus(tt.loadErr)
if got != tt.want {
t.Fatalf("initialBootstrapStatus(%v) = %q, want %q", tt.loadErr, got, tt.want)
}
if tt.loadErr != nil {
if !strings.HasPrefix(got, "config error: ") {
t.Fatalf("warning must be prefixed with %q, got %q", "config error: ", got)
}
if !strings.Contains(got, "config.json") {
t.Fatalf("warning must retain the config path, got %q", got)
}
}
})
}
}
28 changes: 26 additions & 2 deletions cmd/late/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,12 @@ func main() {
}
}
}
// Load App configuration
// Load App configuration. A load error means the run proceeds with
// degraded defaults (LoadConfig already wraps the error with the exact
// config path); keep the message so the TUI status bar can surface it
// before backend discovery reports.
appConfig, err := appconfig.LoadConfig()
configLoadWarning := initialBootstrapStatus(err)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to load app config: %v\n", err)
}
Expand Down Expand Up @@ -676,7 +680,15 @@ func main() {
pOpts = append(pOpts, tea.WithWindowSize(w, h))
}

model.BootstrapStatus = "Starting..."
// A degraded app config surfaces as the initial status-bar text so the
// user sees it on the first paint; the async BootstrapStatusMsg traffic
// below replaces it as soon as backend discovery reports. "Starting..."
// only applies to a clean config load.
if configLoadWarning != "" {
model.BootstrapStatus = configLoadWarning
} else {
model.BootstrapStatus = "Starting..."
}
p := tea.NewProgram(model, pOpts...)

// toolSync serializes plugin/MCP tool-registry refreshes triggered by
Expand Down Expand Up @@ -788,6 +800,18 @@ func deriveEffectiveSessionID(historyPath string) string {
}
return id
}

// initialBootstrapStatus decides what the TUI status bar shows before the
// async backend-discovery messages arrive. A failed app-config load returns
// the "config error: ..." warning (the error already names the exact config
// path); a clean load returns "" so the caller falls back to "Starting...".
func initialBootstrapStatus(loadErr error) string {
if loadErr != nil {
return fmt.Sprintf("config error: %v", loadErr)
}
return ""
}

func newModelClient(ctx context.Context, setting appconfig.ModelSetting, enableImages bool, logitBias map[string]int) *client.Client {
c := client.NewClient(client.Config{
BaseURL: setting.URL,
Expand Down
21 changes: 19 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ type Config struct {
Theme string `json:"theme,omitempty"`
Models []ModelSetting `json:"models,omitempty"`
AgentModels map[string]string `json:"agent_models,omitempty"`

// Degraded is set by LoadConfig when config.json exists but could not
// be read or parsed: the returned config is a fallback default, not
// the user's real settings. SaveConfig refuses to persist a degraded
// config so that runtime toggles (/infobar, /timestamps, /model)
// cannot overwrite the user's hand-edited config.json with defaults.
// It is never serialized (json:"-") and is NOT set when the file is
// merely missing — that is a normal fresh install.
Degraded bool `json:"-"`
}

func defaultConfig() Config {
Expand Down Expand Up @@ -136,15 +145,17 @@ func LoadConfig() (*Config, error) {
}

fallback := defaultConfig()
return &fallback, err
fallback.Degraded = true
return &fallback, fmt.Errorf("failed to read %s: %w", configPath, err)
}

permErr := ensureSecureConfigPermissions(lateConfigDir, configPath)

var cfg Config
if err := json.Unmarshal(content, &cfg); err != nil {
fallback := defaultConfig()
return &fallback, err
fallback.Degraded = true
return &fallback, fmt.Errorf("failed to parse %s: %w", configPath, err)
}

if cfg.EnabledTools == nil {
Expand Down Expand Up @@ -361,7 +372,13 @@ func (cfg *Config) GetModelForAgent(agentType string) (ModelSetting, bool) {
}

// SaveConfig atomically writes the configuration back to config.json.
// A degraded config (loaded from an invalid config.json) is never saved:
// the caller must fix or remove the file first, so a fallback default can
// never clobber the user's hand-edited config.
func SaveConfig(cfg *Config) error {
if cfg != nil && cfg.Degraded {
return fmt.Errorf("refusing to save config: it was loaded from an invalid config.json; fix or remove the file first")
}
lateConfigDir, err := pathutil.LateConfigDir()
if err != nil {
return err
Expand Down
137 changes: 137 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -838,3 +838,140 @@ func TestConfig_PermissionModeJSONRoundTrip(t *testing.T) {
t.Fatalf("empty config should not marshal a permission-mode key, got %s", emptyData)
}
}

// TestLoadConfig_DegradationGuard covers the config degradation guard: a
// config.json that cannot be parsed or read yields the fallback default
// together with an error naming the exact file path and a Degraded flag
// that makes SaveConfig refuse to overwrite the user's file. A valid file
// (including unknown extra keys, which must keep parsing permissively)
// loads non-degraded and saves normally. A missing file (fresh install) is
// covered by TestLoadConfig_MissingFileCreatesDefault and stays
// non-degraded.
func TestLoadConfig_DegradationGuard(t *testing.T) {
cases := []struct {
name string
configContent string
wantErr bool
wantPathInErr bool
wantDegraded bool
// roundTripModel: non-empty for savable configs — SaveConfig must
// succeed and the model must survive a save/reload round trip.
roundTripModel string
}{
{
name: "valid config parses without degradation",
configContent: `{"enabled_tools":{"bash":true},"openai_model":"gpt-test"}`,
wantErr: false,
wantDegraded: false,
roundTripModel: "gpt-test",
},
{
name: "trailing comma is a parse error naming the path",
configContent: `{"enabled_tools":{"bash":true},}`,
wantErr: true,
wantPathInErr: true,
wantDegraded: true,
},
{
name: "wrong-typed field is a parse error naming the path",
configContent: `{"theme":123}`,
wantErr: true,
wantPathInErr: true,
wantDegraded: true,
},
{
name: "unknown extra field parses permissively",
configContent: `{"totally-new-option":123}`,
wantErr: false,
wantDegraded: false,
roundTripModel: "",
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
configRoot := t.TempDir()
setUserConfigEnv(t, configRoot)
configPath := lateConfigPath(t)

if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(configPath, []byte(tc.configContent), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := LoadConfig()
if tc.wantErr {
if err == nil {
t.Fatal("LoadConfig() expected an error, got nil")
}
if tc.wantPathInErr && !strings.Contains(err.Error(), configPath) {
t.Fatalf("LoadConfig() error = %q, want it to contain the config path %q", err.Error(), configPath)
}
} else if err != nil {
t.Fatalf("LoadConfig() error = %v, want nil", err)
}
if cfg == nil {
t.Fatal("LoadConfig() returned nil config")
}
if cfg.Degraded != tc.wantDegraded {
t.Fatalf("cfg.Degraded = %v, want %v", cfg.Degraded, tc.wantDegraded)
}

if !tc.wantDegraded {
if err := SaveConfig(cfg); err != nil {
t.Fatalf("SaveConfig() error = %v, want nil for a non-degraded config", err)
}
if tc.roundTripModel != "" {
reloaded, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig() after save error = %v", err)
}
if reloaded.OpenAIModel != tc.roundTripModel {
t.Fatalf("round-tripped OpenAIModel = %q, want %q", reloaded.OpenAIModel, tc.roundTripModel)
}
if reloaded.Degraded {
t.Fatal("reloaded config after a normal save must not be degraded")
}
}
return
}

before, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
saveErr := SaveConfig(cfg)
if saveErr == nil {
t.Fatal("SaveConfig() expected a refusal error for a degraded config, got nil")
}
if !strings.Contains(saveErr.Error(), "refusing to save config") {
t.Fatalf("SaveConfig() error = %q, want it to mention refusing to save", saveErr.Error())
}
after, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if string(after) != string(before) {
t.Fatalf("SaveConfig() modified a degraded config's file:\nbefore: %s\nafter: %s", before, after)
}
})
}
}

// TestConfig_DegradedNotSerialized pins that the runtime-only Degraded flag
// never leaks into config.json (json:"-").
func TestConfig_DegradedNotSerialized(t *testing.T) {
data, err := json.Marshal(&Config{Degraded: true})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if _, ok := raw["Degraded"]; ok {
t.Fatalf("Degraded must not be serialized, got %s", data)
}
}
Loading
Loading