mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
1586 lines
61 KiB
Go
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"}
|
|
}
|