carlos / platform Public

auth: .carlos/config default-console file and lookup walk

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxXEWoRXU8WVTXjTJypuJ5
Paul Campbell 04c18808382e50763ade4a4cae5df23902ceb992 parent fd5f6f3
3 files changed, +300 −2
  • auth_test.go +9 −2
  • config.go +161 −0
  • config_test.go +130 −0
diff --git a/auth_test.go b/auth_test.go
index ae735e5df..9e2536c3b 100644
--- a/auth_test.go
+++ b/auth_test.go
@@ -13,10 +13,17 @@ import (
"time"
)
-// useTempCredentials points the credentials file at a scratch path for the
-// length of one test.
+// useTempCredentials points everything the CLI's console resolution reads —
+// the credentials file, $HOME, the working directory, CARLOS_CONSOLE — at
+// scratch space for the length of one test. Config resolution (config.go)
+// consults all four; a developer's real ~/.carlos/config must never change
+// a test's answer.
func useTempCredentials(t *testing.T) string {
t.Helper()
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("CARLOS_CONSOLE", "")
+ t.Chdir(t.TempDir())
path := filepath.Join(t.TempDir(), "carlos", "credentials")
t.Setenv("CARLOS_CREDENTIALS", path)
return path
diff --git a/config.go b/config.go
new file mode 100644
index 000000000..b11c3171c
--- /dev/null
+++ b/config.go
@@ -0,0 +1,161 @@
+// The .carlos/config file: a settable default console.
+//
+// Two levels, one shape. ~/.carlos/config is the machine's default;
+// .carlos/config at a project root — committed to the repo — pins that
+// project to a deployment (the titogo checkout says the Tito stack; a
+// checkout with no opinion falls to the global). The nearest .carlos/config
+// walking up from the working directory wins.
+//
+// A resolved default is authoritative: resolution never falls past it to
+// "the only credential on this machine". Holding only a flagship credential
+// inside a checkout pinned to another stack is an error naming that stack —
+// never a quiet ship to the flagship. The single-credential convenience
+// exists only for the no-default world.
+//
+// The file is deliberately not a secret and never holds one: nothing from
+// ~/.carlos/credentials moves here, ever. It IS repo-controlled input when
+// committed, which a malicious PR could repoint — accepted in the spec
+// (docs/superpowers/specs/2026-08-04-console-default-resolution-design.md)
+// because per-origin credentials mean no token ever crosses consoles, and
+// every verb prints the console it is talking to.
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// defaultConsole is where `carlos auth login` lands when nothing — flag,
+// env, project config, global config — names a console: the flagship. Login
+// only; no other verb ever assumes it.
+const defaultConsole = "https://platform.carlosframework.com"
+
+// cliConfig is the whole file. Unknown fields are ignored on read and
+// dropped on rewrite: the file is additive, and the CLI is its only parser.
+type cliConfig struct {
+ Console string `json:"console"`
+}
+
+// loadConfigFile reads one .carlos/config. A missing file, or one without a
+// console, is ("", nil): "no default at this level", and resolution
+// continues. A file that cannot be parsed, or names a console
+// normalizeConsole refuses, is an ERROR — silently ignoring a typo'd
+// config would ship to the wrong console, the exact mistake this file
+// exists to prevent.
+func loadConfigFile(path string) (string, error) {
+ raw, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return "", nil
+ }
+ if err != nil {
+ return "", err
+ }
+ var cfg cliConfig
+ if err := json.Unmarshal(raw, &cfg); err != nil {
+ return "", fmt.Errorf("%s is not readable as a carlos config: %w", path, err)
+ }
+ if strings.TrimSpace(cfg.Console) == "" {
+ return "", nil
+ }
+ n, err := normalizeConsole(cfg.Console)
+ if err != nil {
+ return "", fmt.Errorf("%s: %w", path, err)
+ }
+ return n, nil
+}
+
+// configDefault resolves the configured default console: the nearest
+// .carlos/config walking up from the working directory, else
+// ~/.carlos/config. Returns the normalized console and the file it came
+// from — the source goes into error messages, because "not logged in to X"
+// is only actionable if you know WHICH file said X.
+//
+// If the walk passes through $HOME it finds ~/.carlos/config by the same
+// rule — the global default arriving one step early, same answer. The
+// explicit global check after the walk is for working directories outside
+// $HOME entirely.
+func configDefault() (console, source string, err error) {
+ if dir, werr := os.Getwd(); werr == nil {
+ for {
+ path := filepath.Join(dir, ".carlos", "config")
+ c, err := loadConfigFile(path)
+ if err != nil {
+ return "", "", err
+ }
+ if c != "" {
+ return c, path, nil
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ break
+ }
+ dir = parent
+ }
+ }
+ path, perr := globalConfigPath()
+ if perr != nil {
+ // No home directory means no global config — a state, not an error.
+ return "", "", nil
+ }
+ c, err := loadConfigFile(path)
+ if err != nil {
+ return "", "", err
+ }
+ if c == "" {
+ return "", "", nil
+ }
+ return c, path, nil
+}
+
+// globalConfigPath is ~/.carlos/config — the same directory that holds
+// credentials, though this file is not a secret and never holds one.
+func globalConfigPath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("no home directory for ~/.carlos/config: %w", err)
+ }
+ return filepath.Join(home, ".carlos", "config"), nil
+}
+
+// saveConfigFile validates the console and writes {"console": …} at path,
+// creating the .carlos directory (dirPerm: 0700 for ~/.carlos, which
+// MkdirAll leaves alone if it already exists; 0755 for a project directory
+// that is meant to be committed). Temp-file-then-rename like
+// credentialFile.save, so a crash cannot leave a truncated config — but
+// 0644, not 0600: this file is not a secret.
+func saveConfigFile(path, console string, dirPerm os.FileMode) error {
+ n, err := normalizeConsole(console)
+ if err != nil {
+ return err
+ }
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, dirPerm); err != nil {
+ return err
+ }
+ body, err := json.MarshalIndent(cliConfig{Console: n}, "", " ")
+ if err != nil {
+ return err
+ }
+ body = append(body, '\n')
+ tmp, err := os.CreateTemp(dir, ".config-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp.Name())
+ if err := tmp.Chmod(0o644); err != nil {
+ tmp.Close()
+ return err
+ }
+ if _, err := tmp.Write(body); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ return os.Rename(tmp.Name(), path)
+}
diff --git a/config_test.go b/config_test.go
new file mode 100644
index 000000000..22890c844
--- /dev/null
+++ b/config_test.go
@@ -0,0 +1,130 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// writeConfig plants a .carlos/config under dir, the way a checked-in
+// project file or `carlos auth default` would.
+func writeConfig(t *testing.T, dir, body string) string {
+ t.Helper()
+ path := filepath.Join(dir, ".carlos", "config")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func TestLoadConfigFile(t *testing.T) {
+ useTempCredentials(t)
+ dir := t.TempDir()
+
+ // Missing file: no default at this level, not an error.
+ if c, err := loadConfigFile(filepath.Join(dir, ".carlos", "config")); err != nil || c != "" {
+ t.Fatalf("missing config = %q, %v; want empty, nil", c, err)
+ }
+
+ // A well-formed file: the console comes back normalized.
+ p := writeConfig(t, dir, `{"console": "https://tito.example/"}`+"\n")
+ if c, err := loadConfigFile(p); err != nil || c != "https://tito.example" {
+ t.Fatalf("loadConfigFile = %q, %v; want the normalized console", c, err)
+ }
+
+ // Unknown fields are ignored (additive evolution), and a file WITHOUT
+ // a console field is "no default at this level", not an error.
+ os.WriteFile(p, []byte(`{"future": true}`), 0o644)
+ if c, err := loadConfigFile(p); err != nil || c != "" {
+ t.Fatalf("config without console = %q, %v; want empty, nil", c, err)
+ }
+
+ // Malformed JSON is an error naming the file — silently skipping a
+ // typo'd config would ship to the wrong console.
+ os.WriteFile(p, []byte(`{`), 0o644)
+ if _, err := loadConfigFile(p); err == nil || !strings.Contains(err.Error(), p) {
+ t.Fatalf("malformed config = %v, want an error naming %s", err, p)
+ }
+
+ // A console normalizeConsole rejects is an error too, same reasoning.
+ os.WriteFile(p, []byte(`{"console": "ftp://nope"}`), 0o644)
+ if _, err := loadConfigFile(p); err == nil || !strings.Contains(err.Error(), p) {
+ t.Fatalf("bad-URL config = %v, want an error naming %s", err, p)
+ }
+}
+
+func TestConfigDefaultWalk(t *testing.T) {
+ useTempCredentials(t)
+ home := os.Getenv("HOME")
+
+ // Nothing anywhere: no default, no error.
+ if c, src, err := configDefault(); err != nil || c != "" || src != "" {
+ t.Fatalf("bare configDefault = %q %q %v, want empty", c, src, err)
+ }
+
+ // Only the global: the global wins, and the source names the file.
+ globalPath := writeConfig(t, home, `{"console": "https://global.example"}`)
+ c, src, err := configDefault()
+ if err != nil || c != "https://global.example" || src != globalPath {
+ t.Fatalf("global-only = %q %q %v", c, src, err)
+ }
+
+ // A project config anywhere up the cwd walk beats the global, and the
+ // NEAREST project config beats a farther one.
+ root := t.TempDir()
+ writeConfig(t, root, `{"console": "https://outer.example"}`)
+ nested := filepath.Join(root, "cmd", "deep")
+ if err := os.MkdirAll(nested, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Chdir(nested)
+ if c, _, err = configDefault(); err != nil || c != "https://outer.example" {
+ t.Fatalf("walk from nested = %q %v, want the ancestor's config", c, err)
+ }
+ nearest := writeConfig(t, nested, `{"console": "https://inner.example"}`)
+ if c, src, err = configDefault(); err != nil || c != "https://inner.example" || src != nearest {
+ t.Fatalf("nearest = %q %q %v", c, src, err)
+ }
+
+ // A level whose config has no console does not stop the walk.
+ os.WriteFile(nearest, []byte(`{"future": true}`), 0o644)
+ if c, _, err = configDefault(); err != nil || c != "https://outer.example" {
+ t.Fatalf("empty-console level = %q %v, want the walk to continue", c, err)
+ }
+
+ // A malformed config mid-walk is an error, not a skip.
+ os.WriteFile(nearest, []byte(`not json`), 0o644)
+ if _, _, err = configDefault(); err == nil {
+ t.Fatal("malformed project config must be an error, not a skip")
+ }
+}
+
+func TestSaveConfigFile(t *testing.T) {
+ useTempCredentials(t)
+ dir := t.TempDir()
+ path := filepath.Join(dir, ".carlos", "config")
+
+ // The URL is validated and normalized on WRITE, so a bad default is
+ // caught when set, not at first use.
+ if err := saveConfigFile(path, "ftp://nope", 0o755); err == nil {
+ t.Fatal("saving an invalid console must fail")
+ }
+ if err := saveConfigFile(path, "https://tito.example/", 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if c, err := loadConfigFile(path); err != nil || c != "https://tito.example" {
+ t.Fatalf("round-trip = %q, %v", c, err)
+ }
+ // Not a secret: world-readable is fine and expected in a repo.
+ fi, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fi.Mode().Perm() != 0o644 {
+ t.Fatalf("config mode = %v, want 0644", fi.Mode().Perm())
+ }
+}