review fixes: one resolution per invocation, loud fallbacks, honest saves
Opus review findings applied: secrets set resolves the account ONCE (double resolution could seal with one account's key and write to another — undecryptable); resolution memoized on the session; the ops fallback names itself; a failed config save warns instead of killing the verb; the save prompt is skipped from $HOME (it would rewrite the global default); consoleless config accounts no longer apply to every console; explicit -registry failures surface instead of hopping to console mode; resolvedAccount fatals on a nil session; direct-mode empty==ops invariant pinned by test; interactiveTerminal/chooserIn injectable so both chooser branches are tested.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+270
−52
account_choose.go+23 −10account_choose_test.go+19 −7accounts.go+14 −1accounts_resolve_test.go+12 −0apimode.go+58 −16apimode_test.go+112 −11appconfig_cli.go+8 −2docs/superpowers/specs/2026-08-12-account-default-resolution-design.md+24 −5
diff --git a/account_choose.go b/account_choose.go| index 04a95ce68..1f4594be4 100644 |
| --- a/account_choose.go |
| +++ b/account_choose.go |
| @@ -37,18 +37,28 @@ func severalAccountsErr(accts []apiAccountEntry) error { |
| // interactiveTerminal reports whether a prompt has a human on both ends. |
| // Stdin AND stdout must be terminals: a redirected stdout means the output |
| -// is for a parser, and a redirected stdin has no one to answer. |
| -func interactiveTerminal() bool { |
| +// is for a parser, and a redirected stdin has no one to answer. A var, not |
| +// a func: a global that reads real file descriptors is untestable in both |
| +// directions (under `go test` stdin is /dev/null — a char device — and only |
| +// the piped stdout saves the check), so tests set it explicitly. |
| +var interactiveTerminal = func() bool { |
| return charDevice(os.Stdin) && charDevice(os.Stdout) |
| } |
| -// chooseAccount asks which account an omitted -account means, then whether |
| -// to remember the answer as the project's default (the caller writes the |
| -// config; this function only asks). A junk answer re-prompts once; two |
| -// misses, or EOF at the pick, fall back to the non-interactive refusal so |
| -// the caller's error path is identical either way. EOF or anything but |
| -// y/Y at the save prompt means no — the pick itself stands. |
| -func chooseAccount(in io.Reader, out io.Writer, console string, accts []apiAccountEntry) (apiAccountEntry, bool, error) { |
| +// chooserIn is where chooseAccount reads its answers — os.Stdin, swapped by |
| +// tests for the same reason interactiveTerminal is a var. |
| +var chooserIn io.Reader = os.Stdin |
| + |
| +// chooseAccount asks which account an omitted -account means, then — when |
| +// saveDest names a file — whether to remember the answer there (the caller |
| +// writes the config; this function only asks). An empty saveDest skips the |
| +// save prompt entirely: from $HOME the "project" file would be the global |
| +// config, and a y/N inside a ship must not carry that weight. A junk answer |
| +// re-prompts once; two misses, or EOF at the pick, fall back to the |
| +// non-interactive refusal so the caller's error path is identical either |
| +// way. EOF or anything but y/Y at the save prompt means no — the pick |
| +// itself stands. |
| +func chooseAccount(in io.Reader, out io.Writer, console string, accts []apiAccountEntry, saveDest string) (apiAccountEntry, bool, error) { |
| fmt.Fprintf(out, "you belong to several accounts on %s:\n", console) |
| for i, a := range accts { |
| fmt.Fprintf(out, " %d. %s (%s)\n", i+1, a.Name, a.Sqid) |
| @@ -67,7 +77,10 @@ func chooseAccount(in io.Reader, out io.Writer, console string, accts []apiAccou |
| if picked == nil { |
| return apiAccountEntry{}, false, severalAccountsErr(accts) |
| } |
| - fmt.Fprintf(out, "save %s as this project's default account? [y/N] ", picked.Name) |
| + if saveDest == "" { |
| + return *picked, false, nil |
| + } |
| + fmt.Fprintf(out, "save %s as this directory's default account (%s)? [y/N] ", picked.Name, saveDest) |
| save := sc.Scan() && strings.EqualFold(strings.TrimSpace(sc.Text()), "y") |
| return *picked, save, nil |
| } |
diff --git a/account_choose_test.go b/account_choose_test.go| index 3296a285e..4e2d10ba6 100644 |
| --- a/account_choose_test.go |
| +++ b/account_choose_test.go |
| @@ -22,14 +22,14 @@ func TestChooseAccount(t *testing.T) { |
| // A clean pick of the second entry, declining the save. |
| var out bytes.Buffer |
| - picked, save, err := chooseAccount(strings.NewReader("2\nn\n"), &out, "https://console.example", accts) |
| + picked, save, err := chooseAccount(strings.NewReader("2\nn\n"), &out, "https://console.example", accts, ".carlos/config") |
| if err != nil || picked.Sqid != "bce" || save { |
| t.Fatalf("pick 2 = %+v save=%v err=%v", picked, save, err) |
| } |
| for _, want := range []string{ |
| "you belong to several accounts on https://console.example", |
| "1. tom@keymail.dev (bdk)", "2. acme (bce)", "account [1-2]:", |
| - "save acme as this project's default account? [y/N]", |
| + "save acme as this directory's default account (.carlos/config)? [y/N]", |
| } { |
| if !strings.Contains(out.String(), want) { |
| t.Fatalf("chooser output missing %q:\n%s", want, out.String()) |
| @@ -38,28 +38,40 @@ func TestChooseAccount(t *testing.T) { |
| // Saying y to the save prompt. |
| out.Reset() |
| - picked, save, err = chooseAccount(strings.NewReader("1\ny\n"), &out, "https://console.example", accts) |
| + picked, save, err = chooseAccount(strings.NewReader("1\ny\n"), &out, "https://console.example", accts, ".carlos/config") |
| if err != nil || picked.Sqid != "bdk" || !save { |
| t.Fatalf("pick 1 + save = %+v save=%v err=%v", picked, save, err) |
| } |
| + // An empty saveDest — the caller decided saving is off the table (from |
| + // $HOME the "project" file would be the global config) — asks nothing |
| + // and never saves, even with a y queued up. |
| + out.Reset() |
| + picked, save, err = chooseAccount(strings.NewReader("1\ny\n"), &out, "https://console.example", accts, "") |
| + if err != nil || picked.Sqid != "bdk" || save { |
| + t.Fatalf("no-dest pick = %+v save=%v err=%v, want pick without a save", picked, save, err) |
| + } |
| + if strings.Contains(out.String(), "save ") { |
| + t.Fatalf("no-dest chooser must not offer the save:\n%s", out.String()) |
| + } |
| + |
| // An empty save answer means no — N is the default. |
| out.Reset() |
| - _, save, err = chooseAccount(strings.NewReader("1\n\n"), &out, "https://console.example", accts) |
| + _, save, err = chooseAccount(strings.NewReader("1\n\n"), &out, "https://console.example", accts, ".carlos/config") |
| if err != nil || save { |
| t.Fatalf("empty save answer = save=%v err=%v, want no-save", save, err) |
| } |
| // EOF at the save prompt also means no; the pick itself stands. |
| out.Reset() |
| - picked, save, err = chooseAccount(strings.NewReader("1\n"), &out, "https://console.example", accts) |
| + picked, save, err = chooseAccount(strings.NewReader("1\n"), &out, "https://console.example", accts, ".carlos/config") |
| if err != nil || picked.Sqid != "bdk" || save { |
| t.Fatalf("EOF at save = %+v save=%v err=%v", picked, save, err) |
| } |
| // Junk once re-prompts; a valid second answer lands. |
| out.Reset() |
| - picked, _, err = chooseAccount(strings.NewReader("zzz\n1\nn\n"), &out, "https://console.example", accts) |
| + picked, _, err = chooseAccount(strings.NewReader("zzz\n1\nn\n"), &out, "https://console.example", accts, ".carlos/config") |
| if err != nil || picked.Sqid != "bdk" { |
| t.Fatalf("junk-then-1 = %+v err=%v", picked, err) |
| } |
| @@ -70,7 +82,7 @@ func TestChooseAccount(t *testing.T) { |
| // Junk twice — including out-of-range numbers — is the refusal. |
| for _, input := range []string{"zzz\nzzz\n", "0\n3\n", ""} { |
| out.Reset() |
| - if _, _, err = chooseAccount(strings.NewReader(input), &out, "https://console.example", accts); err == nil || err.Error() != refusal { |
| + if _, _, err = chooseAccount(strings.NewReader(input), &out, "https://console.example", accts, ".carlos/config"); err == nil || err.Error() != refusal { |
| t.Fatalf("input %q = %v, want the several-accounts refusal", input, err) |
| } |
| } |
diff --git a/accounts.go b/accounts.go| index 135eb33df..d20bcac4f 100644 |
| --- a/accounts.go |
| +++ b/accounts.go |
| @@ -217,8 +217,21 @@ func runAccountsList(args []string) { |
| // console API and must keep working on a box that has a registry and no |
| // bucket env, no credentials — while a laptop with neither registry nor |
| // box access (the #157 case) asks its console. An explicit -console |
| - // skips the registry even where one exists. |
| + // skips the registry even where one exists; an explicit -registry is a |
| + // commitment, not a hint — a typo'd path or an EACCES must surface as |
| + // registry.Open's own error, never as a silent hop to console mode |
| + // answering "not logged in" to someone who asked about a database. |
| + explicitRegistry := false |
| + fs.Visit(func(f *flag.Flag) { |
| + if f.Name == "registry" { |
| + explicitRegistry = true |
| + } |
| + }) |
| if *consoleFlag == "" { |
| + if explicitRegistry { |
| + runAccountsListRegistry(*regPath) |
| + return |
| + } |
| if _, err := os.Stat(*regPath); err == nil { |
| runAccountsListRegistry(*regPath) |
| return |
diff --git a/accounts_resolve_test.go b/accounts_resolve_test.go| index 4464126ab..54f751cdf 100644 |
| --- a/accounts_resolve_test.go |
| +++ b/accounts_resolve_test.go |
| @@ -9,6 +9,18 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| +// The load-bearing invariant behind the account-default branch (#157): the |
| +// flag default flipped from "ops" to "" for every dual-mode verb, and |
| +// direct mode survives ONLY because resolveAccount reads "" as ops — with |
| +// no registry involved, since CI and fresh boxes have none. |
| +func TestResolveAccountEmptyMeansOps(t *testing.T) { |
| + for _, want := range []string{"", "ops", " ops ", " "} { |
| + if got := resolveAccount("/nonexistent/registry.db", want, ""); got != release.OpsAccount { |
| + t.Errorf("resolveAccount(%q) = %q, want %q", want, got, release.OpsAccount) |
| + } |
| + } |
| +} |
| + |
| func TestPickAccount(t *testing.T) { |
| accts := []registry.Account{ |
| {ID: 1, Sqid: "bab", Name: "ops"}, |
diff --git a/apimode.go b/apimode.go| index c71597cf4..2eb5a1b91 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -45,6 +45,11 @@ type apiSession struct { |
| c *consoleClient |
| console string // origin, for messages |
| address string |
| + // defAcct memoizes defaultAccount's answer: resolution happens once per |
| + // invocation however many times a verb asks (runSecretsSet asks twice — |
| + // once for the seal key, once for the write; two chooser prompts there |
| + // once sealed a value with one account's key and wrote it to another). |
| + defAcct *apiAcct |
| } |
| // resolveMode decides which mode the CLI is in. (nil, nil) means direct mode |
| @@ -262,11 +267,29 @@ func (s *apiSession) listAccounts(ctx context.Context) (accts []apiAccountEntry, |
| // guessed account that stayed invisible would be the wrong-deployment |
| // mistake this file already refuses everywhere else. |
| func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error) { |
| + if s.defAcct != nil { |
| + return *s.defAcct, nil |
| + } |
| + a, err := s.defaultAccountOnce(ctx) |
| + if err == nil { |
| + s.defAcct = &a |
| + } |
| + return a, err |
| +} |
| + |
| +func (s *apiSession) defaultAccountOnce(ctx context.Context) (apiAcct, error) { |
| name, src, fileConsole, err := configAccount() |
| if err != nil { |
| return apiAcct{}, err |
| } |
| - if name != "" && (fileConsole == "" || fileConsole == s.console) { |
| + // Honored ONLY when the file names THIS console: the pair is one fact. |
| + // A consoleless {"account": …} is deliberately not a wildcard — the |
| + // console key is nearly inert as committed input (no credential, no |
| + // request), but an account that applied to every console would let a |
| + // checked-in file silently redirect a ship between stacks the caller |
| + // really is a member of. saveConfigAccount always writes the pair, so |
| + // only a hand-written file can even be consoleless. |
| + if name != "" && fileConsole == s.console { |
| // The name travels verbatim, like a typed -account: the console |
| // resolves it against the caller's memberships, so a stale config |
| // value fails with the same membership 404 a typo would. |
| @@ -278,9 +301,12 @@ func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error) { |
| return apiAcct{}, err |
| } |
| if !supported { |
| - // A console from before the listing endpoint cannot say whose |
| - // token this is — keep the pre-discovery default so nothing |
| - // regresses against an un-upgraded console. |
| + // No memberships answer — a console from before the listing |
| + // endpoint, or any 404-shaped thing in front of one. Keep the |
| + // pre-discovery default so old consoles regress nothing, but SAY |
| + // so: a silent ops here followed by the membership 404 is exactly |
| + // the three-failures-deep riddle #157 was filed about. |
| + mutedf(stdout, "account: ops (%s did not answer with your memberships)", s.console) |
| return apiAcct{Seg: "ops"}, nil |
| } |
| switch len(accts) { |
| @@ -293,21 +319,30 @@ func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error) { |
| if !interactiveTerminal() { |
| return apiAcct{}, severalAccountsErr(accts) |
| } |
| - picked, save, err := chooseAccount(os.Stdin, stdout, s.console, accts) |
| + // ./.carlos/config in the working directory — the same "run it where |
| + // you want the file" rule as `auth default -project`. Except from |
| + // $HOME, where that path IS ~/.carlos/config: a y there would silently |
| + // repoint the machine-wide default console from inside a ship, so the |
| + // save is not offered at all. |
| + dest := filepath.Join(".carlos", "config") |
| + if abs, err := filepath.Abs(dest); err == nil { |
| + if gp, gerr := globalConfigPath(); gerr == nil && abs == gp { |
| + dest = "" |
| + } |
| + } |
| + picked, save, err := chooseAccount(chooserIn, stdout, s.console, accts, dest) |
| if err != nil { |
| return apiAcct{}, err |
| } |
| - if save { |
| - // ./.carlos/config in the working directory — the same "run it |
| - // where you want the file" rule as `auth default -project`. The |
| - // console is pinned alongside: configAccount reads the pair as one |
| - // fact, and an account without its console would silently stop |
| - // applying the day the directory's console default changed. |
| - path := filepath.Join(".carlos", "config") |
| - if err := saveConfigAccount(path, s.console, picked.Name, 0o755); err != nil { |
| - return apiAcct{}, err |
| + if save && dest != "" { |
| + // The save is bookkeeping, never a precondition: the human already |
| + // answered, the account is known, and a read-only checkout must |
| + // not turn that answer into a dead ship. |
| + if err := saveConfigAccount(dest, s.console, picked.Name, 0o755); err != nil { |
| + warnf(stdout, "not saved (%s): %v", dest, err) |
| + } else { |
| + mutedf(stdout, "saved: %s is now this directory's default account (%s)", picked.Name, dest) |
| } |
| - mutedf(stdout, "saved: %s is now this directory's default account (%s)", picked.Name, path) |
| } |
| return apiAcct{Seg: picked.Sqid, Exact: true}, nil |
| } |
| @@ -320,9 +355,16 @@ func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error) { |
| // print it and stop. |
| func resolvedAccount(sess *apiSession, raw, rawID *string) apiAcct { |
| a := apiAccount(raw, rawID) |
| - if a.Seg != "" || sess == nil { |
| + if a.Seg != "" { |
| return a |
| } |
| + if sess == nil { |
| + // Unreachable from any current verb (each guards its mode before |
| + // resolving), and kept LOUD: returning the empty segment would |
| + // build /api/cli/apps//<app> and answer a client-side bug with the |
| + // membership-404 riddle. |
| + log.Fatal("resolvedAccount: no session — a direct-mode path reached API account resolution (a bug in this binary)") |
| + } |
| r, err := sess.defaultAccount(context.Background()) |
| if err != nil { |
| log.Fatal(err) |
diff --git a/apimode_test.go b/apimode_test.go| index fb7579a7a..5b1fd4415 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -2817,14 +2817,25 @@ func TestDefaultAccountSingleMembership(t *testing.T) { |
| } |
| } |
| +// forceTerminal pins interactiveTerminal for one test — the real check |
| +// reads process file descriptors, which a test controls in neither |
| +// direction (under `go test` stdin is /dev/null, a char device). |
| +func forceTerminal(t *testing.T, tty bool) { |
| + t.Helper() |
| + old := interactiveTerminal |
| + interactiveTerminal = func() bool { return tty } |
| + t.Cleanup(func() { interactiveTerminal = old }) |
| +} |
| + |
| func TestDefaultAccountSeveralNonTTY(t *testing.T) { |
| useTempCredentials(t) |
| captureStdout(t) |
| + forceTerminal(t, false) |
| s, _ := accountsConsole(t, http.StatusOK, |
| `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`) |
| - // Under go test stdin is not a terminal, so several memberships take |
| - // the non-interactive refusal — which names every account. |
| + // No terminal: several memberships take the non-interactive refusal — |
| + // which names every account. |
| _, err := s.defaultAccount(context.Background()) |
| want := "you belong to several accounts — say which with -account: tom@keymail.dev (bdk), acme (bce)" |
| if err == nil || err.Error() != want { |
| @@ -2832,6 +2843,72 @@ func TestDefaultAccountSeveralNonTTY(t *testing.T) { |
| } |
| } |
| +func TestDefaultAccountChooserPicksSavesAndMemoizes(t *testing.T) { |
| + useTempCredentials(t) |
| + out := captureStdout(t) |
| + forceTerminal(t, true) |
| + oldIn := chooserIn |
| + chooserIn = strings.NewReader("2\ny\n") |
| + t.Cleanup(func() { chooserIn = oldIn }) |
| + s, hits := accountsConsole(t, http.StatusOK, |
| + `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`) |
| + |
| + a, err := s.defaultAccount(context.Background()) |
| + if err != nil || a.Seg != "bce" || !a.Exact { |
| + t.Fatalf("chooser pick = %+v, %v; want acme's sqid, exact", a, err) |
| + } |
| + // y saved the pair to ./.carlos/config (cwd is a temp dir here). |
| + cfg, err := loadConfig(filepath.Join(".carlos", "config")) |
| + if err != nil || cfg.Account != "acme" || cfg.Console != s.console { |
| + t.Fatalf("saved config = %+v, %v; want the acme/%s pair", cfg, err, s.console) |
| + } |
| + if !strings.Contains(out.String(), "saved: acme") { |
| + t.Fatalf("the save must be confirmed:\n%s", out.String()) |
| + } |
| + |
| + // Asking again is memoized: same answer, no second fetch, no second |
| + // chooser (the input reader is already drained — a re-prompt would |
| + // EOF into the refusal). |
| + a2, err := s.defaultAccount(context.Background()) |
| + if err != nil || a2 != a { |
| + t.Fatalf("second resolution = %+v, %v; want the memoized first", a2, err) |
| + } |
| + if *hits != 1 { |
| + t.Fatalf("endpoint hits = %d, want 1 — resolution happens once per invocation", *hits) |
| + } |
| +} |
| + |
| +func TestDefaultAccountChooserSaveFailureIsNotFatal(t *testing.T) { |
| + useTempCredentials(t) |
| + out := captureStdout(t) |
| + forceTerminal(t, true) |
| + oldIn := chooserIn |
| + chooserIn = strings.NewReader("1\ny\n") |
| + t.Cleanup(func() { chooserIn = oldIn }) |
| + s, _ := accountsConsole(t, http.StatusOK, |
| + `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`) |
| + |
| + // A read-only working directory: the config walk still reads fine |
| + // (nothing exists to read), but MkdirAll(".carlos") cannot create. |
| + cwd, err := os.Getwd() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chmod(cwd, 0o555); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.Chmod(cwd, 0o755) }) |
| + // The human answered; the account is known; the save is bookkeeping. |
| + // A read-only checkout must not turn the answer into a dead ship. |
| + a, err := s.defaultAccount(context.Background()) |
| + if err != nil || a.Seg != "bdk" { |
| + t.Fatalf("pick with failing save = %+v, %v; want the pick to stand", a, err) |
| + } |
| + if !strings.Contains(out.String(), "not saved") { |
| + t.Fatalf("a failed save must be said out loud:\n%s", out.String()) |
| + } |
| +} |
| + |
| func TestDefaultAccountOldConsoleFallsBackToOps(t *testing.T) { |
| useTempCredentials(t) |
| captureStdout(t) |
| @@ -2843,6 +2920,21 @@ func TestDefaultAccountOldConsoleFallsBackToOps(t *testing.T) { |
| } |
| } |
| +func TestDefaultAccountOpsFallbackIsSaidOutLoud(t *testing.T) { |
| + useTempCredentials(t) |
| + out := captureStdout(t) |
| + s, _ := accountsConsole(t, http.StatusNotFound, "404 page not found\n") |
| + |
| + // The fallback must not be silent: an invisible ops followed by the |
| + // membership 404 is the three-failures-deep riddle #157 is about. |
| + if _, err := s.defaultAccount(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.Contains(out.String(), "account: ops") || !strings.Contains(out.String(), s.console) { |
| + t.Fatalf("the ops fallback must name itself and the console:\n%s", out.String()) |
| + } |
| +} |
| + |
| func TestDefaultAccountZeroMemberships(t *testing.T) { |
| useTempCredentials(t) |
| captureStdout(t) |
| @@ -2886,22 +2978,31 @@ func TestDefaultAccountConfigDefault(t *testing.T) { |
| t.Fatalf("the config-sourced account and its file must be printed; got:\n%s", out.String()) |
| } |
| - // A config account with NO console is a pure default: honored too. |
| + // A config account with NO console is ignored: the pair is one fact, |
| + // and an account key that applied to every console would let a |
| + // checked-in file redirect ships across stacks. With zero memberships |
| + // behind it, resolution reports those — proving the fall-through. |
| + // (Each check below uses a fresh session: resolution memoizes per |
| + // invocation, and these are separate invocations.) |
| writeConfig(t, ".", `{"account": "acme"}`) |
| - if a, err = s.defaultAccount(context.Background()); err != nil || a.Seg != "acme" { |
| - t.Fatalf("consoleless config account = %+v, %v", a, err) |
| + s2 := newTestSession(s.console) |
| + if _, err = s2.defaultAccount(context.Background()); err == nil || |
| + !strings.Contains(err.Error(), "no account") { |
| + t.Fatalf("consoleless config account = %v, want fall-through to memberships", err) |
| + } |
| + if *hits != 1 { |
| + t.Fatalf("endpoint hits = %d, want exactly the fall-through call", *hits) |
| } |
| - // Paired with a DIFFERENT console it is ignored — and with zero |
| - // memberships behind it, resolution reports those, proving the walk |
| - // fell through to the endpoint. |
| + // Paired with a DIFFERENT console it is ignored the same way. |
| writeConfig(t, ".", `{"console": "https://other.example", "account": "acme"}`) |
| - if _, err = s.defaultAccount(context.Background()); err == nil || |
| + s3 := newTestSession(s.console) |
| + if _, err = s3.defaultAccount(context.Background()); err == nil || |
| !strings.Contains(err.Error(), "no account") { |
| t.Fatalf("mismatched config console = %v, want fall-through to memberships", err) |
| } |
| - if *hits != 1 { |
| - t.Fatalf("endpoint hits = %d, want exactly the fall-through call", *hits) |
| + if *hits != 2 { |
| + t.Fatalf("endpoint hits = %d, want one per fall-through", *hits) |
| } |
| } |
diff --git a/appconfig_cli.go b/appconfig_cli.go| index 566d8d735..e9b382c98 100644 |
| --- a/appconfig_cli.go |
| +++ b/appconfig_cli.go |
| @@ -608,7 +608,13 @@ func runSecretsSet(args []string) { |
| } |
| if sess != nil { |
| printUsing(os.Stdout, sess) |
| - pub := secretsPubKeyAPI(sess, resolvedAccount(sess, acctRaw, acctIDRaw), *app) |
| + // Resolved ONCE, used for both the seal key and the write: two |
| + // resolutions here could answer differently (the chooser asked |
| + // twice), sealing the value with one account's public key and |
| + // writing it into another's config — an undecryptable secret whose |
| + // only symptom is a render failure in the hourly adopt log. |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + pub := secretsPubKeyAPI(sess, acct, *app) |
| set := make(map[string]appconfig.Var, len(kv)) |
| for k, v := range kv { |
| env, err := appconfig.Seal(pub, []byte(v)) |
| @@ -617,7 +623,7 @@ func runSecretsSet(args []string) { |
| } |
| set[k] = appconfig.Var{Kind: "secret", Envelope: env} |
| } |
| - if _, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, |
| + if _, err := sess.configApply(context.Background(), acct, *app, |
| apiConfigApply{Environment: *environment, Set: set}); err != nil { |
| log.Fatal(err) |
| } |
diff --git a/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md b/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md| index b41ccdd1b..b4e6bf2c7 100644 |
| --- a/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md |
| +++ b/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md |
| @@ -62,9 +62,15 @@ session, in order: |
| exactly as today. |
| 2. **Config default:** the `account` key of the nearest `.carlos/config` |
| (same upward walk as the console default, then global). Honored **only |
| - when that file's `console` matches the session's console, or the file |
| - names no console** — a config pair points at one stack; `-console` to a |
| - different stack must not drag the other stack's account along. |
| + when that same file's `console` matches the session's console** — the |
| + pair is one fact; `-console` to a different stack must not drag the |
| + other stack's account along. A consoleless `{"account": …}` is |
| + deliberately NOT a wildcard (tightened in review, 2026-08-12): the |
| + committed `console` key is nearly inert as repo input — no credential, |
| + no request — but an account applying to every console would let a |
| + checked-in file silently redirect ships between stacks the caller |
| + really is a member of. The chooser always writes the pair, so only a |
| + hand-written file can even be consoleless. |
| 3. **Memberships** via `GET /api/cli/apps`: |
| - exactly one → use its **sqid, exact-matched** (`?exact=sqid`), and |
| print a muted `account: <name> (<sqid>)` line under the "using …" |
| @@ -76,7 +82,11 @@ session, in order: |
| - zero (unreachable in practice — sign-in mints a personal account) → |
| error naming the console. |
| 4. **Old console** (endpoint 404s) → send `ops`, byte-for-byte today's |
| - behavior. No regression against un-upgraded consoles. |
| + behavior — but SAID: a muted |
| + `account: ops (<console> did not answer with your memberships)` line |
| + prints (review, 2026-08-12: a silent ops followed by the membership 404 |
| + is the exact riddle #157 was filed about, and any 404-shaped thing in |
| + front of a console — proxy, mis-stamped route — lands here too). |
| The resolution happens once per invocation, before the first request that |
| needs an account path; verbs that never touch an account path never call it. |
| @@ -88,7 +98,7 @@ needs an account path; verbs that never touch an account path never call it. |
| 2. acme (bce) |
| account [1-2]: |
| - save acme as this project's default account? [y/N] |
| + save acme as this directory's default account (.carlos/config)? [y/N] |
| - Pick by number; anything else (including empty) re-prompts once, then |
| errors with the non-TTY message. EOF (^D) errors the same way. |
| @@ -96,6 +106,15 @@ needs an account path; verbs that never touch an account path never call it. |
| `./.carlos/config` **in the working directory** — same placement rule as |
| `auth default -project` ("run it where you want the file"). Any other |
| answer saves nothing. |
| +- **From `$HOME` the save is not offered at all** (review, 2026-08-12): |
| + there `./.carlos/config` IS `~/.carlos/config`, and a y/N buried inside |
| + a ship must not silently repoint the machine-wide default console. |
| +- **A failed save warns and continues** (review, 2026-08-12): the human |
| + already answered and the account is known — a read-only checkout must |
| + not turn bookkeeping into a dead ship. |
| +- Resolution is memoized on the session: however many times a verb asks |
| + (`secrets set` asks for the seal key and again for the write), the |
| + chooser runs at most once and both answers are the same account. |
| - The saved value is the account **name** (readable in a committed file); |
| it resolves server-side against memberships like any `-account` value. |
| - Single-account users never see either prompt. |