carlos / platform Public

Merge pull request #51 from carlosframework/worktree-console-default-config

carlos: settable default consoles (~/.carlos/config + per-project .carlos/config)
Paul Campbell 761d3d0104b64d23bf06e0996a4dd926ca89d1b6 parent 088358dparent c5e138e
10 files changed, +1897 −78
  • CLAUDE.md +6 −4
  • auth.go +132 −22
  • auth_e2e_test.go +42 −12
  • auth_test.go +9 −10
  • config.go +161 −0
  • config_test.go +327 −0
  • docs/superpowers/plans/2026-08-04-console-default-resolution.md +939 −0
  • docs/superpowers/specs/2026-08-04-console-default-resolution-design.md +162 −0
  • e2e_test.go +99 −22
  • scopedkeys_e2e_test.go +20 −8
diff --git a/CLAUDE.md b/CLAUDE.md
index c1c195707..363f011c8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -50,10 +50,12 @@ capable model — exact values, file paths and interfaces go in the brief.
"verified" reports in one day.
- Icons are vendored Lucide (in-code SVG constants, never CDN); emoji is
content, icons are chrome.
-- **A test that shells out to the carlos binary must set `CARLOS_CREDENTIALS`
- into its tempdir.** The fallback is the operator's real login; a logged-in
- laptop running `go test ./...` minted 40+ real accounts on the production
- console before this was isolated (`carlosTestEnv`).
+- **A test that shells out to the carlos binary must build its env through
+ `carlosTestEnv`** (masks `CARLOS_CREDENTIALS`, `HOME`, `CARLOS_CONSOLE`)
+ **and set `cmd.Dir` to a scratch dir** — the binary walks cwd ancestors for
+ `.carlos/config`. The fallback is the operator's real login and default
+ console; a logged-in laptop running `go test ./...` minted 40+ real accounts
+ on the production console before this was isolated.
## Deploy reality (2026-08-02)
diff --git a/auth.go b/auth.go
index fba2b8a39..e8053f778 100644
--- a/auth.go
+++ b/auth.go
@@ -48,20 +48,29 @@ import (
"time"
)
-const authUsage = `usage: carlos auth <login|whoami|logout>
-
- login -console <url> log this terminal in to a deployment's console.
- Prints a code; you approve it in a browser that
- is already signed in to that console. No browser
- on this machine is needed — the code travels.
+const authUsage = `usage: carlos auth <login|whoami|logout|default>
+
+ login [-console <url>] log this terminal in to a deployment's console
+ (default: the configured default console, else
+ https://platform.carlosframework.com). Prints a
+ code; you approve it in a browser that is already
+ signed in to that console. No browser on this
+ machine is needed — the code travels.
[-label <name>] what to call this terminal on the console's
command-line access page (default: user@host)
whoami [-console <url>] ask the console who this terminal is
logout [-console <url>] revoke this terminal's token and forget it
+ default [-project] [<url>]
+ show the default console and where it is set, or
+ set it: writes ~/.carlos/config — or, with
+ -project, ./.carlos/config, which overrides the
+ global and is meant to be committed so a checkout
+ pins its own deployment
The token lands in ~/.carlos/credentials (mode 0600), one entry per console
origin — a laptop can hold credentials for the flagship and a self-hosted
-deployment at once. -console may be omitted when there is exactly one.
+deployment at once. -console may be omitted when there is exactly one, or
+when a default is set.
HEADLESS AND BOX-SIDE USE: do not log in. A box has no browser and no human,
and a long-lived token on it is a standing credential nobody is watching.
@@ -83,6 +92,8 @@ func RunAuth(args []string) {
err = authWhoami(os.Stdout, args[1:])
case "logout":
err = authLogout(os.Stdout, args[1:])
+ case "default":
+ err = authDefault(os.Stdout, args[1:])
default:
fmt.Fprint(os.Stderr, authUsage)
os.Exit(1)
@@ -236,14 +247,37 @@ func (f *credentialFile) remove(console string) bool {
return false
}
-// pick resolves which credential a verb should use. An explicit -console
-// wins; otherwise the only credential is used, and several is a refusal
-// rather than a guess — shipping to the wrong deployment because the CLI
-// picked one for you is not a mistake worth being convenient about.
+// pick resolves which credential a verb should use: an explicit -console,
+// else CARLOS_CONSOLE, else the configured default (.carlos/config walking
+// up from cwd, then ~/.carlos/config — config.go), else the only live
+// credential. Several with no default is a refusal rather than a guess —
+// shipping to the wrong deployment because the CLI picked one for you is
+// not a mistake worth being convenient about.
+//
+// Any explicitly named console — flag, env, or config — is authoritative:
+// it resolves against credentials for THAT console or errors. It never
+// falls back to "the only credential", because inside a checkout pinned to
+// one stack, quietly using a credential for a different stack is the wrong
+// deployment with extra steps.
func (f *credentialFile) pick(console string) (*credential, error) {
if console == "" {
console = os.Getenv("CARLOS_CONSOLE")
}
+ from := ""
+ if console == "" {
+ var src string
+ var err error
+ console, src, err = configDefault()
+ if err != nil {
+ return nil, err
+ }
+ if console != "" {
+ // Only a config-sourced console gets the clause: whoever typed
+ // -console or exported CARLOS_CONSOLE knows where it came from,
+ // but a default set weeks ago in a file needs naming.
+ from = fmt.Sprintf(" (the default from %s)", src)
+ }
+ }
if console != "" {
want, err := normalizeConsole(console)
if err != nil {
@@ -251,11 +285,11 @@ func (f *credentialFile) pick(console string) (*credential, error) {
}
c := f.find(want)
if c == nil {
- return nil, fmt.Errorf("not logged in to %s — run: carlos auth login -console %s", want, want)
+ return nil, fmt.Errorf("not logged in to %s%s — run: carlos auth login -console %s", want, from, want)
}
if c.Expired(time.Now()) {
- return nil, fmt.Errorf("the credential for %s expired %s — run: carlos auth login -console %s",
- want, c.ExpiresAt.Format(time.RFC3339), want)
+ return nil, fmt.Errorf("the credential for %s%s expired %s — run: carlos auth login -console %s",
+ want, from, c.ExpiresAt.Format(time.RFC3339), want)
}
return c, nil
}
@@ -269,13 +303,15 @@ func (f *credentialFile) pick(console string) (*credential, error) {
case 1:
return live[0], nil
case 0:
+ // EXACT wording — apimode.go's noCredentialsMsg matches this string.
return nil, errors.New("not logged in — run: carlos auth login -console <url>")
default:
var names []string
for _, c := range live {
names = append(names, c.Console)
}
- return nil, fmt.Errorf("logged in to several consoles — say which with -console: %s",
+ return nil, fmt.Errorf("logged in to several consoles — say which with -console, "+
+ "or set a default with `carlos auth default <url>`: %s",
strings.Join(names, ", "))
}
}
@@ -533,20 +569,37 @@ func loginDeadline(now time.Time, expiresIn int) time.Time {
return now.Add(d)
}
+// loginConsole is login's resolution chain: flag → CARLOS_CONSOLE → the
+// configured default → the flagship. Login alone gets the built-in landing
+// place: there may be no credential yet to pick from, and the flagship is
+// the product answer. Every other verb resolves through pick(), which
+// assumes nothing.
+func loginConsole(flagValue string) (string, error) {
+ base := flagValue
+ if base == "" {
+ base = os.Getenv("CARLOS_CONSOLE")
+ }
+ if base == "" {
+ var err error
+ if base, _, err = configDefault(); err != nil {
+ return "", err
+ }
+ }
+ if base == "" {
+ base = defaultConsole
+ }
+ return normalizeConsole(base)
+}
+
func authLogin(out io.Writer, args []string) error {
fs := flag.NewFlagSet("auth login", flag.ContinueOnError)
fs.SetOutput(out)
- console := fs.String("console", os.Getenv("CARLOS_CONSOLE"), "the console to log in to (https://…)")
+ console := fs.String("console", "", "the console to log in to (default: the configured default, else "+defaultConsole+")")
label := fs.String("label", "", "what to call this terminal on the console (default: user@host)")
if err := fs.Parse(args); err != nil {
return err
}
- if *console == "" {
- return errors.New("which console? pass -console https://platform.carlosframework.com " +
- "(or set CARLOS_CONSOLE). On a box with no browser, don't log in at all — " +
- "pass -owner <address> to `carlos accounts create` instead")
- }
- base, err := normalizeConsole(*console)
+ base, err := loginConsole(*console)
if err != nil {
return err
}
@@ -717,6 +770,63 @@ func authLogout(out io.Writer, args []string) error {
return nil
}
+// authDefault shows or sets the default console. Bare: report the effective
+// configured default and which file said so (flag and env are not consulted
+// — this verb reports CONFIG, and someone with CARLOS_CONSOLE exported can
+// see their own shell). With a URL: write ~/.carlos/config, or
+// ./.carlos/config under -project — relative to the working directory,
+// deliberately not "the repo root": the CLI has no VCS awareness, and
+// "run it where you want the file" beats guessing.
+func authDefault(out io.Writer, args []string) error {
+ fs := flag.NewFlagSet("auth default", flag.ContinueOnError)
+ fs.SetOutput(out)
+ project := fs.Bool("project", false, "write ./.carlos/config (this directory) instead of ~/.carlos/config")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ switch fs.NArg() {
+ case 0:
+ if *project {
+ return errors.New("-project sets a default: carlos auth default -project <url>")
+ }
+ console, src, err := configDefault()
+ if err != nil {
+ return err
+ }
+ if console == "" {
+ fmt.Fprintf(out, "no default console configured.\n")
+ fmt.Fprintf(out, "`carlos auth login` defaults to %s; other commands use the\n", defaultConsole)
+ fmt.Fprintf(out, "only stored credential, or ask when there are several.\n")
+ fmt.Fprintf(out, "Set one with: carlos auth default <url> (-project pins this directory)\n")
+ return nil
+ }
+ fmt.Fprintf(out, "%s (from %s)\n", console, src)
+ return nil
+ case 1:
+ path, err := globalConfigPath()
+ perm := os.FileMode(0o700) // ~/.carlos also holds credentials
+ if *project {
+ path, err = filepath.Join(".carlos", "config"), nil
+ perm = 0o755 // meant to be committed; nothing secret inside
+ }
+ if err != nil {
+ return err
+ }
+ if err := saveConfigFile(path, fs.Arg(0), perm); err != nil {
+ return err
+ }
+ // Echo the NORMALIZED value: what was saved, not what was typed.
+ console, err := normalizeConsole(fs.Arg(0))
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "default console: %s (written to %s)\n", console, path)
+ return nil
+ default:
+ return errors.New("usage: carlos auth default [-project] [<url>]")
+ }
+}
+
// ---- what other verbs use ---------------------------------------------------
// loggedInCredential is the seam every other verb reaches through: the
diff --git a/auth_e2e_test.go b/auth_e2e_test.go
index c1d0bb82e..b743d1e9b 100644
--- a/auth_e2e_test.go
+++ b/auth_e2e_test.go
@@ -48,25 +48,41 @@ func TestAuthCLI(t *testing.T) {
fc := newFakeConsole(t)
home := t.TempDir()
creds := filepath.Join(home, ".carlos", "credentials")
- env := append(os.Environ(), "CARLOS_CREDENTIALS="+creds)
+ env := append(os.Environ(),
+ "CARLOS_CREDENTIALS="+creds,
+ "HOME="+home,
+ "CARLOS_CONSOLE=",
+ )
run := func(args ...string) (string, error) {
cmd := exec.Command(bin, args...)
cmd.Env = env
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = home
out, err := cmd.CombinedOutput()
return string(out), err
}
- // A login with no console at all refuses, and names both ways out.
- out, err := run("auth", "login")
- if err == nil {
- t.Fatalf("carlos auth login with no -console exited 0:\n%s", out)
+ // A login with no console anywhere now lands on the BUILT-IN default —
+ // the production flagship — which this test must never talk to. Prove
+ // the default chain is consulted without crossing the network: a config
+ // default in the scratch home points at the fake console, and login
+ // with no -console follows it.
+ if err := os.MkdirAll(filepath.Join(home, ".carlos"), 0o755); err != nil {
+ t.Fatal(err)
}
- if !strings.Contains(out, "-console") || !strings.Contains(out, "-owner") {
- t.Fatalf("refusal does not name both options:\n%s", out)
+ if err := os.WriteFile(filepath.Join(home, ".carlos", "config"),
+ []byte(`{"console": "`+fc.ts.URL+`"}`+"\n"), 0o644); err != nil {
+ t.Fatal(err)
}
-
fc.approved.Store(true)
+ out, err := run("auth", "login", "-label", "e2e@box")
+ if err != nil {
+ t.Fatalf("carlos auth login via config default: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, fc.ts.URL) {
+ t.Fatalf("login did not follow the config default to the fake console:\n%s", out)
+ }
out, err = run("auth", "login", "-console", fc.ts.URL, "-label", "e2e@box")
if err != nil {
t.Fatalf("carlos auth login: %v\n%s", err, out)
@@ -116,9 +132,13 @@ func TestAccountsCreateNeedsAnOwner(t *testing.T) {
"CARLOS_DEPLOYMENT_DIR="+filepath.Join(tmp, "store"),
"CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"),
"CARLOS_DATA_DIR="+tmp,
+ "HOME="+tmp,
+ "CARLOS_CONSOLE=",
)
cmd := exec.Command(bin, "accounts", "create", "-name", "orphan")
cmd.Env = env
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("ownerless accounts create exited 0:\n%s", out)
@@ -142,13 +162,14 @@ func TestAccountsCreateRefusesANonSlugName(t *testing.T) {
bin := buildCarlos(t)
tmp := t.TempDir()
storeDir := filepath.Join(tmp, "store")
- env := append(os.Environ(),
+ env := carlosTestEnv(t,
"CARLOS_DEPLOYMENT_DIR="+storeDir,
- "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"),
"CARLOS_DATA_DIR="+tmp,
)
cmd := exec.Command(bin, "accounts", "create", "-name", "Paul's Stuff!", "-owner", "paul@keymail.dev")
cmd.Env = env
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("non-slug accounts create exited 0:\n%s", out)
@@ -174,7 +195,11 @@ func TestAccountsCreateWithOwnerFlag(t *testing.T) {
"CARLOS_DEPLOYMENT_DIR="+storeDir,
"CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"),
"CARLOS_DATA_DIR="+tmp,
+ "HOME="+tmp,
+ "CARLOS_CONSOLE=",
)
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("carlos accounts create -owner: %v\n%s", err, out)
@@ -210,11 +235,12 @@ func TestAccountsCreateTrimsTheNameBeforeMinting(t *testing.T) {
tmp := t.TempDir()
storeDir := filepath.Join(tmp, "store")
cmd := exec.Command(bin, "accounts", "create", "-name", " kass ", "-owner", "paul@keymail.dev")
- cmd.Env = append(os.Environ(),
+ cmd.Env = carlosTestEnv(t,
"CARLOS_DEPLOYMENT_DIR="+storeDir,
- "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"),
"CARLOS_DATA_DIR="+tmp,
)
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("carlos accounts create -name ' kass ': %v\n%s", err, out)
@@ -259,9 +285,13 @@ func TestAccountsCreateUsesTheLoggedInIdentity(t *testing.T) {
cmd.Env = append(os.Environ(),
"CARLOS_CREDENTIALS="+creds,
"CARLOS_DATA_DIR="+tmp,
+ "HOME="+tmp,
+ "CARLOS_CONSOLE=",
// Deliberately no CARLOS_DEPLOYMENT_DIR: the console owns the bucket
// write on this path, and a laptop has no deployment store configured.
)
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("carlos accounts create (logged in): %v\n%s", err, out)
diff --git a/auth_test.go b/auth_test.go
index ae735e5df..bb8a50c74 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
@@ -333,14 +340,6 @@ func TestAuthLoginDenied(t *testing.T) {
}
}
-func TestAuthLoginNeedsAConsole(t *testing.T) {
- useTempCredentials(t)
- var out bytes.Buffer
- if err := authLogin(&out, nil); err == nil || !strings.Contains(err.Error(), "-console") {
- t.Fatalf("login with no console = %v, want a refusal naming -console", err)
- }
-}
-
// A credentials file anyone can read is a credential anyone can steal: the
// CLI says so rather than pretending it is fine.
func TestLooseCredentialPermissionsAreReported(t *testing.T) {
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..4e835c085
--- /dev/null
+++ b/config_test.go
@@ -0,0 +1,327 @@
+package main
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// 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())
+ }
+}
+
+func TestPickHonorsConfigDefault(t *testing.T) {
+ useTempCredentials(t)
+ home := os.Getenv("HOME")
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now().UTC()
+ f, _ := loadCredentials()
+ f.put(credential{Console: "https://flagship.example", Address: "p@keymail.dev",
+ Token: "t-flag", ExpiresAt: now.Add(time.Hour)})
+ f.put(credential{Console: "https://tito.example", Address: "p@keymail.dev",
+ Token: "t-tito", ExpiresAt: now.Add(time.Hour)})
+
+ // Two credentials, no default: today's refusal — and it now also says
+ // how to stop having to choose.
+ if _, err := f.pick(""); err == nil || !strings.Contains(err.Error(), "carlos auth default") {
+ t.Fatalf("multi-credential refusal = %v, want the set-a-default hint", err)
+ }
+
+ // A global default resolves the ambiguity.
+ writeConfig(t, home, `{"console": "https://tito.example"}`)
+ c, err := f.pick("")
+ if err != nil || c.Token != "t-tito" {
+ t.Fatalf("pick with global default = %+v %v, want the tito credential", c, err)
+ }
+
+ // A project default overrides the global.
+ writeConfig(t, cwd, `{"console": "https://flagship.example"}`)
+ if c, err = f.pick(""); err != nil || c.Token != "t-flag" {
+ t.Fatalf("pick with project default = %+v %v, want the flagship credential", c, err)
+ }
+
+ // CARLOS_CONSOLE still beats any config.
+ t.Setenv("CARLOS_CONSOLE", "https://tito.example")
+ if c, err = f.pick(""); err != nil || c.Token != "t-tito" {
+ t.Fatalf("env over config = %+v %v", c, err)
+ }
+ t.Setenv("CARLOS_CONSOLE", "")
+
+ // An explicit flag still beats everything.
+ if c, err = f.pick("https://tito.example"); err != nil || c.Token != "t-tito" {
+ t.Fatalf("flag over config = %+v %v", c, err)
+ }
+}
+
+func TestConfigDefaultIsAuthoritative(t *testing.T) {
+ useTempCredentials(t)
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The titogo case: the checkout is pinned to a stack this machine holds
+ // no credential for, and the only credential is for a DIFFERENT console.
+ // Falling back to it would quietly ship the project to the wrong
+ // deployment — the exact mistake the default exists to prevent.
+ f, _ := loadCredentials()
+ f.put(credential{Console: "https://flagship.example", Address: "p@keymail.dev",
+ Token: "t-flag", ExpiresAt: time.Now().Add(time.Hour)})
+ src := writeConfig(t, cwd, `{"console": "https://tito.example"}`)
+
+ _, err = f.pick("")
+ if err == nil {
+ t.Fatal("a set default with no credential must refuse, not fall back")
+ }
+ for _, want := range []string{"https://tito.example", src} {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("authoritative refusal %q does not name %q", err, want)
+ }
+ }
+
+ // A malformed config is an error even though a usable credential exists.
+ os.WriteFile(src, []byte(`{`), 0o644)
+ if _, err := f.pick(""); err == nil {
+ t.Fatal("malformed config must be an error, not a skip to the credential")
+ }
+}
+
+// The login chain: flag → CARLOS_CONSOLE → project config → global config →
+// the flagship. Login is the one verb with a built-in landing place; there
+// may be no credential yet, and the flagship is the product answer.
+func TestLoginConsoleChain(t *testing.T) {
+ useTempCredentials(t)
+ home := os.Getenv("HOME")
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if got, err := loginConsole(""); err != nil || got != defaultConsole {
+ t.Fatalf("bare loginConsole = %q %v, want the flagship", got, err)
+ }
+ writeConfig(t, home, `{"console": "https://global.example"}`)
+ if got, err := loginConsole(""); err != nil || got != "https://global.example" {
+ t.Fatalf("global config = %q %v", got, err)
+ }
+ writeConfig(t, cwd, `{"console": "https://project.example"}`)
+ if got, err := loginConsole(""); err != nil || got != "https://project.example" {
+ t.Fatalf("project config = %q %v", got, err)
+ }
+ t.Setenv("CARLOS_CONSOLE", "https://env.example")
+ if got, err := loginConsole(""); err != nil || got != "https://env.example" {
+ t.Fatalf("env = %q %v", got, err)
+ }
+ if got, err := loginConsole("https://flag.example"); err != nil || got != "https://flag.example" {
+ t.Fatalf("flag = %q %v", got, err)
+ }
+ // A malformed config fails the login rather than silently logging in
+ // to the flagship.
+ t.Setenv("CARLOS_CONSOLE", "")
+ os.WriteFile(filepath.Join(cwd, ".carlos", "config"), []byte(`{`), 0o644)
+ if _, err := loginConsole(""); err == nil {
+ t.Fatal("malformed config must fail the login, not skip to the flagship")
+ }
+}
+
+// End to end: with a configured default and no -console at all, login goes
+// where the config points.
+func TestAuthLoginUsesConfiguredDefault(t *testing.T) {
+ useTempCredentials(t)
+ fc := newFakeConsole(t)
+ writeConfig(t, os.Getenv("HOME"), `{"console": "`+fc.ts.URL+`"}`)
+ var out bytes.Buffer
+ if err := authLogin(&out, nil); err != nil {
+ t.Fatalf("authLogin via config default: %v\n%s", err, out.String())
+ }
+ f, _ := loadCredentials()
+ if c := f.find(fc.ts.URL); c == nil || c.Token != "cli-token" {
+ t.Fatalf("credential stored = %+v, want one for the configured console", c)
+ }
+}
+
+func TestAuthDefaultVerb(t *testing.T) {
+ useTempCredentials(t)
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out bytes.Buffer
+
+ // Nothing configured: the report explains the built-in behavior rather
+ // than printing an empty string.
+ if err := authDefault(&out, nil); err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"no default", defaultConsole, "carlos auth default"} {
+ if !strings.Contains(out.String(), want) {
+ t.Fatalf("bare report missing %q:\n%s", want, out.String())
+ }
+ }
+
+ // Set the global; the value is normalized on write and read back with
+ // its source.
+ out.Reset()
+ if err := authDefault(&out, []string{"https://global.example/"}); err != nil {
+ t.Fatal(err)
+ }
+ out.Reset()
+ if err := authDefault(&out, nil); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "https://global.example") ||
+ !strings.Contains(out.String(), filepath.Join(".carlos", "config")) {
+ t.Fatalf("report after set:\n%s", out.String())
+ }
+
+ // -project writes ./.carlos/config, which then overrides the global.
+ out.Reset()
+ if err := authDefault(&out, []string{"-project", "https://project.example"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(cwd, ".carlos", "config")); err != nil {
+ t.Fatalf("-project did not write ./.carlos/config: %v", err)
+ }
+ out.Reset()
+ if err := authDefault(&out, nil); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "https://project.example") {
+ t.Fatalf("project default not effective:\n%s", out.String())
+ }
+
+ // A bad URL is refused at set time, and -project with nothing to set
+ // is a usage error, not a silent no-op.
+ if err := authDefault(&out, []string{"ftp://nope"}); err == nil {
+ t.Fatal("setting an invalid console must fail")
+ }
+ if err := authDefault(&out, []string{"-project"}); err == nil {
+ t.Fatal("-project with no URL must be a usage error")
+ }
+ if err := authDefault(&out, []string{"a", "b"}); err == nil {
+ t.Fatal("two positional args must be a usage error")
+ }
+}
diff --git a/docs/superpowers/plans/2026-08-04-console-default-resolution.md b/docs/superpowers/plans/2026-08-04-console-default-resolution.md
new file mode 100644
index 000000000..edca8e3c6
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-04-console-default-resolution.md
@@ -0,0 +1,939 @@
+# Console Default Resolution Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** `carlos` resolves its target console through settable defaults — global `~/.carlos/config`, per-project `.carlos/config` — with `auth login` alone falling through to the built-in flagship, per `docs/superpowers/specs/2026-08-04-console-default-resolution-design.md`.
+
+**Architecture:** One new file (`config.go`) owns the config file format, the upward directory walk, and the global path; `auth.go`'s `pick()` and `authLogin()` gain the two config steps in their resolution chains; a new `auth default` sub-verb sets and shows the default. Everything else (`ship`, `promote`, `accounts`, apimode) already resolves through `pick()`/`loggedInCredential` and picks the behavior up for free.
+
+**Tech Stack:** Go 1.26, stdlib only, tests in `package main` alongside the code (repo convention).
+
+## Global Constraints
+
+- Build/test env (sandbox): `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; if go refuses with "read-only file system", rerun with the sandbox disabled.
+- Before each commit: `go build ./...`, `go vet ./...`, `go test ./... -count=1` all clean; `gofmt -l` clean for touched files.
+- **`noCredentialsMsg` is a wire shape.** `apimode.go` matches `pick()`'s no-credentials refusal by exact string: `"not logged in — run: carlos auth login -console <url>"`. That wording must stay byte-identical in `pick()`.
+- The multi-credential refusal and the HEADLESS warning in `authUsage` are kept (the latter verbatim).
+- Built-in login fallback is exactly `https://platform.carlosframework.com`.
+- Config file is JSON `{"console": "https://…"}`; missing file or missing/empty `console` field = "no default at this level" (continue resolution); malformed JSON or a URL `normalizeConsole` rejects = error naming the file (never a silent skip).
+- A resolved config default is authoritative: never fall back past it to the single-credential pick.
+- Follow the repo's comment style: comments explain constraints and reasoning, not what the next line does.
+
+---
+
+### Task 1: config.go — file format, walk, global path
+
+**Files:**
+- Create: `config.go`
+- Create: `config_test.go`
+- Modify: `auth_test.go:16-23` (`useTempCredentials` — isolate HOME/cwd/env too)
+
+**Interfaces:**
+- Consumes: `normalizeConsole(string) (string, error)` from `auth.go`.
+- Produces (later tasks rely on these exact signatures):
+ - `const defaultConsole = "https://platform.carlosframework.com"`
+ - `func loadConfigFile(path string) (string, error)` — normalized console or `""`; error only for malformed/invalid.
+ - `func configDefault() (console, source string, err error)` — the effective configured default and the file it came from; `("", "", nil)` when none.
+ - `func globalConfigPath() (string, error)` — `~/.carlos/config`.
+ - `func saveConfigFile(path, console string, dirPerm os.FileMode) error` — validate, mkdir, write via temp+rename, 0644.
+
+- [ ] **Step 1: Harden the test helper.** Config resolution reads `$HOME`, cwd, and `CARLOS_CONSOLE`, so every test must point all of them away from the developer's real machine — a stray `~/.carlos/config` on the box running the tests must never change a test's answer. Replace `useTempCredentials` in `auth_test.go`:
+
+```go
+// 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
+}
+```
+
+- [ ] **Step 2: Write the failing tests** in a new `config_test.go`:
+
+```go
+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())
+ }
+}
+```
+
+- [ ] **Step 3: Run the tests to verify they fail.**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestLoadConfigFile|TestConfigDefaultWalk|TestSaveConfigFile' -count=1`
+Expected: compile FAILURE — `loadConfigFile`, `configDefault`, `saveConfigFile` undefined.
+
+- [ ] **Step 4: Implement `config.go`:**
+
+```go
+// 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)
+}
+```
+
+- [ ] **Step 5: Run the tests to verify they pass.**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestLoadConfigFile|TestConfigDefaultWalk|TestSaveConfigFile' -count=1 -v`
+Expected: PASS (3 tests).
+
+- [ ] **Step 6: Full check and commit.**
+
+Run: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l config.go config_test.go auth_test.go` (empty gofmt output)
+
+```bash
+git add config.go config_test.go auth_test.go
+git commit -m "auth: .carlos/config default-console file and lookup walk"
+```
+
+---
+
+### Task 2: pick() honors the configured default, authoritatively
+
+**Files:**
+- Modify: `auth.go:239-281` (`pick`)
+- Modify: `config_test.go` (add tests)
+
+**Interfaces:**
+- Consumes: `configDefault()` from Task 1.
+- Produces: `pick`'s signature is unchanged (`func (f *credentialFile) pick(console string) (*credential, error)`); every existing caller (`loggedInCredential`, and through it apimode/accounts/promoteActor) picks the new chain up with no change.
+
+**Hard constraint:** the no-credentials message must remain byte-identical — `"not logged in — run: carlos auth login -console <url>"` — because `apimode.go`'s `noCredentialsMsg` matches it exactly. It is only reachable when no default is configured, so it needs no new wording.
+
+- [ ] **Step 1: Write the failing tests** (append to `config_test.go`):
+
+```go
+func TestPickHonorsConfigDefault(t *testing.T) {
+ useTempCredentials(t)
+ home := os.Getenv("HOME")
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now().UTC()
+ f, _ := loadCredentials()
+ f.put(credential{Console: "https://flagship.example", Address: "p@keymail.dev",
+ Token: "t-flag", ExpiresAt: now.Add(time.Hour)})
+ f.put(credential{Console: "https://tito.example", Address: "p@keymail.dev",
+ Token: "t-tito", ExpiresAt: now.Add(time.Hour)})
+
+ // Two credentials, no default: today's refusal — and it now also says
+ // how to stop having to choose.
+ if _, err := f.pick(""); err == nil || !strings.Contains(err.Error(), "carlos auth default") {
+ t.Fatalf("multi-credential refusal = %v, want the set-a-default hint", err)
+ }
+
+ // A global default resolves the ambiguity.
+ writeConfig(t, home, `{"console": "https://tito.example"}`)
+ c, err := f.pick("")
+ if err != nil || c.Token != "t-tito" {
+ t.Fatalf("pick with global default = %+v %v, want the tito credential", c, err)
+ }
+
+ // A project default overrides the global.
+ writeConfig(t, cwd, `{"console": "https://flagship.example"}`)
+ if c, err = f.pick(""); err != nil || c.Token != "t-flag" {
+ t.Fatalf("pick with project default = %+v %v, want the flagship credential", c, err)
+ }
+
+ // CARLOS_CONSOLE still beats any config.
+ t.Setenv("CARLOS_CONSOLE", "https://tito.example")
+ if c, err = f.pick(""); err != nil || c.Token != "t-tito" {
+ t.Fatalf("env over config = %+v %v", c, err)
+ }
+ t.Setenv("CARLOS_CONSOLE", "")
+
+ // An explicit flag still beats everything.
+ if c, err = f.pick("https://tito.example"); err != nil || c.Token != "t-tito" {
+ t.Fatalf("flag over config = %+v %v", c, err)
+ }
+}
+
+func TestConfigDefaultIsAuthoritative(t *testing.T) {
+ useTempCredentials(t)
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The titogo case: the checkout is pinned to a stack this machine holds
+ // no credential for, and the only credential is for a DIFFERENT console.
+ // Falling back to it would quietly ship the project to the wrong
+ // deployment — the exact mistake the default exists to prevent.
+ f, _ := loadCredentials()
+ f.put(credential{Console: "https://flagship.example", Address: "p@keymail.dev",
+ Token: "t-flag", ExpiresAt: time.Now().Add(time.Hour)})
+ src := writeConfig(t, cwd, `{"console": "https://tito.example"}`)
+
+ _, err = f.pick("")
+ if err == nil {
+ t.Fatal("a set default with no credential must refuse, not fall back")
+ }
+ for _, want := range []string{"https://tito.example", src} {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("authoritative refusal %q does not name %q", err, want)
+ }
+ }
+
+ // A malformed config is an error even though a usable credential exists.
+ os.WriteFile(src, []byte(`{`), 0o644)
+ if _, err := f.pick(""); err == nil {
+ t.Fatal("malformed config must be an error, not a skip to the credential")
+ }
+}
+```
+
+Also add `"time"` to `config_test.go`'s imports.
+
+- [ ] **Step 2: Run the tests to verify they fail.**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestPickHonorsConfigDefault|TestConfigDefaultIsAuthoritative' -count=1`
+Expected: FAIL — pick ignores config (wrong credential / missing hint / no refusal).
+
+- [ ] **Step 3: Replace `pick` in `auth.go`** (the whole function, currently auth.go:239-281):
+
+```go
+// pick resolves which credential a verb should use: an explicit -console,
+// else CARLOS_CONSOLE, else the configured default (.carlos/config walking
+// up from cwd, then ~/.carlos/config — config.go), else the only live
+// credential. Several with no default is a refusal rather than a guess —
+// shipping to the wrong deployment because the CLI picked one for you is
+// not a mistake worth being convenient about.
+//
+// Any explicitly named console — flag, env, or config — is authoritative:
+// it resolves against credentials for THAT console or errors. It never
+// falls back to "the only credential", because inside a checkout pinned to
+// one stack, quietly using a credential for a different stack is the wrong
+// deployment with extra steps.
+func (f *credentialFile) pick(console string) (*credential, error) {
+ if console == "" {
+ console = os.Getenv("CARLOS_CONSOLE")
+ }
+ from := ""
+ if console == "" {
+ var src string
+ var err error
+ console, src, err = configDefault()
+ if err != nil {
+ return nil, err
+ }
+ if console != "" {
+ // Only a config-sourced console gets the clause: whoever typed
+ // -console or exported CARLOS_CONSOLE knows where it came from,
+ // but a default set weeks ago in a file needs naming.
+ from = fmt.Sprintf(" (the default from %s)", src)
+ }
+ }
+ if console != "" {
+ want, err := normalizeConsole(console)
+ if err != nil {
+ return nil, err
+ }
+ c := f.find(want)
+ if c == nil {
+ return nil, fmt.Errorf("not logged in to %s%s — run: carlos auth login -console %s", want, from, want)
+ }
+ if c.Expired(time.Now()) {
+ return nil, fmt.Errorf("the credential for %s%s expired %s — run: carlos auth login -console %s",
+ want, from, c.ExpiresAt.Format(time.RFC3339), want)
+ }
+ return c, nil
+ }
+ var live []*credential
+ for i := range f.Credentials {
+ if !f.Credentials[i].Expired(time.Now()) {
+ live = append(live, &f.Credentials[i])
+ }
+ }
+ switch len(live) {
+ case 1:
+ return live[0], nil
+ case 0:
+ // EXACT wording — apimode.go's noCredentialsMsg matches this string.
+ return nil, errors.New("not logged in — run: carlos auth login -console <url>")
+ default:
+ var names []string
+ for _, c := range live {
+ names = append(names, c.Console)
+ }
+ return nil, fmt.Errorf("logged in to several consoles — say which with -console, "+
+ "or set a default with `carlos auth default <url>`: %s",
+ strings.Join(names, ", "))
+ }
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass — including the pre-existing ones.**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -count=1`
+Expected: PASS. `TestCredentialSelection` (asserts the multi refusal contains `-console`) and the apimode tests (exact `noCredentialsMsg`) must still pass untouched — if either fails, the wording drifted; fix the wording, not the test.
+
+- [ ] **Step 5: Full check and commit.**
+
+Run: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l auth.go config_test.go`
+
+```bash
+git add auth.go config_test.go
+git commit -m "auth: pick() honors the configured default console, authoritatively"
+```
+
+---
+
+### Task 3: auth login resolves through the chain to the flagship
+
+**Files:**
+- Modify: `auth.go:536-548` (`authLogin` — flag definition and the "which console?" refusal)
+- Modify: `auth_test.go:336-342` (`TestAuthLoginNeedsAConsole` — replaced)
+- Modify: `config_test.go` (add login tests)
+
+**Interfaces:**
+- Consumes: `configDefault()`, `defaultConsole` from Task 1.
+- Produces: `func loginConsole(flagValue string) (string, error)` — the login-only resolution chain, factored out so the flagship fallback is testable without any network. `authLogin` calls it; nothing else does.
+
+- [ ] **Step 1: Write the failing tests.** Delete `TestAuthLoginNeedsAConsole` from `auth_test.go` (its premise — refusing when no `-console` — is the behavior being removed). Append to `config_test.go`:
+
+```go
+// The login chain: flag → CARLOS_CONSOLE → project config → global config →
+// the flagship. Login is the one verb with a built-in landing place; there
+// may be no credential yet, and the flagship is the product answer.
+func TestLoginConsoleChain(t *testing.T) {
+ useTempCredentials(t)
+ home := os.Getenv("HOME")
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if got, err := loginConsole(""); err != nil || got != defaultConsole {
+ t.Fatalf("bare loginConsole = %q %v, want the flagship", got, err)
+ }
+ writeConfig(t, home, `{"console": "https://global.example"}`)
+ if got, err := loginConsole(""); err != nil || got != "https://global.example" {
+ t.Fatalf("global config = %q %v", got, err)
+ }
+ writeConfig(t, cwd, `{"console": "https://project.example"}`)
+ if got, err := loginConsole(""); err != nil || got != "https://project.example" {
+ t.Fatalf("project config = %q %v", got, err)
+ }
+ t.Setenv("CARLOS_CONSOLE", "https://env.example")
+ if got, err := loginConsole(""); err != nil || got != "https://env.example" {
+ t.Fatalf("env = %q %v", got, err)
+ }
+ if got, err := loginConsole("https://flag.example"); err != nil || got != "https://flag.example" {
+ t.Fatalf("flag = %q %v", got, err)
+ }
+ // A malformed config fails the login rather than silently logging in
+ // to the flagship.
+ t.Setenv("CARLOS_CONSOLE", "")
+ os.WriteFile(filepath.Join(cwd, ".carlos", "config"), []byte(`{`), 0o644)
+ if _, err := loginConsole(""); err == nil {
+ t.Fatal("malformed config must fail the login, not skip to the flagship")
+ }
+}
+
+// End to end: with a configured default and no -console at all, login goes
+// where the config points.
+func TestAuthLoginUsesConfiguredDefault(t *testing.T) {
+ useTempCredentials(t)
+ fc := newFakeConsole(t)
+ writeConfig(t, os.Getenv("HOME"), `{"console": "`+fc.ts.URL+`"}`)
+ var out bytes.Buffer
+ if err := authLogin(&out, nil); err != nil {
+ t.Fatalf("authLogin via config default: %v\n%s", err, out.String())
+ }
+ f, _ := loadCredentials()
+ if c := f.find(fc.ts.URL); c == nil || c.Token != "cli-token" {
+ t.Fatalf("credential stored = %+v, want one for the configured console", c)
+ }
+}
+```
+
+Add `"bytes"` to `config_test.go`'s imports. (`newFakeConsole` serves plain `http://127.0.0.1:…`, which `normalizeConsole` allows as loopback — that is why the end-to-end test works unchanged.)
+
+- [ ] **Step 2: Run the tests to verify they fail.**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestLoginConsoleChain|TestAuthLoginUsesConfiguredDefault' -count=1`
+Expected: compile FAILURE — `loginConsole` undefined.
+
+- [ ] **Step 3: Implement.** In `auth.go`, add `loginConsole` above `authLogin`, and rewrite `authLogin`'s opening. The flag currently reads `fs.String("console", os.Getenv("CARLOS_CONSOLE"), …)` — the env moves into the chain so each source is checked in order and only once.
+
+```go
+// loginConsole is login's resolution chain: flag → CARLOS_CONSOLE → the
+// configured default → the flagship. Login alone gets the built-in landing
+// place: there may be no credential yet to pick from, and the flagship is
+// the product answer. Every other verb resolves through pick(), which
+// assumes nothing.
+func loginConsole(flagValue string) (string, error) {
+ base := flagValue
+ if base == "" {
+ base = os.Getenv("CARLOS_CONSOLE")
+ }
+ if base == "" {
+ var err error
+ if base, _, err = configDefault(); err != nil {
+ return "", err
+ }
+ }
+ if base == "" {
+ base = defaultConsole
+ }
+ return normalizeConsole(base)
+}
+```
+
+Then in `authLogin`, replace this (current auth.go:539 and 544-551):
+
+```go
+ console := fs.String("console", os.Getenv("CARLOS_CONSOLE"), "the console to log in to (https://…)")
+```
+```go
+ if *console == "" {
+ return errors.New("which console? pass -console https://platform.carlosframework.com " +
+ "(or set CARLOS_CONSOLE). On a box with no browser, don't log in at all — " +
+ "pass -owner <address> to `carlos accounts create` instead")
+ }
+ base, err := normalizeConsole(*console)
+ if err != nil {
+ return err
+ }
+```
+
+with:
+
+```go
+ console := fs.String("console", "", "the console to log in to (default: the configured default, else "+defaultConsole+")")
+```
+```go
+ base, err := loginConsole(*console)
+ if err != nil {
+ return err
+ }
+```
+
+(`normalizeConsole` runs inside `loginConsole`; do not call it twice.)
+
+- [ ] **Step 4: Run the tests to verify they pass.**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -count=1`
+Expected: PASS, whole package — the existing login/denied/permission tests must be untouched by the rewrite.
+
+- [ ] **Step 5: Full check and commit.**
+
+Run: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l auth.go auth_test.go config_test.go`
+
+```bash
+git add auth.go auth_test.go config_test.go
+git commit -m "auth: login resolves through config defaults to the flagship"
+```
+
+---
+
+### Task 4: the `carlos auth default` sub-verb
+
+**Files:**
+- Modify: `auth.go:51-94` (`authUsage`, `RunAuth`)
+- Modify: `auth.go` (add `authDefault` after `authLogout`)
+- Modify: `config_test.go` (add verb test)
+
+**Interfaces:**
+- Consumes: `configDefault()`, `globalConfigPath()`, `saveConfigFile()`, `defaultConsole` (Task 1); `normalizeConsole` (existing).
+- Produces: `func authDefault(out io.Writer, args []string) error`, dispatched from `RunAuth` as `case "default"`.
+
+- [ ] **Step 1: Write the failing test** (append to `config_test.go`):
+
+```go
+func TestAuthDefaultVerb(t *testing.T) {
+ useTempCredentials(t)
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out bytes.Buffer
+
+ // Nothing configured: the report explains the built-in behavior rather
+ // than printing an empty string.
+ if err := authDefault(&out, nil); err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"no default", defaultConsole, "carlos auth default"} {
+ if !strings.Contains(out.String(), want) {
+ t.Fatalf("bare report missing %q:\n%s", want, out.String())
+ }
+ }
+
+ // Set the global; the value is normalized on write and read back with
+ // its source.
+ out.Reset()
+ if err := authDefault(&out, []string{"https://global.example/"}); err != nil {
+ t.Fatal(err)
+ }
+ out.Reset()
+ if err := authDefault(&out, nil); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "https://global.example") ||
+ !strings.Contains(out.String(), filepath.Join(".carlos", "config")) {
+ t.Fatalf("report after set:\n%s", out.String())
+ }
+
+ // -project writes ./.carlos/config, which then overrides the global.
+ out.Reset()
+ if err := authDefault(&out, []string{"-project", "https://project.example"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(cwd, ".carlos", "config")); err != nil {
+ t.Fatalf("-project did not write ./.carlos/config: %v", err)
+ }
+ out.Reset()
+ if err := authDefault(&out, nil); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "https://project.example") {
+ t.Fatalf("project default not effective:\n%s", out.String())
+ }
+
+ // A bad URL is refused at set time, and -project with nothing to set
+ // is a usage error, not a silent no-op.
+ if err := authDefault(&out, []string{"ftp://nope"}); err == nil {
+ t.Fatal("setting an invalid console must fail")
+ }
+ if err := authDefault(&out, []string{"-project"}); err == nil {
+ t.Fatal("-project with no URL must be a usage error")
+ }
+ if err := authDefault(&out, []string{"a", "b"}); err == nil {
+ t.Fatal("two positional args must be a usage error")
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails.**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run TestAuthDefaultVerb -count=1`
+Expected: compile FAILURE — `authDefault` undefined.
+
+- [ ] **Step 3: Implement.** Add after `authLogout` in `auth.go`:
+
+```go
+// authDefault shows or sets the default console. Bare: report the effective
+// configured default and which file said so (flag and env are not consulted
+// — this verb reports CONFIG, and someone with CARLOS_CONSOLE exported can
+// see their own shell). With a URL: write ~/.carlos/config, or
+// ./.carlos/config under -project — relative to the working directory,
+// deliberately not "the repo root": the CLI has no VCS awareness, and
+// "run it where you want the file" beats guessing.
+func authDefault(out io.Writer, args []string) error {
+ fs := flag.NewFlagSet("auth default", flag.ContinueOnError)
+ fs.SetOutput(out)
+ project := fs.Bool("project", false, "write ./.carlos/config (this directory) instead of ~/.carlos/config")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ switch fs.NArg() {
+ case 0:
+ if *project {
+ return errors.New("-project sets a default: carlos auth default -project <url>")
+ }
+ console, src, err := configDefault()
+ if err != nil {
+ return err
+ }
+ if console == "" {
+ fmt.Fprintf(out, "no default console configured.\n")
+ fmt.Fprintf(out, "`carlos auth login` defaults to %s; other commands use the\n", defaultConsole)
+ fmt.Fprintf(out, "only stored credential, or ask when there are several.\n")
+ fmt.Fprintf(out, "Set one with: carlos auth default <url> (-project pins this directory)\n")
+ return nil
+ }
+ fmt.Fprintf(out, "%s (from %s)\n", console, src)
+ return nil
+ case 1:
+ path, err := globalConfigPath()
+ perm := os.FileMode(0o700) // ~/.carlos also holds credentials
+ if *project {
+ path, err = filepath.Join(".carlos", "config"), nil
+ perm = 0o755 // meant to be committed; nothing secret inside
+ }
+ if err != nil {
+ return err
+ }
+ if err := saveConfigFile(path, fs.Arg(0), perm); err != nil {
+ return err
+ }
+ // Echo the NORMALIZED value: what was saved, not what was typed.
+ console, err := normalizeConsole(fs.Arg(0))
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "default console: %s (written to %s)\n", console, path)
+ return nil
+ default:
+ return errors.New("usage: carlos auth default [-project] [<url>]")
+ }
+}
+```
+
+- [ ] **Step 4: Wire the dispatch and the usage text.** In `RunAuth` (auth.go:79-89) add a case alongside the others:
+
+```go
+ case "default":
+ err = authDefault(os.Stdout, args[1:])
+```
+
+Replace `authUsage` (auth.go:51-70) with — note the HEADLESS paragraph is verbatim from today:
+
+```go
+const authUsage = `usage: carlos auth <login|whoami|logout|default>
+
+ login [-console <url>] log this terminal in to a deployment's console
+ (default: the configured default console, else
+ https://platform.carlosframework.com). Prints a
+ code; you approve it in a browser that is already
+ signed in to that console. No browser on this
+ machine is needed — the code travels.
+ [-label <name>] what to call this terminal on the console's
+ command-line access page (default: user@host)
+ whoami [-console <url>] ask the console who this terminal is
+ logout [-console <url>] revoke this terminal's token and forget it
+ default [-project] [<url>]
+ show the default console and where it is set, or
+ set it: writes ~/.carlos/config — or, with
+ -project, ./.carlos/config, which overrides the
+ global and is meant to be committed so a checkout
+ pins its own deployment
+
+The token lands in ~/.carlos/credentials (mode 0600), one entry per console
+origin — a laptop can hold credentials for the flagship and a self-hosted
+deployment at once. -console may be omitted when there is exactly one, or
+when a default is set.
+
+HEADLESS AND BOX-SIDE USE: do not log in. A box has no browser and no human,
+and a long-lived token on it is a standing credential nobody is watching.
+Pass -owner <address> to "carlos accounts create" instead — that is the
+documented path, and it needs no identity of its own.
+`
+```
+
+- [ ] **Step 5: Run the tests to verify they pass.**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -count=1`
+Expected: PASS, whole package.
+
+- [ ] **Step 6: Full check and commit.**
+
+Run: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l auth.go config_test.go`
+
+```bash
+git add auth.go config_test.go
+git commit -m "auth: carlos auth default shows and sets the default console"
+```
+
+---
+
+### Task 5: whole-tree verification
+
+**Files:** none created — this is the gate the repo requires before the branch is done.
+
+- [ ] **Step 1: Run everything, all 14 packages:**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./...
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./...
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1
+gofmt -l config.go config_test.go auth.go auth_test.go
+```
+
+Expected: all clean; gofmt prints nothing. Pay attention to `apimode_test.go` and `internal/release/laddercheck_test.go` — both pin exact strings/channels and must pass unmodified.
+
+- [ ] **Step 2: Confirm no test file was weakened.** `git diff main -- auth_test.go` must show only: the `useTempCredentials` hardening (Task 1) and the deletion of `TestAuthLoginNeedsAConsole` (Task 3, behavior removed by design). Any other loosened assertion is a defect.
+
+- [ ] **Step 3: Commit anything outstanding** (there should be nothing) and report the branch ready for review.
diff --git a/docs/superpowers/specs/2026-08-04-console-default-resolution-design.md b/docs/superpowers/specs/2026-08-04-console-default-resolution-design.md
new file mode 100644
index 000000000..05b164552
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-04-console-default-resolution-design.md
@@ -0,0 +1,162 @@
+# Console default resolution for the carlos CLI
+
+**Date:** 2026-08-04
+**Status:** Approved (Paul, 2026-08-04 session)
+
+## Problem
+
+`carlos auth login` with no `-console` and no `CARLOS_CONSOLE` refuses and
+makes you retype `https://platform.carlosframework.com` — a URL the error
+message itself already contains. Meanwhile every other verb resolves its
+console through `pick()` (auth.go): explicit flag → env → the only live
+credential → a refusal when there are several. There is no way to say "in
+this checkout, the console is the Tito stack" or "on this machine, the
+default is the flagship" — someone permanently multi-homed (flagship + a
+self-hosted deployment) types `-console` forever.
+
+## Rules (what the user asked for, verbatim in spirit)
+
+1. **One credential → never specify.** Unchanged from today.
+2. **Several credentials and no default set → always specify.** Unchanged
+ refusal; the message additionally suggests setting a default.
+3. **A default is settable at two levels**: global (`~/.carlos/config`) for
+ everything, and per-project (`.carlos/config` at the project root,
+ committed to the repo) which overrides the global. The titogo checkout
+ defaults to the Tito stack; everywhere else defaults to the flagship.
+
+## Resolution order
+
+For **every** verb that talks to a console (`ship`, `promote`, `accounts`,
+`auth whoami`, `auth logout`, …):
+
+ -console flag
+ → CARLOS_CONSOLE env
+ → nearest .carlos/config walking up from cwd
+ → ~/.carlos/config
+ → the only live credential
+ → refuse: "logged in to several consoles — say which with -console,
+ or set a default with: carlos auth default <url>"
+
+`carlos auth login` uses the same chain but, having no credential to fall
+back on, ends differently:
+
+ … → ~/.carlos/config
+ → built-in https://platform.carlosframework.com
+
+The HEADLESS warning in the usage text stays: a default makes it easier to
+accidentally log in on a box, so the "don't log in on boxes — use -owner"
+note earns its keep more, not less.
+
+### A set default is authoritative — no fallback past it
+
+If a config default resolves, the single-credential auto-pick is **not**
+consulted. Holding only a flagship credential inside the titogo checkout
+(whose `.carlos/config` says the Tito stack), `carlos ship` errors
+"not logged in to <tito console> — run: carlos auth login …" rather than
+quietly shipping titogo to the flagship. A default is explicit intent; the
+single-credential rule exists only for the no-default world.
+
+The same holds for `CARLOS_CONSOLE` and `-console` today, so the invariant
+is uniform: **any explicitly named console resolves against credentials for
+that console or errors — it never silently substitutes another.**
+
+## The config file
+
+One layout at both levels: a `.carlos` **directory** containing a `config`
+file. `~/.carlos` already exists (it holds `credentials`); projects get the
+same shape. One lookup rule, no file-vs-directory branching, and room for a
+future gitignored `.carlos/local` without a migration.
+
+`config` is JSON:
+
+ {
+ "console": "https://carlos-tito-ie.example"
+ }
+
+- Unknown fields are ignored (additive evolution, per the wire-shape rule —
+ though this file is new, so nothing existing parses it).
+- A missing file, or a file without `"console"`, means "no default at this
+ level" and resolution continues down the chain.
+- A **malformed** file (unparseable JSON) is an error, not a skip: silently
+ ignoring a typo'd project config would ship to the wrong console — the
+ exact mistake this feature exists to prevent.
+- The stored value is normalized on read the same way `-console` is
+ (`normalizeConsole`: https, or http only for loopback; scheme+host only).
+ A value that fails normalization is an error, same as malformed JSON.
+
+### Project lookup walk
+
+From cwd upward to the filesystem root, the first directory containing
+`.carlos/config` wins. If the walk passes through `$HOME`, it finds
+`~/.carlos/config` by the same rule — which is simply the global default
+arriving one step early; behavior is identical. If the walk finds nothing,
+`~/.carlos/config` is consulted explicitly (projects outside `$HOME`).
+
+## Setting and inspecting: `carlos auth default`
+
+ carlos auth default print the effective default and
+ where it came from (flag/env are
+ not shown — this reports config)
+ carlos auth default <url> write ~/.carlos/config
+ carlos auth default -project <url> write ./.carlos/config (cwd)
+
+- URLs are normalized and validated on **write** (same `normalizeConsole`
+ rule), so a bad default is caught when set, not at first use.
+- `-project` writes to `./.carlos/config` relative to cwd — deliberately not
+ "the repo root", because the CLI has no VCS awareness and guessing a root
+ is worse than the documented rule "run it where you want the file".
+- Writes create the `.carlos` directory (0755 for a project, `~/.carlos`
+ stays 0700) and rewrite `config` wholesale via the same
+ temp-file-then-rename pattern as `credentials.save()`. `config` holds no
+ secret; 0644 is fine.
+- Removing a default: delete the file, or edit it — it is one line of JSON.
+ No `-clear` flag until someone actually wants one (YAGNI).
+- With no config anywhere, `carlos auth default` prints that the built-in
+ login default is the flagship and that other verbs use the
+ single-credential rule.
+
+## Security notes (accepted, recorded)
+
+- **A committed `.carlos/config` is repo-controlled input.** A malicious PR
+ could repoint a project at a *different console the operator is logged in
+ to*. Per-origin credentials contain the blast radius — no token is ever
+ sent to a console other than the one it was issued by — and every verb
+ prints the console it is talking to. No extra ceremony (no trust prompt,
+ no allowlist): the mitigation is per-origin credentials plus visibility.
+- **`config` is not a secret** and lives world-readable in repos by design.
+ Nothing from `credentials` moves into it, ever.
+- The device-code phishing stance (bare URI + typed code, never the
+ prefilled link) is untouched.
+
+## What changes in code
+
+- `auth.go pick()` grows the two config steps between env and
+ single-credential, and the authoritative-default rule.
+- `auth.go authLogin()` replaces its "which console?" error with the chain
+ plus the built-in flagship fallback.
+- New: config load/save (one small file, likely `config.go`), the walk, and
+ the `default` sub-verb in `RunAuth` + `authUsage`.
+- `credentials` file: **untouched**. No wire-shape change anywhere; the
+ console API is untouched.
+
+Consumer check (the "everything that PARSES the shape" rule): the config
+file is new — its only parser is the CLI itself. No other component reads
+`~/.carlos` except the CLI's own credentials code, which does not change.
+
+## Testing
+
+- Resolution order: each link in the chain wins over everything below it,
+ for `login` and for `pick()`-based verbs (table test with temp dirs and
+ `CARLOS_CREDENTIALS`/`HOME`/cwd control).
+- Authoritative default: project default set + only credential is for a
+ different console → error naming the default console, not a silent pick.
+- Walk: nested cwd finds an ancestor's `.carlos/config`; nearer beats
+ farther; `$HOME` reached mid-walk equals global.
+- Malformed config and non-normalizable URL → error, not skip.
+- `auth default` round-trip: write global, write `-project`, print shows
+ value and source path.
+- Login with nothing configured anywhere → flagship URL in the printed
+ output (no network in the test; assert on the start-request target via a
+ fake server or on the printed base).
+- Existing multi-credential refusal still fires when no default is set, with
+ the new set-a-default hint.
diff --git a/e2e_test.go b/e2e_test.go
index 4e992452a..7bb355fbc 100644
--- a/e2e_test.go
+++ b/e2e_test.go
@@ -63,20 +63,29 @@ func e2eGet(t *testing.T, base, host, path string) (int, string) {
return resp.StatusCode, string(b)
}
-// carlosTestEnv is os.Environ() with the operator's real login masked:
-// CARLOS_CREDENTIALS points into the test's own tempdir, where no
-// credential exists. Every subprocess that runs the carlos binary MUST
-// build its env through this. A test that inherits the real
-// ~/.carlos/credentials is one `go test ./...` on a logged-in laptop away
-// from writing to a production console — which is exactly how 40+ test
-// accounts got minted on the flagship (2026-08-04).
+// carlosTestEnv is os.Environ() with the operator's real login and console
+// state masked: CARLOS_CREDENTIALS points into the test's own tempdir where
+// no credential exists, HOME is a scratch dir (the binary reads
+// ~/.carlos/config for the default console), and CARLOS_CONSOLE is cleared.
+// Every subprocess that runs the carlos binary MUST build its env through
+// this AND set cmd.Dir to a scratch dir — the binary walks cwd ancestors
+// for .carlos/config, so a subprocess running from the real checkout reads
+// the developer's real config the moment one exists. A test that inherits
+// the real ~/.carlos is one `go test ./...` on a logged-in laptop away from
+// writing to a production console — which is exactly how 40+ test accounts
+// got minted on the flagship (2026-08-04).
//
// Duplicated in scopedkeys_e2e_test.go: this file is package main_test (an
// external test package), that one is package main, and the two cannot
// share an unexported helper across the package boundary.
func carlosTestEnv(t *testing.T, extra ...string) []string {
t.Helper()
- env := append(os.Environ(), "CARLOS_CREDENTIALS="+filepath.Join(t.TempDir(), "credentials"))
+ scratch := t.TempDir()
+ env := append(os.Environ(),
+ "CARLOS_CREDENTIALS="+filepath.Join(scratch, "credentials"),
+ "HOME="+scratch,
+ "CARLOS_CONSOLE=",
+ )
return append(env, extra...)
}
@@ -497,7 +506,11 @@ func TestStatusCLI(t *testing.T) {
if err := os.MkdirAll(dryRunDir, 0o755); err != nil {
t.Fatalf("mkdir dry-run dir: %v", err)
}
- out, err := exec.Command(carlosBin, "status", "-dry-run", dryRunDir).CombinedOutput()
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd := exec.Command(carlosBin, "status", "-dry-run", dryRunDir)
+ cmd.Env = append(os.Environ(), "HOME="+tmp, "CARLOS_CONSOLE=")
+ cmd.Dir = tmp
+ out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("carlos status -dry-run %s: %v\n%s", dryRunDir, err, out)
}
@@ -505,7 +518,10 @@ func TestStatusCLI(t *testing.T) {
t.Errorf("carlos status -dry-run output = %q, want it to contain %q", out, "dry-run: no leaks")
}
- if err := exec.Command(carlosBin, "status").Run(); err == nil {
+ cmd = exec.Command(carlosBin, "status")
+ cmd.Env = append(os.Environ(), "HOME="+tmp, "CARLOS_CONSOLE=")
+ cmd.Dir = tmp
+ if err := cmd.Run(); err == nil {
t.Error("carlos status with no -dry-run flag: want a non-zero exit, got 0")
}
}
@@ -702,11 +718,18 @@ func TestAccountsCLI(t *testing.T) {
}
storeDir := filepath.Join(tmp, "store")
regPath := filepath.Join(tmp, "registry.db")
+ // The subprocess must never see the operator's real ~/.carlos — a genuine
+ // login on the developer's machine makes ownerless `accounts create`
+ // succeed (the logged-in identity becomes the owner, by design) and
+ // flips this test's answer. carlosTestEnv masks credentials, HOME and
+ // CARLOS_CONSOLE; cmd.Dir below closes the cwd-walk path.
env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir)
run := func(args ...string) string {
t.Helper()
cmd := exec.Command(carlosBin, args...)
cmd.Env = env
+ // The binary now walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out)
@@ -744,6 +767,7 @@ func TestAccountsCLI(t *testing.T) {
// rather than print a shrug.
failed := exec.Command(carlosBin, "accounts", "create", "-registry", regPath, "-name", "kass")
failed.Env = env
+ failed.Dir = tmp
refusal, err := failed.CombinedOutput()
if err == nil {
t.Fatalf("accounts create with no -owner succeeded — that is the ownerless account bug:\n%s", refusal)
@@ -761,6 +785,7 @@ func TestAccountsCLI(t *testing.T) {
// open either.
failed = exec.Command(carlosBin, "accounts", "create", "-registry", regPath, "-name", "kass", "-owner", "paul")
failed.Env = env
+ failed.Dir = tmp
if refusal, err := failed.CombinedOutput(); err == nil {
t.Errorf("accounts create accepted the non-address owner %q:\n%s", "paul", refusal)
}
@@ -911,11 +936,16 @@ func TestAccountsMigrateTakesTheLiveObjects(t *testing.T) {
}
env := carlosTestEnv(t,
"CARLOS_DEPLOYMENT_DIR="+storeDir,
- "CARLOS_RELEASE_PUBKEY="+pubB64)
+ "CARLOS_RELEASE_PUBKEY="+pubB64,
+ "HOME="+tmp,
+ "CARLOS_CONSOLE=",
+ )
run := func(args ...string) (string, error) {
t.Helper()
cmd := exec.Command(carlosBin, args...)
cmd.Env = env
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
return string(out), err
}
@@ -1062,11 +1092,16 @@ func TestAccountsMigrateDoesNotCopyTheProductionFlag(t *testing.T) {
}
env := append(os.Environ(),
"CARLOS_DEPLOYMENT_DIR="+storeDir,
- "CARLOS_RELEASE_PUBKEY="+pubB64)
+ "CARLOS_RELEASE_PUBKEY="+pubB64,
+ "HOME="+tmp,
+ "CARLOS_CONSOLE=",
+ )
run := func(args ...string) (string, error) {
t.Helper()
cmd := exec.Command(carlosBin, args...)
cmd.Env = env
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
return string(out), err
}
@@ -1083,7 +1118,7 @@ func TestAccountsMigrateDoesNotCopyTheProductionFlag(t *testing.T) {
t.Fatal(err)
}
- out, err := run("accounts", "create", "-registry", regPath, "-name", "Kass", "-owner", "paul@keymail.dev")
+ out, err := run("accounts", "create", "-registry", regPath, "-name", "kass", "-owner", "paul@keymail.dev")
if err != nil {
t.Fatalf("accounts create: %v\n%s", err, out)
}
@@ -1261,8 +1296,12 @@ func TestAddUnitBackingRequiresDBForHibernate(t *testing.T) {
regPath := filepath.Join(tmp, "registry.db")
// No -db: refused, non-zero exit, no row written.
- out, err := exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass",
- "-backing", "unit", "-hibernate", "no-db.example.com").CombinedOutput()
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd := exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass",
+ "-backing", "unit", "-hibernate", "no-db.example.com")
+ cmd.Env = append(os.Environ(), "HOME="+tmp, "CARLOS_CONSOLE=")
+ cmd.Dir = tmp
+ out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("add -backing unit -hibernate with no -db: want non-zero exit, got 0\n%s", out)
}
@@ -1273,8 +1312,11 @@ func TestAddUnitBackingRequiresDBForHibernate(t *testing.T) {
// With -db: succeeds, and the route's DBPath is exactly what was given
// (ops.FleetDBs reads this verbatim — no default synthesized here).
dbPath := filepath.Join(tmp, "unit-state", "app.db")
- out, err = exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass",
- "-backing", "unit", "-hibernate", "-db", dbPath, "with-db.example.com").CombinedOutput()
+ cmd = exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass",
+ "-backing", "unit", "-hibernate", "-db", dbPath, "with-db.example.com")
+ cmd.Env = append(os.Environ(), "HOME="+tmp, "CARLOS_CONSOLE=")
+ cmd.Dir = tmp
+ out, err = cmd.CombinedOutput()
if err != nil {
t.Fatalf("add -backing unit -hibernate -db %s: %v\n%s", dbPath, err, out)
}
@@ -1432,11 +1474,16 @@ func TestAccountsMigrateLegacyIntoOps(t *testing.T) {
}
env := carlosTestEnv(t,
"CARLOS_DEPLOYMENT_DIR="+storeDir,
- "CARLOS_RELEASE_PUBKEY="+pubB64)
+ "CARLOS_RELEASE_PUBKEY="+pubB64,
+ "HOME="+tmp,
+ "CARLOS_CONSOLE=",
+ )
run := func(args ...string) (string, error) {
t.Helper()
cmd := exec.Command(carlosBin, args...)
cmd.Env = env
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
return string(out), err
}
@@ -1548,6 +1595,8 @@ func TestEnvCLI(t *testing.T) {
t.Helper()
cmd := exec.Command(carlosBin, args...)
cmd.Env = env
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out)
@@ -1599,6 +1648,8 @@ func TestSecretsCLI(t *testing.T) {
t.Helper()
cmd := exec.Command(carlosBin, args...)
cmd.Env = env
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out)
@@ -1677,6 +1728,8 @@ func TestEnvironmentBundlesCLI(t *testing.T) {
t.Helper()
cmd := exec.Command(carlosBin, args...)
cmd.Env = env
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out)
@@ -1687,6 +1740,8 @@ func TestEnvironmentBundlesCLI(t *testing.T) {
t.Helper()
cmd := exec.Command(carlosBin, args...)
cmd.Env = env
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("carlos %s: succeeded, want a refusal\n%s", strings.Join(args, " "), out)
@@ -1824,6 +1879,8 @@ func TestExecDeliveryCLI(t *testing.T) {
t.Helper()
cmd := exec.Command(carlosBin, args...)
cmd.Env = env
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out)
@@ -1834,6 +1891,8 @@ func TestExecDeliveryCLI(t *testing.T) {
t.Helper()
cmd := exec.Command(carlosBin, args...)
cmd.Env = env
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("carlos %s: succeeded, want a refusal\n%s", strings.Join(args, " "), out)
@@ -1930,6 +1989,8 @@ func TestEnvironmentMergeVisibleFromCLI(t *testing.T) {
t.Helper()
cmd := exec.Command(carlosBin, args...)
cmd.Env = env
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out)
}
@@ -2003,11 +2064,16 @@ func TestRouteCLI(t *testing.T) {
}
env := carlosTestEnv(t,
"CARLOS_DEPLOYMENT_DIR="+storeDir,
- "CARLOS_RELEASE_PUBKEY="+pubB64)
+ "CARLOS_RELEASE_PUBKEY="+pubB64,
+ "HOME="+tmp,
+ "CARLOS_CONSOLE=",
+ )
run := func(args ...string) (string, error) {
t.Helper()
cmd := exec.Command(carlosBin, args...)
cmd.Env = env
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
return string(out), err
}
@@ -2274,6 +2340,7 @@ type routeTestFixture struct {
bin string
storeDir string
regPath string
+ tmpDir string
store release.Store
priv ed25519.PrivateKey
pubB64 string
@@ -2303,7 +2370,7 @@ func newRouteTestFixture(t *testing.T) *routeTestFixture {
}
return &routeTestFixture{
t: t, bin: bin, storeDir: storeDir, regPath: filepath.Join(tmp, "registry.db"),
- store: release.NewDirStore(storeDir), priv: priv, pubB64: pubB64,
+ tmpDir: tmp, store: release.NewDirStore(storeDir), priv: priv, pubB64: pubB64,
}
}
@@ -2314,6 +2381,8 @@ func (f *routeTestFixture) run(env []string, args ...string) (string, error) {
f.t.Helper()
cmd := exec.Command(f.bin, args...)
cmd.Env = env
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = f.tmpDir
out, err := cmd.CombinedOutput()
return string(out), err
}
@@ -2321,7 +2390,10 @@ func (f *routeTestFixture) run(env []string, args ...string) (string, error) {
func (f *routeTestFixture) fullEnv() []string {
return carlosTestEnv(f.t,
"CARLOS_DEPLOYMENT_DIR="+f.storeDir,
- "CARLOS_RELEASE_PUBKEY="+f.pubB64)
+ "CARLOS_RELEASE_PUBKEY="+f.pubB64,
+ "HOME="+f.tmpDir,
+ "CARLOS_CONSOLE=",
+ )
}
func (f *routeTestFixture) ship(acct, app, version string) {
@@ -3163,11 +3235,16 @@ func TestAccountsMigrateLegacyKeepsNewerQualifiedPointer(t *testing.T) {
}
env := carlosTestEnv(t,
"CARLOS_DEPLOYMENT_DIR="+storeDir,
- "CARLOS_RELEASE_PUBKEY="+pubB64)
+ "CARLOS_RELEASE_PUBKEY="+pubB64,
+ "HOME="+tmp,
+ "CARLOS_CONSOLE=",
+ )
run := func(args ...string) (string, error) {
t.Helper()
cmd := exec.Command(carlosBin, args...)
cmd.Env = env
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = tmp
out, err := cmd.CombinedOutput()
return string(out), err
}
diff --git a/scopedkeys_e2e_test.go b/scopedkeys_e2e_test.go
index a6cb352ab..439d2deda 100644
--- a/scopedkeys_e2e_test.go
+++ b/scopedkeys_e2e_test.go
@@ -10,13 +10,17 @@ import (
"github.com/carlosframework/platform/internal/release"
)
-// carlosTestEnv is os.Environ() with the operator's real login masked:
-// CARLOS_CREDENTIALS points into the test's own tempdir, where no
-// credential exists. Every subprocess that runs the carlos binary MUST
-// build its env through this. A test that inherits the real
-// ~/.carlos/credentials is one `go test ./...` on a logged-in laptop away
-// from writing to a production console — which is exactly how 40+ test
-// accounts got minted on the flagship (2026-08-04).
+// carlosTestEnv is os.Environ() with the operator's real login and console
+// state masked: CARLOS_CREDENTIALS points into the test's own tempdir where
+// no credential exists, HOME is a scratch dir (the binary reads
+// ~/.carlos/config for the default console), and CARLOS_CONSOLE is cleared.
+// Every subprocess that runs the carlos binary MUST build its env through
+// this AND set cmd.Dir to a scratch dir — the binary walks cwd ancestors
+// for .carlos/config, so a subprocess running from the real checkout reads
+// the developer's real config the moment one exists. A test that inherits
+// the real ~/.carlos is one `go test ./...` on a logged-in laptop away from
+// writing to a production console — which is exactly how 40+ test accounts
+// got minted on the flagship (2026-08-04).
//
// Duplicated from e2e_test.go rather than shared: that file is package
// main_test (an external test package with no access to this package's
@@ -24,7 +28,12 @@ import (
// an unexported helper across the package boundary.
func carlosTestEnv(t *testing.T, extra ...string) []string {
t.Helper()
- env := append(os.Environ(), "CARLOS_CREDENTIALS="+filepath.Join(t.TempDir(), "credentials"))
+ scratch := t.TempDir()
+ env := append(os.Environ(),
+ "CARLOS_CREDENTIALS="+filepath.Join(scratch, "credentials"),
+ "HOME="+scratch,
+ "CARLOS_CONSOLE=",
+ )
return append(env, extra...)
}
@@ -36,6 +45,7 @@ func carlosTestEnv(t *testing.T, extra ...string) []string {
func TestScopedKeysCLI(t *testing.T) {
bin := buildCarlos(t)
bucket := t.TempDir()
+ scratch := t.TempDir()
run := func(env []string, args ...string) (string, error) {
t.Helper()
@@ -45,6 +55,8 @@ func TestScopedKeysCLI(t *testing.T) {
"CARLOS_DEPLOYMENT_DIR=" + bucket,
"CARLOS_DATA_DIR=" + t.TempDir(),
}, env...)...)
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = scratch
out, err := cmd.CombinedOutput()
return string(out), err
}