Files
DeepSeek-Reasonix/internal/contract/config/config.go
T

1586 lines
61 KiB
Go

// Package config loads Reasonix's runtime configuration from TOML. Resolution order:
// flag > project config > user config.toml (in the OS user-config dir) > built-in defaults.
// Project config is ./reasonix.toml, falling back to .reasonix/reasonix.toml.
// Secrets come from the environment via api_key_env and are never stored in config files.
package config
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"golang.org/x/text/unicode/norm"
"reasonix/internal/base/netclient"
)
// Config is Reasonix's runtime configuration.
type Config struct {
ConfigVersion int `toml:"config_version"`
DefaultModel string `toml:"default_model"`
Language string `toml:"language"` // ui/model language tag (e.g. "zh"); empty = auto-detect from $LANG / $REASONIX_LANG
CredentialsStore string `toml:"credentials_store"`
UI UIConfig `toml:"ui"`
CLI CLIConfig `toml:"cli"`
Desktop DesktopConfig `toml:"desktop"`
Billing BillingConfig `toml:"billing"`
Telemetry TelemetryConfig `toml:"telemetry"`
Notifications NotificationsConfig `toml:"notifications"`
ProgressWatch ProgressWatchConfig `toml:"progress_watch"`
AutoArchive AutoArchiveConfig `toml:"auto_archive"`
Agent AgentConfig `toml:"agent"`
Providers []ProviderEntry `toml:"providers"`
Tools ToolsConfig `toml:"tools"`
Permissions PermissionsConfig `toml:"permissions"`
Sandbox SandboxConfig `toml:"sandbox"`
Network NetworkConfig `toml:"network"`
Environment EnvironmentConfig `toml:"environment"`
Plugins []PluginEntry `toml:"plugins"`
Skills SkillsConfig `toml:"skills"`
Memory MemoryConfig `toml:"memory"`
Statusline StatuslineConfig `toml:"statusline"`
LSP LSPConfig `toml:"lsp"`
Browser BrowserConfig `toml:"browser"`
Bot map[string]any `toml:"bot"` // opaque; see passthrough.go
Serve ServeConfig `toml:"serve"`
Attachments AttachmentsConfig `toml:"attachments,omitempty"`
Secrets SecretsConfig `toml:"secrets"`
Remote RemoteConfig `toml:"remote"`
// AutoSubmit commits a multi-question ask once its last question is answered,
// instead of showing the Submit tab. User/global only; a repo cannot set it.
AutoSubmit bool `toml:"auto_submit"`
// Peers governs messages between sessions; see PeersConfig.
Peers PeersConfig `toml:"peers"`
// Observability governs the opt-in trace export. User/global only.
Observability ObservabilityConfig `toml:"observability"`
// Mods governs Claude Code mods; see ModsConfig.
Mods ModsConfig `toml:"mods"`
// Storage relocates the movable roots, keyed by RootID. User/global only.
Storage map[string]string `toml:"storage"`
roots Roots
systemPromptFileSource promptFileSource
providerSources map[string]providerSourceScope
shadowedProjectProviders []ProviderEntry
ignoredProjectDefaultModel string
ignoredLegacyStepLimits bool
expansionEnv map[string]string
pluginPackageOwners map[string]string
pluginPackageSkillOwners map[string][]string
pluginPackageAgentOwners map[string][]string
// explicitProjectSkillKeys records project-level skill fields that the
// settings UI intentionally owns even when their value equals the built-in
// default. It is transient edit metadata and is never serialized directly.
explicitProjectSkillKeys map[string]bool
// Project provider declarations and the normalized edit baseline distinguish
// file-authored overrides from providers synthesized by provider_access.
explicitProjectProviderNames map[string]bool
projectProviderEditBaseline []ProviderEntry
editLoadErr error
// loadWarnings are non-fatal issues observed while loading config (corrupt
// user/project files recovered via last-known-good or defaults). They never
// rewrite the original file; the UI may surface them for doctor repair.
loadWarnings []string
projectScope projectScopeReport
}
// KeepProjectSkillKey marks a skill field as an intentional project override.
// An explicit empty/false project value must still be written so it can
// override a non-default user setting in the layered configuration.
func (c *Config) KeepProjectSkillKey(key string) error {
key = strings.TrimSpace(key)
switch key {
case "paths", "excluded_paths", "disabled_skills", "disable_implicit_invocation", "suppress_warnings", "max_depth":
default:
return fmt.Errorf("unknown project skill key %q", key)
}
if c.explicitProjectSkillKeys == nil {
c.explicitProjectSkillKeys = make(map[string]bool)
}
c.explicitProjectSkillKeys[key] = true
return nil
}
func (c *Config) keepsProjectSkillKey(key string) bool {
return c != nil && c.explicitProjectSkillKeys[key]
}
type promptFileSource uint8
const (
promptFileSourceUnknown promptFileSource = iota
promptFileSourceUser
promptFileSourceProject
)
type systemPromptFileError struct {
configured string
candidates []string
errors []error
allMissing bool
}
func (e *systemPromptFileError) Error() string {
detail := "could not be read from any configured location"
if e.allMissing {
detail = "not found at any configured location"
}
message := fmt.Sprintf("system_prompt_file %q %s: %s", e.configured, detail, strings.Join(e.candidates, ", "))
if !e.allMissing && len(e.errors) > 0 {
message += ": " + errors.Join(e.errors...).Error()
}
return message
}
func (e *systemPromptFileError) Unwrap() error { return errors.Join(e.errors...) }
// IsMissingSystemPromptFile reports whether every allowed location for a
// configured prompt file was absent. Permission, containment, and other I/O
// failures deliberately return false so callers do not start without an
// explicitly configured prompt.
func IsMissingSystemPromptFile(err error) bool {
var target *systemPromptFileError
return errors.As(err, &target) && target.allMissing
}
// TelemetryConfig controls content-free CLI usage metrics. It is user-global:
// project reasonix.toml values are ignored so a cloned repository cannot opt a
// user into reporting.
type TelemetryConfig struct {
CLIMetrics string `toml:"cli_metrics"` // auto|on|off; empty means consent has not been requested
}
// CLITelemetryConfigured reports whether the user has made an explicit CLI
// telemetry choice. The runtime policy still treats an absent value as auto,
// but persistence must preserve absence until the first eligible consent prompt.
func (c *Config) CLITelemetryConfigured() bool {
if c == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(c.Telemetry.CLIMetrics)) {
case "auto", "on", "off":
return true
default:
return false
}
}
// CLITelemetryMode returns the normalized CLI telemetry policy.
func (c *Config) CLITelemetryMode() string {
if c == nil {
return "auto"
}
switch strings.ToLower(strings.TrimSpace(c.Telemetry.CLIMetrics)) {
case "on":
return "on"
case "off":
return "off"
default:
return "auto"
}
}
// LoadWarnings returns non-fatal config load issues (corrupt files recovered in
// memory). The returned slice is a copy.
func (c *Config) LoadWarnings() []string {
if c == nil || len(c.loadWarnings) == 0 {
return nil
}
out := make([]string, len(c.loadWarnings))
copy(out, c.loadWarnings)
return out
}
// HasLoadWarnings reports whether the load used a degraded in-memory fallback.
func (c *Config) HasLoadWarnings() bool {
return c != nil && len(c.loadWarnings) > 0
}
func (c *Config) addLoadWarning(msg string) {
if c == nil {
return
}
msg = strings.TrimSpace(msg)
if msg == "" {
return
}
c.loadWarnings = append(c.loadWarnings, msg)
}
// IgnoredLegacyAgentStepLimits reports whether this load found and ignored the
// retired [agent].max_steps or planner_max_steps settings. Boot removes standard
// key assignments before loading, while read-only/config-only loads only report
// and normalize them in memory.
func (c *Config) IgnoredLegacyAgentStepLimits() bool {
return c != nil && c.ignoredLegacyStepLimits
}
// IgnoredProjectDefaultModel returns the project reasonix.toml default_model
// that LoadForRoot ignored because no configured provider serves it (see
// restoreUnresolvableProjectDefaultModel), or "" when none was ignored.
func (c *Config) IgnoredProjectDefaultModel() string {
if c == nil {
return ""
}
return c.ignoredProjectDefaultModel
}
// SecretsConfig controls the credential protection layers. It is a user-global
// setting: project reasonix.toml values are ignored (see LoadForRoot), so a
// cloned repository cannot silently opt the user into workflow-breaking
// protections.
type SecretsConfig struct {
// FilterSubprocessEnv strips credential-like environment variables
// (*_API_KEY, *TOKEN*, *SECRET*, ...) from tool subprocesses (bash, hooks,
// LSP, MCP stdio). Default off: it breaks token-based workflows such as
// `gh`, HTTPS `git push`, and `npm publish`.
FilterSubprocessEnv bool `toml:"filter_subprocess_env"`
// ProtectSensitiveFiles makes read/list/search tools treat credential
// paths (.env, .git-credentials, .netrc, *.pem/*.key/*.p12/*.pfx, ~/.ssh)
// as invisible. Default off because hiding the files breaks legitimate
// "edit my .env" workflows.
ProtectSensitiveFiles bool `toml:"protect_sensitive_files"`
// ProtectCredentialFiles denies raw reads of pure-credential files (SSH
// private keys, ~/.aws/credentials, ~/.netrc) to the read tools and the OS
// bash sandbox alike. Default on; those files hold no settings a tool needs.
ProtectCredentialFiles bool `toml:"protect_credential_files"`
}
// DesktopExternalOpener returns the selected opener id; unavailable ids fall
// back to the platform file manager in the desktop shell.
func (c *Config) DesktopExternalOpener() string {
if c == nil {
return ""
}
return strings.ToLower(strings.TrimSpace(c.Desktop.ExternalOpener))
}
// NotificationsConfig controls optional system notifications for CLI chat/run.
type NotificationsConfig struct {
Enabled bool `toml:"enabled"`
TurnDone bool `toml:"turn_done"`
ApprovalRequest bool `toml:"approval_request"`
AskRequest bool `toml:"ask_request"`
FeedbackReply bool `toml:"feedback_reply"`
DiskLow bool `toml:"disk_low"`
// Sound plays a short system chime for the events switched on above, whether
// or not system notifications are; off unless asked for.
Sound bool `toml:"sound"`
// Phone says what this desktop tells its enrolled phones about. Off unless
// asked for, because it tells the account service that something is waiting.
Phone PhoneNotificationsConfig `toml:"phone"`
}
// PhoneNotificationsConfig is the desktop's half of phone notifications: the
// master switch and which of the three occasions it reports. What a phone does
// with a report is that phone's own choice. Only the type of occasion and a
// count are ever sent.
type PhoneNotificationsConfig struct {
Enabled bool `toml:"enabled"`
Approval bool `toml:"approval"`
Input bool `toml:"input"`
Error bool `toml:"error"`
}
// EnvironmentEnabled reports whether startup environment probing should feed the
// cache-stable system prompt.
func (c *Config) EnvironmentEnabled() bool {
return c == nil || c.Environment.Enabled == nil || *c.Environment.Enabled
}
// UITheme normalizes ui.theme to a supported value.
func (c *Config) UITheme() string {
switch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {
case "dark":
return "dark"
case "light":
return "light"
default:
return "auto"
}
}
// UIThemeStyle normalizes ui.theme_style. Empty means "pick the default style
// for the resolved light/dark shell".
func (c *Config) UIThemeStyle() string {
return normalizeThemeStyle(c.UI.ThemeStyle)
}
// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept
// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both
// layouts.
func (c *Config) UIShortcutLayout() string {
switch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {
case "desktop", "dual", "dual-axis", "dual_axis":
return "desktop"
default:
return "classic"
}
}
// UICursorShape normalizes ui.cursor_shape. The slim "bar" default stays
// visible without covering CJK wide characters. Valid values are "block",
// "underline", and "bar".
func (c *Config) UICursorShape() string {
switch strings.ToLower(strings.TrimSpace(c.UI.CursorShape)) {
case "block":
return "block"
case "underline":
return "underline"
default:
return "bar"
}
}
// UICommandMode reports whether the composer should use a vi-style command
// mode. Only the value "vi" enables it; any other value keeps the default
// insert-always editing.
func (c *Config) UICommandMode() bool {
return strings.ToLower(strings.TrimSpace(c.UI.CommandMode)) == "vi"
}
func normalizeThemeStyle(style string) string {
switch strings.ToLower(strings.TrimSpace(style)) {
case "graphite", "aurora", "slate", "carbon", "nocturne", "amber", "ember", "midnight", "sandstone", "porcelain", "linen", "glacier":
return strings.ToLower(strings.TrimSpace(style))
default:
return ""
}
}
func normalizeCloseBehavior(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "quit", "exit":
return "quit"
default:
return "background"
}
}
// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect
// from the browser/OS locale; it deliberately does not read top-level language,
// which is used by the CLI/model-facing runtime.
func (c *Config) DesktopLanguage() string {
switch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {
case "en":
return "en"
case "zh":
return "zh"
default:
return ""
}
}
// DesktopCurrency returns the explicit user-global pricing currency. The
// persisted field keeps its original desktop namespace for compatibility;
// empty means the pricing region follows the desktop/CLI language.
func (c *Config) DesktopCurrency() string {
if c == nil {
return ""
}
switch strings.ToUpper(strings.TrimSpace(c.Desktop.Currency)) {
case "CNY", "RMB", "CNH":
return "CNY"
case "USD":
return "USD"
default:
return ""
}
}
// DesktopTheme normalizes desktop.theme. New desktop users default to the OS
// automatic graphite product look; an explicit auto/light/dark is preserved.
func (c *Config) DesktopTheme() string {
switch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {
case "auto":
return "auto"
case "light":
return "light"
case "dark":
return "dark"
default:
return "auto"
}
}
// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend
// chooses the default style for the resolved desktop theme.
func (c *Config) DesktopThemeStyle() string {
return normalizeThemeStyle(c.Desktop.ThemeStyle)
}
// DesktopTerminalTheme normalizes the integrated terminal colour preference.
// Auto deliberately follows the resolved desktop app theme, including OS theme
// changes while desktop.theme is also auto.
func (c *Config) DesktopTerminalTheme() string {
switch strings.ToLower(strings.TrimSpace(c.Desktop.TerminalTheme)) {
case "dark":
return "dark"
case "light":
return "light"
default:
return "auto"
}
}
// DesktopCloseBehavior normalizes the desktop close-window preference. It falls
// back to the legacy ui.close_behavior value for configs written before [desktop]
// existed.
func (c *Config) DesktopCloseBehavior() string {
if strings.TrimSpace(c.Desktop.CloseBehavior) != "" {
return normalizeCloseBehavior(c.Desktop.CloseBehavior)
}
return normalizeCloseBehavior(c.UI.CloseBehavior)
}
// UICloseBehavior is the legacy name for DesktopCloseBehavior.
func (c *Config) UICloseBehavior() string {
return c.DesktopCloseBehavior()
}
// DesktopDisplayMode normalizes the transcript display mode. Default is
// "standard" (flat rendering, no folding).
func (c *Config) DesktopDisplayMode() string {
switch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {
case "standard":
return "standard"
case "compact", "minimal":
return "compact"
default:
return "standard"
}
}
// DesktopConversationWidth returns the normalized desktop conversation width.
// Unknown and missing values fall back to standard for backward compatibility.
func (c *Config) DesktopConversationWidth() string {
if c != nil {
switch w := strings.ToLower(strings.TrimSpace(c.Desktop.ConversationWidth)); w {
case "full", "full-all", "custom":
return w
}
}
return "standard"
}
// NormalizeToolApprovalMode returns the canonical desktop/session tool approval
// posture. Unknown or missing values fall back to ask for safety.
func NormalizeToolApprovalMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "auto":
return "auto"
case "yolo", "full", "full-access", "bypass":
return "yolo"
default:
return "ask"
}
}
// DesktopStatusBarStyle normalizes the desktop status bar metric label style.
// Default is "text"; explicit "icon" preserves the user's compact choice.
func (c *Config) DesktopStatusBarStyle() string {
switch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {
case "icon":
return "icon"
case "text":
return "text"
default:
return "text"
}
}
var defaultDesktopStatusBarItems = []string{
"model",
"workspace",
"git_branch",
"cache",
"cache_avg",
"session_tokens",
"turn_tokens",
"turn_tps",
"turn_output_tokens",
"turn_cache_tokens",
"turn_cost",
"session_turns",
"context",
"compact",
"cost",
"balance",
}
var knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)
func desktopStatusBarItemSet(items []string) map[string]bool {
out := make(map[string]bool, len(items))
for _, item := range items {
out[item] = true
}
return out
}
// DefaultDesktopStatusBarItems returns the default ordered visible desktop
// status bar items.
func DefaultDesktopStatusBarItems() []string {
return append([]string(nil), defaultDesktopStatusBarItems...)
}
// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.
// An unset or empty list uses the default full set; explicit non-empty lists
// preserve user order and omit hidden items.
func (c *Config) DesktopStatusBarItems() []string {
return normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)
}
func normalizeDesktopStatusBarItems(items []string) []string {
out := make([]string, 0, len(items))
seen := map[string]bool{}
for _, raw := range items {
id := strings.TrimSpace(raw)
if !knownDesktopStatusBarItems[id] || seen[id] {
continue
}
out = append(out, id)
seen[id] = true
}
if len(out) == 0 {
return DefaultDesktopStatusBarItems()
}
return out
}
// DesktopCheckUpdates reports whether the desktop should check for updates on
// startup. Missing configs default to true so existing users keep update notices.
func (c *Config) DesktopCheckUpdates() bool {
if c == nil || c.Desktop.CheckUpdates == nil {
return true
}
return *c.Desktop.CheckUpdates
}
// NormalizeCLIUpdateChannel returns the only public native CLI update channel.
// The input remains accepted so older preview configurations keep loading.
func NormalizeCLIUpdateChannel(_ string) string {
return "stable"
}
// CLIUpdateChannel returns the user-global native CLI update channel.
func (c *Config) CLIUpdateChannel() string {
if c == nil {
return "stable"
}
return NormalizeCLIUpdateChannel(c.CLI.UpdateChannel)
}
// NormalizeDesktopUpdateChannel returns the only public Desktop update channel.
// Legacy preview/canary/beta/next values are deliberately ignored so an old
// configuration cannot strand the installation on the retired channel.
func NormalizeDesktopUpdateChannel(_ string) string {
return "stable"
}
// DesktopUpdateChannel returns the desktop channel whose latest pointer should be
// checked. Missing or unknown configs default to stable.
func (c *Config) DesktopUpdateChannel() string {
if c == nil {
return "stable"
}
return NormalizeDesktopUpdateChannel(c.Desktop.UpdateChannel)
}
// ResponseLanguage normalizes the top-level language preference for final
// answers. Empty means auto: replies follow the current user turn.
func (c *Config) ResponseLanguage() string {
if c == nil {
return "auto"
}
return NormalizeLanguage(c.Language)
}
// NormalizeLanguage returns one of auto|zh|en for UI/default reply language settings.
func NormalizeLanguage(lang string) string {
switch strings.ToLower(strings.TrimSpace(lang)) {
case "", "auto", "detect", "default":
return "auto"
case "zh", "cn", "chinese", "中文":
return "zh"
case "en", "english":
return "en"
default:
return "auto"
}
}
// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:
// visible reasoning follows the conversation language already described by the
// stable LanguagePolicy. Legacy "default" is treated as auto.
func (c *Config) ReasoningLanguage() string {
if c == nil {
return "auto"
}
return NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)
}
// NormalizeReasoningLanguage returns one of auto|zh|en.
func NormalizeReasoningLanguage(lang string) string {
switch strings.ToLower(strings.TrimSpace(lang)) {
case "", "auto", "follow", "conversation", "detect", "default", "model", "model-default", "model_default", "provider":
return "auto"
case "zh", "cn", "chinese", "中文":
return "zh"
case "en", "english":
return "en"
default:
return "auto"
}
}
// DesktopTelemetry reports whether the desktop sends the anonymous daily ping.
// It carries no conversation, key, or file data — see desktop/README.md.
func (c *Config) DesktopTelemetry() bool {
if c == nil || c.Desktop.Telemetry == nil {
return true
}
return *c.Desktop.Telemetry
}
// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —
// anonymous (signal, bucket) counters, never content. Default on.
func (c *Config) DesktopMetrics() bool {
if c == nil || c.Desktop.Metrics == nil {
return true
}
return *c.Desktop.Metrics
}
// StatuslineConfig configures a custom status line. Command, when set, is run at
// startup and after each turn; its first line of stdout replaces the built-in
// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.
// User/global only: LoadForRoot discards a project reasonix.toml's value.
type StatuslineConfig struct {
Command string `toml:"command"`
}
// ServeConfig controls the HTTP serve frontend security settings.
type ServeConfig struct {
// AuthMode selects the authentication mode for the HTTP serve frontend.
// "none" (default): no authentication.
// "token": a pre-shared token in the URL query string.
// "password": a login page with bcrypt password verification.
AuthMode string `toml:"auth_mode"`
// Token is a pre-shared token for auth_mode = "token". When empty, a
// cryptographically random token is generated at startup and printed.
Token string `toml:"token"`
// PasswordHash is a bcrypt hash of the password for auth_mode = "password".
// Generate one with: reasonix serve --hash-password --password '...'
PasswordHash string `toml:"password_hash"`
// BehindProxy indicates the server sits behind a trusted reverse proxy
// (nginx, Caddy, Cloudflare, etc.) that sets X-Forwarded-For and
// X-Forwarded-Proto headers. When true, those headers are used for
// rate-limiting and Secure-cookie decisions. When false (default), they
// are ignored — an attacker can otherwise forge them.
BehindProxy bool `toml:"behind_proxy"`
// SharePort fixes the port of the phone-access LAN link; zero picks a free
// one each time the door opens. User-global like the rest of [serve].
SharePort int `toml:"share_port"`
// RememberPairedDevices keeps paired phones across restarts: the next process
// reopens the share where it was and adopts the devices that paired against it.
// Off by default, like every other promise about a device you are not holding.
RememberPairedDevices bool `toml:"remember_paired_devices"`
}
// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,
// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.
// web_fetch reuses these proxy settings while keeping its own SSRF-guarded
// dialer.
type NetworkConfig struct {
// ProxyMode is "auto" (default; environment proxy for now), "env", "custom",
// or "off". auto leaves room for OS proxy detection later without changing the
// config shape.
ProxyMode string `toml:"proxy_mode"`
// ProxyURL is an advanced custom override such as "socks5://127.0.0.1:7890".
// When set and proxy_mode = "custom", it wins over the structured proxy table.
ProxyURL string `toml:"proxy_url"`
// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the
// process environment instead.
NoProxy string `toml:"no_proxy"`
Proxy NetworkProxyConfig `toml:"proxy"`
NetworkResolverConfig
}
// NetworkProxyConfig is the structured custom-proxy editor shape. Password is
// optional and supports ${VAR} expansion, so users can avoid storing it literally.
type NetworkProxyConfig struct {
Type string `toml:"type"` // http|https|socks5|socks5h
Server string `toml:"server"`
Port int `toml:"port"`
Username string `toml:"username"`
Password string `toml:"password"`
}
// NetworkProxySpec returns the expanded proxy settings used by netclient. The
// settings are the user's, so ${VAR} expands from the process environment and
// never from a workspace .env.
func (c *Config) NetworkProxySpec() netclient.ProxySpec {
return netclient.ProxySpec{
Mode: c.Network.ProxyMode,
URL: ExpandVars(c.Network.ProxyURL),
NoProxy: ExpandVars(c.Network.NoProxy),
Type: c.Network.Proxy.Type,
Server: ExpandVars(c.Network.Proxy.Server),
Port: c.Network.Proxy.Port,
Username: ExpandVars(c.Network.Proxy.Username),
Password: ExpandVars(c.Network.Proxy.Password),
DirectHosts: c.directProxyHosts(),
}
}
// directProxyHosts collects the base_url hosts of providers marked no_proxy, so
// netclient bypasses the proxy for them without knowing any provider by name.
//
// Only for an auto-detected proxy (auto/env): that proxy is typically a
// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep
// them direct. An explicit proxy_mode = "custom" is the user saying "route
// everything through this" — e.g. a mandatory corporate proxy — so honor it for
// every provider; a custom-proxy user who wants a host direct uses
// network.no_proxy instead (#3635).
func (c *Config) directProxyHosts() []string {
if c.NetworkProxyMode() == netclient.ModeCustom {
return nil
}
seen := map[string]bool{}
var out []string
for _, p := range c.Providers {
if !p.NoProxy {
continue
}
u, err := url.Parse(strings.TrimSpace(p.BaseURL))
if err != nil {
continue
}
if h := u.Hostname(); h != "" && !seen[h] {
seen[h] = true
out = append(out, h)
}
}
return out
}
// NetworkProxyMode normalizes network.proxy_mode to a known value.
func (c *Config) NetworkProxyMode() string {
return netclient.NormalizeMode(c.Network.ProxyMode)
}
// SkillsConfig configures skill discovery. Paths adds extra "custom"-scope skill
// roots — each a directory of SKILL.md / <name>.md playbooks — scanned between
// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and
// the global roots. ExcludedPaths hides matching discovery roots without deleting
// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills
// hides named skills from the agent prompt, slash invocation, and skill tools
// while keeping them manageable. DisableImplicitInvocation keeps skills
// discoverable to the host for explicit /skill use and management, but hides
// their index and model-facing invocation tools.
type SkillsConfig struct {
Paths []string `toml:"paths"`
ExcludedPaths []string `toml:"excluded_paths"`
DisabledSkills []string `toml:"disabled_skills"`
DisableImplicitInvocation bool `toml:"disable_implicit_invocation"`
SuppressWarnings bool `toml:"suppress_warnings"`
MaxDepth int `toml:"max_depth"`
}
// SkillCustomPaths returns the configured custom skill roots with ${VAR}
// expanded; empty entries are dropped.
func (c *Config) SkillCustomPaths() []string {
var out []string
for _, p := range c.Skills.Paths {
if p = c.expandVars(p); strings.TrimSpace(p) != "" {
out = append(out, p)
}
}
return out
}
// SkillExcludedPaths returns configured skill roots that should be hidden from
// discovery, with ${VAR} expanded and empty entries dropped.
func (c *Config) SkillExcludedPaths() []string {
var out []string
for _, p := range c.Skills.ExcludedPaths {
if p = c.expandVars(p); strings.TrimSpace(p) != "" {
out = append(out, p)
}
}
return out
}
// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill
// packs while Store keeps nested markdown safe by requiring descriptions.
func (c *Config) SkillMaxDepth() int {
const (
defaultDepth = 3
maxDepth = 5
)
if c == nil || c.Skills.MaxDepth == 0 {
return defaultDepth
}
if c.Skills.MaxDepth < 1 {
return 1
}
if c.Skills.MaxDepth > maxDepth {
return maxDepth
}
return c.Skills.MaxDepth
}
// DisabledSkillNames returns valid disabled skill identifiers, preserving the
// first spelling and dropping duplicates/empty entries.
func (c *Config) DisabledSkillNames() []string {
seen := map[string]bool{}
var out []string
for _, name := range c.Skills.DisabledSkills {
name = strings.TrimSpace(name)
if !IsValidSkillName(name) {
continue
}
name = norm.NFC.String(name)
key := SkillNameKey(name)
if seen[key] {
continue
}
seen[key] = true
out = append(out, name)
}
return out
}
// IsSkillDisabled reports whether name is configured as disabled.
func (c *Config) IsSkillDisabled(name string) bool {
key := SkillNameKey(name)
if key == "" {
return false
}
for _, disabled := range c.DisabledSkillNames() {
if SkillNameKey(disabled) == key {
return true
}
}
return false
}
// SandboxConfig bounds the blast radius of tool calls. WorkspaceRoot is the
// directory the built-in file writers (write_file / edit_file / multi_edit /
// move_file) may modify; empty means the current working directory, so writes
// stay inside the project by default. AllowWrite lists extra directories
// writers may also touch (e.g. a sibling repo or a temp dir). ForbidRead lists
// files or directories the agent may not read or list (e.g. ~/.ssh for
// secrets). Both support ${VAR} / ${VAR:-default} expansion.
type SandboxConfig struct {
WorkspaceRoot string `toml:"workspace_root"`
AllowWrite []string `toml:"allow_write"`
ForbidRead []string `toml:"forbid_read"`
// Bash is the OS-sandbox mode for the bash tool: "enforce" jails each
// command when an OS sandbox is available and refuses bash otherwise; "off"
// runs it unconfined. Empty uses the platform default.
Bash string `toml:"bash"`
// WindowsIntegrity turns the bash sandbox on for Windows: commands run under a
// write-restricted, Low-integrity token, and the workspace and cache roots are
// granted to it once (ACL entries on your disk, listed by `reasonix sandbox`).
// Off by default and user file only; it bounds writes, not reads or the network.
WindowsIntegrity bool `toml:"windows_integrity"`
// Network allows network egress from inside the bash sandbox. Defaults true
// so module/package downloads keep working; the boundary is then writes.
Network bool `toml:"network"`
// HostAuthorities lists the host services bash may call. Defaults to
// ssh_agent alone: signing is routine, a daemon socket is host-wide
// execution. User-global (LoadForRoot); a repo cannot grant itself one.
HostAuthorities []string `toml:"host_authorities"`
// AllowDevices lists host device nodes (globs under /dev, such as
// /dev/nvidia*) bound into the Linux bash sandbox, which otherwise shows a
// private /dev. User-global: a repo cannot grant itself a device.
AllowDevices []string `toml:"allow_devices"`
// AllowedDomains, when set with Network, limits bash egress (not MCP
// servers') to these hosts through the host's egress proxy; "*.x" names
// every host beneath x. A repo may set it only where the user has not.
AllowedDomains []string `toml:"allowed_domains"`
// DeniedDomains outrank AllowedDomains. The repo's are added to the user's.
DeniedDomains []string `toml:"denied_domains"`
// EgressUnlisted "allow" stops the question about hosts AllowedDomains does
// not name. User file only: a repo cannot switch its own prompts off.
EgressUnlisted string `toml:"egress_unlisted"`
}
// WriteRoots returns the directories file-writer tools may modify: the
// workspace root (defaulting to the current working directory when unset), plus
// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given
// (relative or absolute); the confiner resolves them to absolute, symlink-free
// paths. The result is always non-empty, so confinement is on by default.
func (c *Config) WriteRoots() []string {
return c.WriteRootsForRoot(".")
}
// writeRootsBeforeExtras is WriteRoots with fallbackRoot standing in when the
// config sets no workspace_root: the root plus the user's allow_write entries.
// Desktop tabs pass their project root so confinement needs no cwd change.
func (c *Config) writeRootsBeforeExtras(fallbackRoot string) []string {
root := c.expandSandboxPath(c.Sandbox.WorkspaceRoot)
if root == "" {
root = fallbackRoot
if root == "" || root == "." {
if wd, err := os.Getwd(); err == nil {
root = wd
} else {
root = "."
}
}
}
roots := []string{root}
for _, d := range c.Sandbox.AllowWrite {
if d = c.expandSandboxPath(d); d != "" {
roots = append(roots, d)
}
}
return roots
}
// AllowWriteRoots returns only the configured [sandbox] allow_write extras with
// ${VAR} expanded — the explicit escape-hatch entries, without the workspace
// root that WriteRoots prepends. The session-data write guard treats these as
// user-sanctioned raw access.
func (c *Config) AllowWriteRoots() []string {
var roots []string
for _, d := range c.Sandbox.AllowWrite {
if d = c.expandSandboxPath(d); d != "" {
roots = append(roots, d)
}
}
return roots
}
// ForbidReadRoots returns the paths the agent is forbidden from reading
// or listing, with ${VAR} expanded. Relative roots are resolved against the
// current working directory; the confiner resolves them to symlink-free paths.
// Empty when no forbid_read entries are configured.
func (c *Config) ForbidReadRoots() []string {
return c.ForbidReadRootsForRoot(".")
}
// ForbidReadRootsForRoot is like ForbidReadRoots but uses fallbackRoot when
// resolving relative paths (for desktop tabs that pass their project root).
func (c *Config) ForbidReadRootsForRoot(fallbackRoot string) []string {
root := fallbackRoot
if root == "" || root == "." {
if wd, err := os.Getwd(); err == nil {
root = wd
} else {
root = "."
}
}
roots := make([]string, 0, len(c.Sandbox.ForbidRead))
for _, d := range c.Sandbox.ForbidRead {
if d = c.expandSandboxPath(d); d != "" {
if !filepath.IsAbs(d) {
d = filepath.Join(root, d)
}
roots = append(roots, d)
}
}
return roots
}
// BashMode normalises the bash-sandbox mode for the current host.
func (c *Config) BashMode() string {
return c.BashModeForGOOS(runtimeGOOS)
}
// BashModeForGOOS normalises the bash-sandbox mode for tests and cross-platform
// rendering. Windows enforces only when the user turned on windows_integrity;
// an older config's explicit "enforce" there stays off. macOS/Linux keep the
// existing explicit-mode behavior.
func (c *Config) BashModeForGOOS(goos string) string {
if goos == "windows" {
if c.Sandbox.WindowsIntegrity {
return "enforce"
}
return "off"
}
switch strings.TrimSpace(c.Sandbox.Bash) {
case "enforce":
return "enforce"
case "off":
return "off"
case "":
return "enforce"
default:
return "enforce"
}
}
// BashModeUnderOverride is the mode a caller's request for override wins. On
// Windows a request to enforce is honoured only when the user turned on
// windows_integrity: enforcing there edits permissions on the user's folders,
// which a caller must not do on its own account.
func (c *Config) BashModeUnderOverride(override string) string {
if override == "enforce" && runtimeGOOS == "windows" && !c.Sandbox.WindowsIntegrity {
return c.BashMode()
}
return override
}
// AgentConfig configures the harness loop. PlannerModel is optional: when set
// to another provider's name it enables two-model collaboration, where the
// planner handles low-frequency planning in its own session (kept separate so
// each model's prompt prefix stays cache-stable). SubagentModel is the optional
// default for runAs=subagent skills; SubagentModels overrides it per skill name.
type AgentConfig struct {
SystemPrompt string `toml:"system_prompt"`
SystemPromptFile string `toml:"system_prompt_file"`
// Deprecated compatibility fields. Old TOML and desktop clients may still
// send them, but config loading normalizes both to zero and rendering omits
// them. The one-off CLI limit remains a separate control.
MaxSteps int `toml:"max_steps"`
PlannerMaxSteps int `toml:"planner_max_steps"`
Temperature float64 `toml:"temperature"`
PlannerModel string `toml:"planner_model"`
TitleModel string `toml:"title_model"`
RoleEfforts map[string]string `toml:"role_efforts"`
GuardianModel string `toml:"guardian_model"`
GuardianTemperature float64 `toml:"guardian_temperature"`
// RecoveryModel optionally names a dedicated model for the independent
// recovery reviewer. Empty falls back to GuardianModel, then the main model.
RecoveryModel string `toml:"recovery_model"`
// RecoveryTemperature is accepted from older configs but ignored. Auto
// Guard review is deterministic at temperature zero.
RecoveryTemperature float64 `toml:"recovery_temperature"`
SubagentModel string `toml:"subagent_model"`
SubagentModels map[string]string `toml:"subagent_models"`
// A text-only worker can drop images during serialization. A dedicated
// reader keeps those attachments available independently of the main model.
// Empty retains the receiving sub-agent's model.
VisionModel string `toml:"vision_model"`
WebSearchModel string `toml:"web_search_model"`
// TriageModel answers the small classifications the static tables come up
// short on (is this unrecognized command read-only?). Empty falls back to
// subagent_model, then the main model — set it to point them somewhere cheap.
TriageModel string `toml:"triage_model"`
// ScreenExternalContent asks the triage model whether each external tool
// result tries to instruct the agent. Advisory only: a hit adds a notice.
ScreenExternalContent bool `toml:"screen_external_content"`
// AdvisorModel is the stronger model the advise tool consults. Empty leaves
// the tool out; it never falls back, since advice from the same model is none.
AdvisorModel string `toml:"advisor_model"`
// BestOfN offers best_of_n: parallel unattended attempts in git worktrees,
// judged, with the winner applied. Off by default; every attempt is a full run.
BestOfN bool `toml:"best_of_n"`
// WorktreeIsolation lets task run a writer in a git worktree of the
// workspace, its changes held until applied. Off by default.
WorktreeIsolation bool `toml:"worktree_isolation"`
// CodeMode offers run_script: a Starlark script whose tool calls each pass
// the ordinary checks, so dependent steps cost one round trip.
CodeMode bool `toml:"code_mode"`
// DecisionModel names the backend system_one asks. It is on a decision
// wire, so it never falls back to the main model: a chat model has no
// answer for a question set, and falling back would hide that.
DecisionModel string `toml:"decision_model"`
SubagentEffort string `toml:"subagent_effort"`
SubagentEfforts map[string]string `toml:"subagent_efforts"`
MaxSubagentDepth int `toml:"max_subagent_depth"` // delegation layers; default 8 stops runaway recursion, 0 removes the stop; the user file only
MaxSideSessions int `toml:"max_side_sessions"` // 0 (default) opens as many side conversations as asked; the user file only
MaxQueuedInputs int `toml:"max_queued_inputs"` // 0 (default) queues as many prompts as asked; the user file only
MaxProviderRetries int `toml:"max_provider_retries"` // default 10; 0 retries until cancelled; the user file only
// GoalTokenBudget bounds an unattended Goal loop by cumulative tokens.
// Off unless set: a Goal runs until it finishes or you stop it.
GoalTokenBudget int `toml:"goal_token_budget"`
// MaxSubagentConcurrency bounds how many sub-agents (task, fleet items,
// profile skills, nested children) may run at once in one session.
// 0, the default, sets no limit. The user file only.
MaxSubagentConcurrency int `toml:"max_subagent_concurrency"`
// MaxParallelWriters bounds concurrent writer-capable sub-agents that
// declare non-overlapping write_paths. 0, the default, sets no limit: the
// path claims keep writers apart, not a count. The user file only.
MaxParallelWriters int `toml:"max_parallel_writers"`
SubagentClaims string `toml:"subagent_claims"` // "optimistic" (default) or "strict"; the user file only
WriteLease string `toml:"write_lease"` // "optimistic" (default) or "strict"; the user file only
PlanApproval string `toml:"plan_approval"` // "ask" (default) or "auto": the plan card answers itself; the user file only
DefaultMode string `toml:"default_mode"` // "normal" (default) or "plan": how a new conversation starts; the user file only
GoalAutoRetry int `toml:"goal_auto_retry"` // 0 (default) stops at an unavailable completion check; N retries it up to N times in a row; the user file only
// WriteLeaseMaxWaitMinutes bounds one wait behind another session's write
// claim; 0, the default, waits for as long as the turn lasts. The user file only.
WriteLeaseMaxWaitMinutes int `toml:"write_lease_max_wait_minutes"`
// OutputStyle selects built-in tone guidance or .reasonix/output-styles/<name>.md.
// Empty leaves the prompt unmodified.
OutputStyle string `toml:"output_style"`
Persona PersonaConfig `toml:"persona"`
// Deprecated compatibility field. Automatic plan mode was retired in config
// version 5; old TOML remains readable, but loading normalizes it to "off"
// and rendering omits it. Plan mode remains available as an explicit user
// choice.
AutoPlan string `toml:"auto_plan"`
// ReasoningLanguage controls the preferred language for visible reasoning
// text. Empty/auto follows the conversation language. Applied as transient
// turn context, not the stable prompt.
ReasoningLanguage string `toml:"reasoning_language"`
TurnNarration string `toml:"turn_narration"` // brief|detailed: how much the model says between tool calls; a turn-tail instruction, user-level only
// Deprecated compatibility field paired with AutoPlan. Old TOML remains
// readable, but loading clears it and rendering omits it.
AutoPlanClassifier string `toml:"auto_plan_classifier"`
CompactRatio float64 `toml:"compact_ratio"`
// Retired: a fold keeps no history verbatim, so these four are read and
// ignored. Old files that name them still load.
Keep []string `toml:"keep"`
RecentKeep int `toml:"recent_keep"`
UserTurnKeepTokens int `toml:"user_turn_keep_tokens"`
FirstTurnPinTokens int `toml:"first_turn_pin_tokens"`
// CheckpointCeilingRatio is how small an automatic fold's result must be,
// as a fraction of the window, before it is accepted. 0 selects 0.50.
CheckpointCeilingRatio float64 `toml:"checkpoint_ceiling_ratio"`
// EmbeddedDiffDetection marks a shell result that is a whole unified diff; nil = off.
EmbeddedDiffDetection *bool `toml:"embedded_diff_detection"`
AskTimeoutSeconds AskTimeoutSetting `toml:"ask_timeout_seconds"` // user file only; 0 waits
// PlanModeReadOnlyCommands is retained for old config/session round trips. Main
// Plan bash calls now use the ordinary Permissions classifier and Sandbox.
PlanModeReadOnlyCommands []string `toml:"plan_mode_read_only_commands"`
}
// ToolsConfig selects which built-in tools are enabled. Empty means all of them.
type ToolsConfig struct {
Enabled []string `toml:"enabled"`
// Disabled removes tools by name from every surface; a project file can only add to it.
Disabled []string `toml:"disabled"`
BashTimeoutSeconds *int `toml:"bash_timeout_seconds"`
MCPStartupTimeoutSeconds *int `toml:"mcp_startup_timeout_seconds"`
MCPCallTimeoutSeconds *int `toml:"mcp_call_timeout_seconds"`
BackgroundJobs BackgroundJobsConfig `toml:"background_jobs"`
Search SearchConfig `toml:"search"`
Shell ShellConfig `toml:"shell"`
SystemOne SystemOneConfig `toml:"system_one"`
ProtectChangedFiles *bool `toml:"protect_changed_files"`
BrowserTools *bool `toml:"browser_tools"`
SettingTool *bool `toml:"setting_tool"`
// MCPResources offers a tool that reads a connected server's resource; user file only, off by default.
MCPResources bool `toml:"mcp_resources"`
// PresentFiles offers file-delivery cards to the model; user file only, on by default.
PresentFiles bool `toml:"present_files"`
WebAccessSettings
// VideoNativeMaxBytes lowers the size read_video sends natively below the endpoint's limit; 0 keeps it. User file only.
VideoNativeMaxBytes int64 `toml:"video_native_max_bytes"`
// VideoURLPassthrough lets the model hand a public video address the user gave to the vendor, which then visits it. User file only; off by default.
VideoURLPassthrough bool `toml:"video_url_passthrough"`
// MCPLoad maps a server name to "always" or "deferred", overriding what
// the server's own declaration says.
MCPLoad map[string]string `toml:"mcp_load"`
}
const (
defaultBashTimeoutSeconds = 120
defaultMCPStartupTimeoutSeconds = 30
defaultMCPCallTimeoutSeconds = 300
defaultBackgroundJobStalledWarningSec = 900
maxBackgroundJobStalledWarningSec = 86400
)
// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted
// config keeps the historical 120s safety cap, explicit 0 disables the
// tool-local cap, and positive values set a custom cap. Negative values fall
// back to the default so a typo cannot silently remove the safety net.
func (c *Config) BashTimeoutSeconds() int {
if c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {
return defaultBashTimeoutSeconds
}
return *c.Tools.BashTimeoutSeconds
}
// MCPCallTimeoutSeconds returns the default MCP JSON-RPC call timeout in
// seconds. Omitted, zero, and negative values keep the built-in safety cap so a
// hung MCP server cannot block a turn indefinitely.
func (c *Config) MCPCallTimeoutSeconds() int {
if c.Tools.MCPCallTimeoutSeconds == nil || *c.Tools.MCPCallTimeoutSeconds <= 0 {
return defaultMCPCallTimeoutSeconds
}
return *c.Tools.MCPCallTimeoutSeconds
}
// MCPStartupTimeoutSeconds returns the background initialize + tools/list
// safety cap. Omitted, zero, and negative values keep the built-in default so
// a slow but healthy MCP can outlive the short interactive wait without running
// indefinitely.
func (c *Config) MCPStartupTimeoutSeconds() int {
if c.Tools.MCPStartupTimeoutSeconds == nil || *c.Tools.MCPStartupTimeoutSeconds <= 0 {
return defaultMCPStartupTimeoutSeconds
}
return *c.Tools.MCPStartupTimeoutSeconds
}
// SearchConfig tunes the grep tool's engine. Engine is "auto" (default — use
// ripgrep when it's on PATH, else the native Go scanner), "native" (always Go),
// or "rg" (require ripgrep; warn at startup and fall back to native if absent).
// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.
type SearchConfig struct {
Engine string `toml:"engine"`
RgPath string `toml:"rg_path"`
}
// PermissionsConfig declares the per-call permission policy (see
// internal/safety/permission). Mode is the fallback decision for writer tools when no
// rule matches ("ask" | "allow" | "deny"; default "ask"); read-only tools always
// fall back to allow. Allow/Ask/Deny are rule lists of the form "ToolName" or
// "ToolName(glob)". Precedence: deny > ask > allow > fallback.
type PermissionsConfig struct {
Mode string `toml:"mode"`
Allow []string `toml:"allow"`
Ask []string `toml:"ask"`
Deny []string `toml:"deny"`
AllowDynamicBash bool `toml:"allow_dynamic_bash"`
// AskOnExternalOrigin asks you before a call that repeats text an external
// result gave the task runs on the strength of the mode alone. User file only.
AskOnExternalOrigin bool `toml:"ask_on_external_origin"`
}
// MCPConfigSource records where a merged MCP entry came from. It is runtime
// provenance only and is never serialized back into TOML or .mcp.json.
type MCPConfigSource string
const (
MCPSourceUnknown MCPConfigSource = ""
MCPSourceUserConfig MCPConfigSource = "user_config"
MCPSourceProjectConfig MCPConfigSource = "project_config"
MCPSourceProjectMCPJSON MCPConfigSource = "project_mcp_json"
MCPSourceLegacyUser MCPConfigSource = "legacy_user_config"
MCPSourcePluginPackage MCPConfigSource = "plugin_package"
)
func (s MCPConfigSource) UserAuthorized() bool {
switch s {
case MCPSourceUserConfig, MCPSourceLegacyUser, MCPSourcePluginPackage, MCPSourceClaudeLocal,
MCPSourceProjectConfig, MCPSourceProjectMCPJSON, MCPSourceClaudeUser:
return true
default:
return false
}
}
// ProjectScoped reports whether an MCP entry belongs to one workspace. Project
// scope remains useful for provenance, activation, and relative-path handling;
// it no longer implies a separate launch-approval workflow.
func (s MCPConfigSource) ProjectScoped() bool {
return s == MCPSourceProjectConfig || s == MCPSourceProjectMCPJSON
}
func (e PluginEntry) ShouldAutoStart() bool {
return e.AutoStart == nil || *e.AutoStart
}
// AutoStartPlugins returns enabled MCP entries for the catalog. Durable
// enable/disable overrides in capability-activation.json take precedence over
// the legacy auto_start field. auto_start=false without an override still maps
// to disabled; true/nil map to enabled. "Auto start" no longer means "spawn the
// process at session boot" — enabled servers register cached tools and start
// on first real tool call.
func (c *Config) AutoStartPlugins() []PluginEntry {
return c.EnabledPlugins("", DefaultActivationStore())
}
// EnabledPlugins returns catalog-enabled MCP entries for workspace, consulting
// the activation store when provided.
func (c *Config) EnabledPlugins(workspace string, activation *ActivationStore) []PluginEntry {
if c == nil {
return nil
}
out := make([]PluginEntry, 0, len(c.Plugins))
for _, p := range c.Plugins {
enabled := DeclaredDefaultOn(p)
if activation != nil {
if resolved, err := activation.IsEnabled(p, workspace); err == nil {
enabled = resolved
}
}
if enabled {
out = append(out, p)
}
}
return out
}
// DefaultSystemPrompt is used when config provides none.
const DefaultSystemPrompt = `You are Reasonix, a coding agent.
Use the available tools when they help you complete the user's request.
Keep changes focused and responses concise.`
// Default returns the built-in default configuration.
func Default() *Config {
return &Config{
ConfigVersion: freshConfigVersion,
DefaultModel: "deepseek-flash",
CredentialsStore: CredentialsStoreAuto, Peers: defaultPeers(), Mods: defaultMods(),
UI: UIConfig{Theme: "auto", ShowTurnUsage: true, ShowChangedFiles: true},
Desktop: DesktopConfig{ConversationWidth: "standard"},
Billing: BillingConfig{},
Tools: ToolsConfig{PresentFiles: true},
// Set here, not left to the zero value: an absent [secrets] still protects.
Secrets: SecretsConfig{ProtectCredentialFiles: true},
Notifications: NotificationsConfig{
Enabled: false,
TurnDone: true,
ApprovalRequest: true,
AskRequest: true,
FeedbackReply: true,
DiskLow: true,
},
Agent: AgentConfig{
SystemPrompt: DefaultSystemPrompt,
// No total round cap, and nothing else bounds a turn by default: the
// task budgets below ship off and the round guards were retired.
MaxSteps: 0,
PlannerMaxSteps: 0,
AutoPlan: "off",
// Soft/snip/force are load-only compatibility; CompactRatio alone drives maintenance.
CompactRatio: 0.90,
MaxSubagentDepth: 8,
MaxProviderRetries: 10,
MaxSubagentConcurrency: 0,
MaxParallelWriters: 0,
SubagentClaims: "optimistic",
WriteLease: "optimistic",
PlanApproval: "ask",
DefaultMode: "normal",
},
// Mode "ask" with no rules keeps `reasonix run` autonomous (no TTY → ask
// resolves to allow) while `reasonix` prompts before writers. Users add
// deny/allow rules to harden or quiet specific tools.
Permissions: PermissionsConfig{Mode: "ask"},
// Sandbox uses platform defaults: macOS/Linux jail bash by default;
// Windows has no OS-level Bash sandbox and always forces bash off.
// Network=true here so an absent [sandbox] in a user's file keeps egress
// (zero value would wrongly deny it).
Sandbox: SandboxConfig{Network: true, HostAuthorities: []string{"ssh_agent"}},
// LSP tools on by default, but dormant until a language server is on PATH;
// a missing server yields an install hint rather than an error.
LSP: LSPConfig{Enabled: true},
Network: NetworkConfig{ProxyMode: netclient.ModeAuto},
Providers: deepSeekDefaultProviders(),
}
}
// WriteFile writes the configuration to path as annotated TOML. The write is
// atomic + fsynced so an interrupted write or power loss can never truncate the
// main config into an unparseable state that leaves the app with no usable
// models (#4615, #4708).
func (c *Config) WriteFile(path string) error {
if renderScopeForPath(path) == RenderScopeUser {
resolved, err := resolveConfigReadPath(path)
if err != nil {
return err
}
return c.writeUserConfig(path, resolved, func(body string) error { return atomicWriteToConfigFile(path, body, configFilePerm(path)) })
}
return atomicWriteToConfigFile(path, RenderTOMLForScope(c, renderScopeForPath(path)), configFilePerm(path))
}
// Provider returns the named provider entry.
func (c *Config) Provider(name string) (*ProviderEntry, bool) {
for i := range c.Providers {
if c.Providers[i].Name == name {
return &c.Providers[i], true
}
}
return nil, false
}
// ResolveModel resolves a model reference to a provider entry whose Model is the
// selected model string (a copy, so the config's lists stay intact). It accepts:
// - "provider/model" — that exact model under that provider;
// - a provider name — the provider's default model;
// - a bare model name — the (first) provider that lists it.
//
// The returned entry is ready to build a provider from (NewProvider reads .Model),
// so a single "vendor with many models" entry yields one instance per model
// without duplicating base_url/api_key_env. Single-`model` entries still resolve
// by provider name, keeping older configs working unchanged.
func (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {
if e, ok := standIn(c, ref); ok || ref == "" {
return e, ok
}
if access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {
if access["deepseek"] && !canCanonicalizeLegacyDeepSeekProviders(c) {
delete(access, "deepseek")
}
ref = retargetDesktopOfficialRef(ref, access)
}
ref = c.refForFoldedDeepSeek(ref)
e, ok := c.lookupModel(ref)
if ok {
e.searchAssigned = c.explicitWebSearchRef() != ""
}
return e, ok
}
// ResolveModelWithFallback resolves a model reference to the canonical
// "provider/model" form used by the desktop runtime. If ref is stale or empty,
// it tries the user's configured default_model before falling back to the first
// configured provider — so preference isn't overwritten by iteration order.
func (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {
ref = strings.TrimSpace(ref)
if ref != "" {
if e, found := c.ResolveModel(ref); found && Answering(e.Kind, AnswersChat) {
return e.Name + "/" + e.Model, false, true
}
}
// Before falling back to the first configured provider (which may not be the
// user's preferred choice), try the configured default_model. Skip when ref
// already WAS the DefaultModel (it already failed above, so retrying won't
// help) or when the default provider has no API key configured.
if ref != c.DefaultModel && c.DefaultModel != "" {
if e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() && Answering(e.Kind, AnswersChat) {
return e.Name + "/" + e.Model, true, true
}
}
for i := range c.Providers {
p := &c.Providers[i]
// Skip providers with no models or no API key: falling back onto a keyless
// provider just boots the tab onto something that fails on first use. Mirrors
// the Configured() gate the provider-removal/selection paths already apply.
if len(p.ModelList()) == 0 || !p.Configured() || !Answering(p.Kind, AnswersChat) {
continue
}
return p.Name + "/" + p.DefaultModel(), true, true
}
return "", false, false
}
// ResolveNewSessionChatModel selects the model for a newly-created chat
// session. Configured candidates win; if every chat candidate is keyless, the
// valid default (or first chat model) is kept for the missing-key recovery UI,
// and an unknown default is kept for the CLI's configuration error. A default
// passed over as no conversation source comes back as SkippedDefault.
func (c *Config) ResolveNewSessionChatModel() (NewSessionModel, bool) {
ref, fallback, ok := c.resolveNewSessionChatModel(nil, true)
if !ok {
return NewSessionModel{}, false
}
return c.newSessionModel(ref, fallback), true
}
func (c *Config) resolveNewSessionChatModel(providerAllowed func(string) bool, preserveUnknownDefault bool) (resolvedRef string, fallback bool, ok bool) {
if c == nil || len(c.Providers) == 0 {
return noProviderStart(c)
}
if providerAllowed == nil {
providerAllowed = func(string) bool { return true }
}
def := strings.TrimSpace(c.DefaultModel)
keylessDefault := ""
if def != "" {
if entry, found := c.ResolveModel(def); found {
if providerAllowed(entry.Name) && Answering(entry.Kind, AnswersChat) && IsLikelyChatModel(entry.Model) {
if entry.Configured() {
return def, false, true
}
keylessDefault = def
}
} else if preserveUnknownDefault {
// CLI/boot callers need the stale value intact so their existing
// unknown-model error can name it and explain the providers that
// replaced it. Desktop uses its recovery UI and does not preserve it.
return def, false, true
}
}
keylessFallback := ""
for i := range c.Providers {
p := &c.Providers[i]
if !providerAllowed(p.Name) || !Answering(p.Kind, AnswersChat) {
continue
}
chatModels := p.ChatModelList()
if len(chatModels) == 0 {
continue
}
model := chatModels[0]
for _, candidate := range chatModels {
if candidate == p.DefaultModel() {
model = candidate
break
}
}
resolved := p.Name + "/" + model
if p.Configured() {
return resolved, true, true
}
if keylessFallback == "" {
keylessFallback = resolved
}
}
if keylessDefault != "" {
return keylessDefault, false, true
}
if keylessFallback != "" {
return keylessFallback, true, true
}
return "", false, false
}
// ResolveDesktopNewSessionModel selects the model for a newly-created desktop
// session. It shares the chat-model fallback policy with other frontends while
// limiting candidates to providers exposed by the desktop access catalog.
func (c *Config) ResolveDesktopNewSessionModel() (resolvedRef string, fallback bool, ok bool) {
if c == nil {
return "", false, false
}
access := desktopProviderAccessMap(c.Desktop.ProviderAccess)
return c.resolveNewSessionChatModel(func(name string) bool {
return c.Desktop.ProviderAccess == nil || access[strings.TrimSpace(name)]
}, false)
}
// Validate checks that the selected model's provider is usable.
func (c *Config) Validate(model string) error {
e, ok := c.ResolveModel(model)
if !ok {
return fmt.Errorf("unknown model %q (configured: %s)", model, c.providerNames())
}
if err := kindError(e, model); err != nil {
return err
}
if e.BaseURL == "" {
return fmt.Errorf("provider %q: base_url is required", model)
}
if strings.TrimSpace(e.APIKeyEnv) != "" && !IsValidCredentialKey(e.APIKeyEnv) {
return fmt.Errorf("provider %q: api_key_env %q is invalid; use letters, numbers, and underscores, not a model name", model, e.APIKeyEnv)
}
if e.RequiresAPIKey() && e.APIKey() == "" {
return fmt.Errorf("provider %q: no stored key for %s in %s; run 'reasonix setup'", model, e.APIKeyEnv, e.roots.credentialsLocationForError())
}
return nil
}
func (c *Config) providerNames() string {
names := make([]string, len(c.Providers))
for i, p := range c.Providers {
names[i] = p.Name
}
return strings.Join(names, ", ")
}
// ResolveAPIKeyFromProcessEnvForProbe pins a setup-time, user-entered key onto
// this entry for an immediate connectivity probe. Normal runtime resolution does
// not call this; loaded provider entries still resolve only from Reasonix's
// global .env.
func (e *ProviderEntry) ResolveAPIKeyFromProcessEnvForProbe() {
if e == nil {
return
}
key := strings.TrimSpace(e.APIKeyEnv)
if key == "" {
return
}
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return
}
e.resolvedAPIKey = value
e.resolvedSource = CredentialSource{Kind: CredentialSourceEnvironment, Label: "setup prompt"}
}