CrossRelay stage 2: Codex /v1/responses relay, config.toml manager, Codex page
This commit is contained in:
1 parent
404704171d
commit
5112f7ca06
30 files changed
+3539
-72
No files matched your search
@@ -0,0 +1,442 @@
|
||||
// Package codexcfg reads and writes Codex's config.toml. CrossRelay
|
||||
// only touches the keys it manages; every other line (tables,
|
||||
// comments, spacing, line endings) round-trips untouched.
|
||||
package codexcfg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProviderID is the model_providers entry CrossRelay manages.
|
||||
const ProviderID = "crossrelay"
|
||||
|
||||
// TableName is the full TOML table path of the managed provider.
|
||||
const TableName = "model_providers." + ProviderID
|
||||
|
||||
// DefaultPath returns the location of Codex's config.toml. CODEX_HOME
|
||||
// overrides the default location (~/.codex).
|
||||
func DefaultPath() (string, error) {
|
||||
if v := strings.TrimSpace(os.Getenv("CODEX_HOME")); v != "" {
|
||||
return filepath.Join(v, "config.toml"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("locate home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".codex", "config.toml"), nil
|
||||
}
|
||||
|
||||
// Manager performs guarded edits on one config.toml file.
|
||||
type Manager struct {
|
||||
path string
|
||||
backupDir string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// New builds a manager for configPath, storing backups in backupDir.
|
||||
func New(configPath, backupDir string) *Manager {
|
||||
return &Manager{path: configPath, backupDir: backupDir}
|
||||
}
|
||||
|
||||
// ApplyOptions describes what gets injected into config.toml.
|
||||
type ApplyOptions struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
}
|
||||
|
||||
// Injection is the managed state written into config.toml. It doubles
|
||||
// as the preview returned to the UI after an apply.
|
||||
type Injection struct {
|
||||
ModelProvider string `json:"model_provider"`
|
||||
Table map[string]string `json:"table"`
|
||||
// PriorProvider is the model_provider value found before the
|
||||
// apply, empty when the file carried none.
|
||||
PriorProvider string `json:"prior_provider,omitempty"`
|
||||
}
|
||||
|
||||
// Apply injects the relay configuration, backing up the current file
|
||||
// first when the content changes. It returns the resulting injection.
|
||||
func (m *Manager) Apply(opts ApplyOptions) (*Injection, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
original, err := m.readRaw()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := splitLines(original)
|
||||
prior := readTopLevelKey(lines, "model_provider")
|
||||
lines = setTopLevelKey(lines, "model_provider", ProviderID)
|
||||
lines = setTable(lines, TableName, managedTableLines(opts))
|
||||
updated := joinLines(lines)
|
||||
if original == nil || !bytes.Equal(original, updated) {
|
||||
if len(original) > 0 {
|
||||
if err := m.backupLocked(original); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := m.writeFile(updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Injection{
|
||||
ModelProvider: ProviderID,
|
||||
Table: map[string]string{
|
||||
"name": ProviderID,
|
||||
"wire_api": "responses",
|
||||
"base_url": opts.BaseURL,
|
||||
"experimental_bearer_token": opts.Token,
|
||||
"requires_openai_auth": "false",
|
||||
},
|
||||
PriorProvider: prior,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RemoveManaged deletes the keys CrossRelay manages. priorProvider is
|
||||
// written back into model_provider when non-empty; otherwise the key
|
||||
// is dropped, and only while it still carries the managed value.
|
||||
func (m *Manager) RemoveManaged(priorProvider string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
original, err := m.readRaw()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(original) == 0 {
|
||||
return nil
|
||||
}
|
||||
lines := splitLines(original)
|
||||
lines = removeTable(lines, TableName)
|
||||
if prior := strings.TrimSpace(priorProvider); prior != "" {
|
||||
lines = setTopLevelKey(lines, "model_provider", prior)
|
||||
} else {
|
||||
lines = removeTopLevelKey(lines, "model_provider", ProviderID)
|
||||
}
|
||||
updated := joinLines(lines)
|
||||
if bytes.Equal(original, updated) {
|
||||
return nil
|
||||
}
|
||||
return m.writeFile(updated)
|
||||
}
|
||||
|
||||
// RestoreBackup copies a backup file over config.toml and returns the
|
||||
// backup name.
|
||||
func (m *Manager) RestoreBackup(name string) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if !strings.HasPrefix(name, "config-") || !strings.HasSuffix(name, ".toml") ||
|
||||
strings.ContainsAny(name, `/\`) {
|
||||
return "", fmt.Errorf("invalid backup name %q", name)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(m.backupDir, name))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := m.writeFile(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// BackupInfo describes one backup file.
|
||||
type BackupInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Modified time.Time `json:"modified"`
|
||||
}
|
||||
|
||||
// Backups lists backup files, newest first.
|
||||
func (m *Manager) Backups() ([]BackupInfo, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.listBackupsLocked()
|
||||
}
|
||||
|
||||
func managedTableLines(opts ApplyOptions) []string {
|
||||
return []string{
|
||||
fmt.Sprintf("name = %q", ProviderID),
|
||||
`wire_api = "responses"`,
|
||||
fmt.Sprintf("base_url = %q", opts.BaseURL),
|
||||
fmt.Sprintf("experimental_bearer_token = %q", opts.Token),
|
||||
"requires_openai_auth = false",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) readRaw() ([]byte, error) {
|
||||
data, err := os.ReadFile(m.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (m *Manager) writeFile(data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(m.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := m.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, m.path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) backupLocked(data []byte) error {
|
||||
if err := os.MkdirAll(m.backupDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
name := fmt.Sprintf("config-%s.toml", time.Now().Format("20060102-150405"))
|
||||
if err := os.WriteFile(filepath.Join(m.backupDir, name), data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
m.pruneLocked(20)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) listBackupsLocked() ([]BackupInfo, error) {
|
||||
entries, err := os.ReadDir(m.backupDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []BackupInfo{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := []BackupInfo{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasPrefix(e.Name(), "config-") || !strings.HasSuffix(e.Name(), ".toml") {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, BackupInfo{Name: e.Name(), Size: info.Size(), Modified: info.ModTime()})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Modified.After(out[j].Modified) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Manager) pruneLocked(keep int) {
|
||||
backups, err := m.listBackupsLocked()
|
||||
if err != nil || len(backups) <= keep {
|
||||
return
|
||||
}
|
||||
for _, b := range backups[keep:] {
|
||||
_ = os.Remove(filepath.Join(m.backupDir, b.Name))
|
||||
}
|
||||
}
|
||||
|
||||
// splitLines splits file content on "\n". A trailing newline shows up
|
||||
// as a final empty element, so joining reproduces the file exactly,
|
||||
// CRLF endings included.
|
||||
func splitLines(data []byte) []string {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(string(data), "\n")
|
||||
}
|
||||
|
||||
func joinLines(lines []string) []byte {
|
||||
out := strings.Join(lines, "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] != "" {
|
||||
out += "\n"
|
||||
}
|
||||
return []byte(out)
|
||||
}
|
||||
|
||||
func isHeaderLine(line string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(line), "[")
|
||||
}
|
||||
|
||||
func parseKeyValue(line string) (key, value string, ok bool) {
|
||||
s := strings.TrimSpace(line)
|
||||
if s == "" || strings.HasPrefix(s, "#") || strings.HasPrefix(s, "[") {
|
||||
return "", "", false
|
||||
}
|
||||
eq := strings.Index(s, "=")
|
||||
if eq < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
key = strings.Trim(strings.TrimSpace(s[:eq]), `"'`)
|
||||
if key == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return key, unquoteTOML(strings.TrimSpace(s[eq+1:])), true
|
||||
}
|
||||
|
||||
func unquoteTOML(v string) string {
|
||||
if len(v) >= 2 && v[0] == '"' {
|
||||
if end := strings.Index(v[1:], `"`); end >= 0 {
|
||||
if uq, err := strconv.Unquote(v[:end+2]); err == nil {
|
||||
return uq
|
||||
}
|
||||
return v[1 : end+1]
|
||||
}
|
||||
}
|
||||
if len(v) >= 2 && v[0] == '\'' {
|
||||
if end := strings.Index(v[1:], "'"); end >= 0 {
|
||||
return v[1 : end+1]
|
||||
}
|
||||
}
|
||||
if i := strings.Index(v, "#"); i >= 0 {
|
||||
v = strings.TrimSpace(v[:i])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// findTopLevelKey returns the index of a key assignment in the
|
||||
// top-level section (before the first table header).
|
||||
func findTopLevelKey(lines []string, key string) int {
|
||||
for i, line := range lines {
|
||||
if isHeaderLine(line) {
|
||||
return -1
|
||||
}
|
||||
if k, _, ok := parseKeyValue(line); ok && k == key {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func readTopLevelKey(lines []string, key string) string {
|
||||
if i := findTopLevelKey(lines, key); i >= 0 {
|
||||
if _, v, ok := parseKeyValue(lines[i]); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// setTopLevelKey replaces an existing top-level key or inserts one at
|
||||
// the end of the top-level section.
|
||||
func setTopLevelKey(lines []string, key, value string) []string {
|
||||
line := fmt.Sprintf("%s = %q", key, value)
|
||||
if i := findTopLevelKey(lines, key); i >= 0 {
|
||||
out := append([]string{}, lines...)
|
||||
out[i] = line
|
||||
return out
|
||||
}
|
||||
return insertTopLevelLine(lines, line)
|
||||
}
|
||||
|
||||
func insertTopLevelLine(lines []string, line string) []string {
|
||||
idx := len(lines)
|
||||
for i, l := range lines {
|
||||
if isHeaderLine(l) {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// A trailing empty element represents the file's final newline; new
|
||||
// lines go before it.
|
||||
if idx == len(lines) && idx > 0 && lines[idx-1] == "" {
|
||||
idx--
|
||||
}
|
||||
out := make([]string, 0, len(lines)+1)
|
||||
out = append(out, lines[:idx]...)
|
||||
out = append(out, line)
|
||||
out = append(out, lines[idx:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// removeTopLevelKey drops a top-level key line, but only while it
|
||||
// still carries the given value.
|
||||
func removeTopLevelKey(lines []string, key, value string) []string {
|
||||
i := findTopLevelKey(lines, key)
|
||||
if i < 0 {
|
||||
return lines
|
||||
}
|
||||
_, v, ok := parseKeyValue(lines[i])
|
||||
if !ok || v != value {
|
||||
return lines
|
||||
}
|
||||
out := make([]string, 0, len(lines))
|
||||
out = append(out, lines[:i]...)
|
||||
out = append(out, lines[i+1:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// setTable replaces the body of an existing table or appends a new
|
||||
// table at the end of the file.
|
||||
func setTable(lines []string, name string, body []string) []string {
|
||||
header := "[" + name + "]"
|
||||
if i := findTable(lines, name); i >= 0 {
|
||||
end := tableEnd(lines, i)
|
||||
var sep []string
|
||||
if end < len(lines) && isHeaderLine(lines[end]) && end > i+1 && strings.TrimSpace(lines[end-1]) == "" {
|
||||
sep = []string{""}
|
||||
}
|
||||
out := make([]string, 0, len(lines)+len(body)+1)
|
||||
out = append(out, lines[:i+1]...)
|
||||
out = append(out, body...)
|
||||
out = append(out, sep...)
|
||||
out = append(out, lines[end:]...)
|
||||
return out
|
||||
}
|
||||
block := append([]string{header}, body...)
|
||||
end := len(lines)
|
||||
if end > 0 && lines[end-1] == "" {
|
||||
end--
|
||||
}
|
||||
if end > 0 && strings.TrimSpace(lines[end-1]) != "" {
|
||||
block = append([]string{""}, block...)
|
||||
}
|
||||
out := make([]string, 0, len(lines)+len(block)+1)
|
||||
out = append(out, lines[:end]...)
|
||||
out = append(out, block...)
|
||||
out = append(out, lines[end:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// removeTable drops the named table plus one preceding blank
|
||||
// separator so no double blank lines are left behind.
|
||||
func removeTable(lines []string, name string) []string {
|
||||
i := findTable(lines, name)
|
||||
if i < 0 {
|
||||
return lines
|
||||
}
|
||||
end := tableEnd(lines, i)
|
||||
start := i
|
||||
if start > 0 && strings.TrimSpace(lines[start-1]) == "" {
|
||||
after := end >= len(lines) || strings.TrimSpace(lines[end]) == "" || isHeaderLine(lines[end])
|
||||
if after {
|
||||
start--
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(lines))
|
||||
out = append(out, lines[:start]...)
|
||||
out = append(out, lines[end:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
func findTable(lines []string, name string) int {
|
||||
want := "[" + name + "]"
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == want {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func tableEnd(lines []string, headerIdx int) int {
|
||||
end := headerIdx + 1
|
||||
for end < len(lines) && !isHeaderLine(lines[end]) {
|
||||
end++
|
||||
}
|
||||
return end
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package codexcfg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// richConfig mimics a real Codex config.toml: a custom provider, MCP
|
||||
// servers and project trust entries that must survive untouched.
|
||||
const richConfig = `# Codex settings
|
||||
model_provider = "custom"
|
||||
model_reasoning_effort = "high"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "custom"
|
||||
wire_api = "responses"
|
||||
base_url = "https://cpa.rutua.cn/v1"
|
||||
|
||||
[mcp_servers.docker]
|
||||
command = "docker.exe"
|
||||
args = ["mcp", "gateway", "run"]
|
||||
|
||||
[projects.'C:\Users\me\proj']
|
||||
trust_level = "trusted"
|
||||
`
|
||||
|
||||
func newMgr(t *testing.T) (*Manager, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
return New(path, filepath.Join(dir, "backups")), path
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readConfig(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestApplyCreatesFileWhenMissing(t *testing.T) {
|
||||
mgr, path := newMgr(t)
|
||||
injection, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:9000", Token: "crl_x"})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if injection.ModelProvider != ProviderID {
|
||||
t.Errorf("model_provider = %q", injection.ModelProvider)
|
||||
}
|
||||
if injection.PriorProvider != "" {
|
||||
t.Errorf("prior provider = %q, want empty", injection.PriorProvider)
|
||||
}
|
||||
got := readConfig(t, path)
|
||||
for _, want := range []string{
|
||||
`model_provider = "crossrelay"`,
|
||||
"[model_providers.crossrelay]",
|
||||
`name = "crossrelay"`,
|
||||
`wire_api = "responses"`,
|
||||
`base_url = "http://127.0.0.1:9000"`,
|
||||
`experimental_bearer_token = "crl_x"`,
|
||||
"requires_openai_auth = false",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("created config missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPreservesEverythingElse(t *testing.T) {
|
||||
mgr, path := newMgr(t)
|
||||
writeConfig(t, path, richConfig)
|
||||
injection, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:9000", Token: "crl_x"})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if injection.PriorProvider != "custom" {
|
||||
t.Errorf("prior provider = %q, want custom", injection.PriorProvider)
|
||||
}
|
||||
got := readConfig(t, path)
|
||||
for _, want := range []string{
|
||||
"# Codex settings",
|
||||
`model_reasoning_effort = "high"`,
|
||||
"[model_providers.custom]",
|
||||
`base_url = "https://cpa.rutua.cn/v1"`,
|
||||
"[mcp_servers.docker]",
|
||||
`command = "docker.exe"`,
|
||||
`args = ["mcp", "gateway", "run"]`,
|
||||
`[projects.'C:\Users\me\proj']`,
|
||||
`trust_level = "trusted"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("config lost %q after apply:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Count(got, "model_reasoning_effort") != 1 {
|
||||
t.Errorf("unrelated key duplicated:\n%s", got)
|
||||
}
|
||||
if strings.Count(got, "[model_providers.") != 2 {
|
||||
t.Errorf("provider tables = %d, want 2:\n%s", strings.Count(got, "[model_providers."), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyIsIdempotent(t *testing.T) {
|
||||
mgr, path := newMgr(t)
|
||||
writeConfig(t, path, richConfig)
|
||||
opts := ApplyOptions{BaseURL: "http://127.0.0.1:9000", Token: "crl_x"}
|
||||
if _, err := mgr.Apply(opts); err != nil {
|
||||
t.Fatalf("first Apply: %v", err)
|
||||
}
|
||||
first := readConfig(t, path)
|
||||
if _, err := mgr.Apply(opts); err != nil {
|
||||
t.Fatalf("second Apply: %v", err)
|
||||
}
|
||||
if second := readConfig(t, path); second != first {
|
||||
t.Errorf("second apply changed the file:\nfirst:\n%s\nsecond:\n%s", first, second)
|
||||
}
|
||||
backups, err := mgr.Backups()
|
||||
if err != nil {
|
||||
t.Fatalf("Backups: %v", err)
|
||||
}
|
||||
if len(backups) != 1 {
|
||||
t.Errorf("backups = %d, want 1 (only the changing apply backs up)", len(backups))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveManagedRestoresPriorProvider(t *testing.T) {
|
||||
mgr, path := newMgr(t)
|
||||
writeConfig(t, path, richConfig)
|
||||
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:9000", Token: "crl_x"}); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if err := mgr.RemoveManaged("custom"); err != nil {
|
||||
t.Fatalf("RemoveManaged: %v", err)
|
||||
}
|
||||
got := readConfig(t, path)
|
||||
if !strings.Contains(got, `model_provider = "custom"`) {
|
||||
t.Errorf("prior provider not restored:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "crossrelay") {
|
||||
t.Errorf("managed keys survived removal:\n%s", got)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`model_reasoning_effort = "high"`,
|
||||
"[mcp_servers.docker]",
|
||||
`trust_level = "trusted"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("config lost %q after removal:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveManagedWithoutPriorDropsKey(t *testing.T) {
|
||||
mgr, path := newMgr(t)
|
||||
writeConfig(t, path, "model_provider = \"crossrelay\"\n\n[model_providers.crossrelay]\nname = \"crossrelay\"\n\n[other]\nx = 1\n")
|
||||
if err := mgr.RemoveManaged(""); err != nil {
|
||||
t.Fatalf("RemoveManaged: %v", err)
|
||||
}
|
||||
got := readConfig(t, path)
|
||||
if strings.Contains(got, "model_provider") {
|
||||
t.Errorf("managed key survived:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "[other]") || !strings.Contains(got, "x = 1") {
|
||||
t.Errorf("unrelated table damaged:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveManagedKeepsUserChangedProvider(t *testing.T) {
|
||||
mgr, path := newMgr(t)
|
||||
writeConfig(t, path, richConfig)
|
||||
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:9000", Token: "crl_x"}); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
// The user switches provider by hand after the apply.
|
||||
writeConfig(t, path, strings.Replace(readConfig(t, path), `model_provider = "crossrelay"`, `model_provider = "custom"`, 1))
|
||||
if err := mgr.RemoveManaged(""); err != nil {
|
||||
t.Fatalf("RemoveManaged: %v", err)
|
||||
}
|
||||
got := readConfig(t, path)
|
||||
if !strings.Contains(got, `model_provider = "custom"`) {
|
||||
t.Errorf("user's provider choice was deleted:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "model_providers.crossrelay") {
|
||||
t.Errorf("managed table survived:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupAndRestoreBackup(t *testing.T) {
|
||||
mgr, path := newMgr(t)
|
||||
writeConfig(t, path, richConfig)
|
||||
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:9000", Token: "crl_x"}); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
backups, err := mgr.Backups()
|
||||
if err != nil {
|
||||
t.Fatalf("Backups: %v", err)
|
||||
}
|
||||
if len(backups) != 1 {
|
||||
t.Fatalf("backups = %d, want 1", len(backups))
|
||||
}
|
||||
name, err := mgr.RestoreBackup(backups[0].Name)
|
||||
if err != nil {
|
||||
t.Fatalf("RestoreBackup: %v", err)
|
||||
}
|
||||
if name != backups[0].Name {
|
||||
t.Errorf("restored name = %q", name)
|
||||
}
|
||||
if got := readConfig(t, path); got != richConfig {
|
||||
t.Errorf("rollback content mismatch:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreBackupRejectsForeignNames(t *testing.T) {
|
||||
mgr, _ := newMgr(t)
|
||||
if _, err := mgr.RestoreBackup("settings-20260101-000000.json"); err == nil {
|
||||
t.Error("RestoreBackup accepted a non-config backup name")
|
||||
}
|
||||
if _, err := mgr.RestoreBackup("../config.toml"); err == nil {
|
||||
t.Error("RestoreBackup accepted a path traversal name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCRLFPreserved(t *testing.T) {
|
||||
mgr, path := newMgr(t)
|
||||
crlf := strings.ReplaceAll(richConfig, "\n", "\r\n")
|
||||
writeConfig(t, path, crlf)
|
||||
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:9000", Token: "crl_x"}); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
got := readConfig(t, path)
|
||||
for _, line := range []string{`command = "docker.exe"`, `trust_level = "trusted"`, `model_reasoning_effort = "high"`} {
|
||||
if !strings.Contains(got, line+"\r\n") {
|
||||
t.Errorf("line %q lost its CRLF ending:\n%q", line, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPathHonoursCodexHome(t *testing.T) {
|
||||
t.Setenv("CODEX_HOME", `C:\codex-home`)
|
||||
path, err := DefaultPath()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultPath: %v", err)
|
||||
}
|
||||
if path != filepath.Join(`C:\codex-home`, "config.toml") {
|
||||
t.Errorf("path = %q", path)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user