carlos / platform Public

e2e/docs: config through the console API, end to end (issues #43, #44)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WVANvB83eHrRANRpEL8mn
Paul Campbell 0cdd7bb141c17eb767162a03a14a72cb976f37b0 parent 5d72385
3 files changed, +334 −4
  • README.md +15 −3
  • config_e2e_test.go +317 −0
  • docs/superpowers/specs/2026-08-03-cli-console-api-design.md +2 −1
diff --git a/README.md b/README.md
index c21cef5ab..f4fdcb131 100644
--- a/README.md
+++ b/README.md
@@ -110,9 +110,9 @@ origin), expires in 90 days, and is listed with a revoke button at
carlos auth logout # revokes the token, then forgets it
**Shipping from a logged-in laptop.** Once you're logged in, `ship`,
-`promote`, `rollback` and `channels` speak through the console instead of the
-bucket directly — no `CARLOS_DEPLOYMENT_BUCKET`/`_DIR` on a developer's
-machine at all:
+`promote`, `rollback`, `channels`, and `env`/`secrets` (issue #43) speak
+through the console instead of the bucket directly — no
+`CARLOS_DEPLOYMENT_BUCKET`/`_DIR` on a developer's machine at all:
carlos ship -app blog -version $(git rev-parse --short HEAD) -target linux-arm64 blog-linux-arm64
carlos promote -app blog abc1234 canary/my-branch
@@ -191,6 +191,15 @@ sealed client-side via `carlos secrets` — the bucket never sees plaintext.
carlos secrets set -app keymail SES_SECRET=... # sealed with CARLOS_SECRETS_PUBKEY before upload
carlos secrets list -app keymail # key names only — a value is never readable again
+Like `ship`/`promote` (see "From the CLI" above), `env` and `secrets` work
+through a logged-in console with no `CARLOS_DEPLOYMENT_BUCKET`/`_DIR` on your
+machine at all (issue #43) — `carlos auth login` alone is enough, including
+for `secrets set`: with no `CARLOS_SECRETS_PUBKEY` in your own shell, it
+seals against the console's own served public key instead (set via
+`CARLOS_SECRETS_PUBKEY` in *the console's* environment; the private half
+never leaves the hosts that hold it either way). Direct-bucket mode
+(`CARLOS_DEPLOYMENT_BUCKET`/`_DIR` set) is unchanged.
+
The keypair is the mirror image of the release signing pair:
`CARLOS_SECRETS_PUBKEY` only encrypts and can be held widely (CI, laptops
— write-only credentials); `CARLOS_SECRETS_KEY` decrypts and belongs in
@@ -202,6 +211,9 @@ Hosts converge during the hourly root `carlos adopt` pass: with
`/etc/carlos/apps/<route.Host>.env` (the file `carlos-app@.service`
already reads) and restarts routes whose file changed. With it unset,
config sync is inert — the same convention as every other optional loop.
+A write also reaches exec-backed instances in seconds rather than waiting
+for that hourly pass (issue #44) via the agent's own fast poll loop; run
+`carlos env sync -app <a>` to request convergence on demand.
Operational notes:
diff --git a/config_e2e_test.go b/config_e2e_test.go
new file mode 100644
index 000000000..aa09fa8e8
--- /dev/null
+++ b/config_e2e_test.go
@@ -0,0 +1,317 @@
+// End-to-end acceptance test for issues #43/#44: `carlos env`/`carlos
+// secrets` working through the console API with `carlos auth login` alone,
+// and `carlos env sync` touching the fast-convergence generation object.
+// Spec: docs/superpowers/specs/2026-08-04-config-console-api-design.md §6.
+//
+// Follows TestE2ECLIThroughConsole's shape exactly (auth_e2e_test.go): a
+// REAL console.Server (DirStore, a real ed25519 release signing key — no
+// hand-rolled httptest handlers standing in for it), a device-flow login
+// landing a real bearer token in a temp credential file, and then — with NO
+// store env set and NO CARLOS_SECRETS_PUBKEY either (the console-served
+// pubkey path is the point of issue #43) — the real `carlos env`/`carlos
+// secrets` run* functions (appconfig_cli.go) driven in-process, their
+// effects verified against the console's own config door and, for the
+// sealed secret, the raw manifest bytes straight from the DirStore.
+//
+// appconfig_cli.go's run* functions print through fmt.Printf/os.Stdout
+// directly, not through cli.go's `stdout` package var (the seam
+// captureStdout swaps) — ship/promote/channels/ledgers are the only verbs
+// wired to that seam today. So happy-path assertions here read the
+// console's own response (or the DirStore) as ground truth, exactly what
+// the spec's §6 bullets ask for ("assert ... that PORT=8080 is visible",
+// "secrets list shows the key name", "the generation object's etag moved"),
+// with one exception (env unset's "unset N of M" line) captured via a raw
+// os.Pipe swap of the real os.Stdout, since that specific line is the one
+// piece of this flow the spec pins as printed text.
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// runCapturingRealStdout swaps the process's real os.Stdout for the length
+// of fn, synchronously: fn runs to completion (writing whatever it likes
+// into the pipe's buffer — comfortably under the OS pipe buffer's size for
+// one CLI confirmation line), THEN the write end closes and the whole pipe
+// is drained. Unlike a background-goroutine copy, there is no race between
+// "did the copy see this byte yet" and the caller's read of the result:
+// nothing reads until fn has already returned and the pipe is closed.
+//
+// This is a different seam from captureStdout (apimode_test.go), which
+// swaps cli.go's `stdout` package var — appconfig_cli.go's run* functions
+// write straight to os.Stdout instead, so that seam doesn't reach them.
+func runCapturingRealStdout(t *testing.T, fn func()) string {
+ t.Helper()
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ orig := os.Stdout
+ os.Stdout = w
+ fn()
+ os.Stdout = orig
+ w.Close()
+ out, err := io.ReadAll(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(out)
+}
+
+// TestConfigE2EThroughConsole drives the whole config-console-api feature
+// (issues #43, #44) end to end: login, `env set`, `secrets set` with no
+// CARLOS_SECRETS_PUBKEY in the terminal's own environment (the console
+// serves its own), `env unset`, `env sync`, and a membership refusal for a
+// token that belongs to nobody on the account.
+func TestConfigE2EThroughConsole(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ // The point of issue #43 is that `secrets set` needs nothing but a
+ // login — no CARLOS_SECRETS_PUBKEY in this terminal's own env either,
+ // so the fallback to the console's served key (Config.SecretsPub) is
+ // what actually gets exercised, not secretsPubKeyAPI's env-var branch.
+ t.Setenv("CARLOS_SECRETS_PUBKEY", "")
+
+ const acctFlag = "ops" // resolves to console.OpsAccountID / release.OpsAccount ("bab")
+ const app = "titogo"
+
+ tmp := t.TempDir()
+ relStore := release.NewDirStore(filepath.Join(tmp, "store"))
+
+ // A real appconfig secrets keypair — the private half stays in this
+ // test (standing in for CARLOS_SECRETS_KEY on a real box), the public
+ // half is what the console will SERVE (Config.SecretsPub), never hold
+ // the private half at all.
+ secPrivB64, secPubB64, err := appconfig.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ secPriv, err := appconfig.ParsePrivateKey(secPrivB64, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // The unrelated release-signing keypair every console needs to boot,
+ // exactly as TestE2ECLIThroughConsole sets one up — config doors don't
+ // check it (apiconfig.go's package comment: "no signing-key check on
+ // any door here"), but console.NewServer still wants a valid one.
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seed, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cst, err := console.Open(filepath.Join(tmp, "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { cst.Close() })
+
+ var srv *console.Server
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv.ServeHTTP(w, r)
+ }))
+ t.Cleanup(ts.Close)
+
+ srv, err = console.NewServer(console.Config{
+ Origin: ts.URL,
+ Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin
+ Registry: filepath.Join(tmp, "registry.db"),
+ Releases: relStore,
+ ReleasePub: pub,
+ Key: priv,
+ AppScheme: "http",
+ SecretsPub: secPubB64, // the console-served path issue #43 exists for
+ }, cst)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ const addr = "paul@keymail.dev"
+ if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := cst.CreateAppIn(console.OpsAccountID, app, addr, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ // --- device flow: start -> approve -> poll, to a real token on disk ----
+ token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop")
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ // sess is used only for the assertions this test makes directly against
+ // the console (config GET, and the negative case below) — never for the
+ // mutations under test, which go through the real run* functions.
+ sess := &apiSession{c: newConsoleClient(ts.URL, token), console: ts.URL, address: addr}
+
+ // --- (1) `env set PORT=8080` through the real CLI path ------------------
+ runEnvSet([]string{"-app", app, "PORT=8080"})
+
+ cfg, err := sess.configGet(context.Background(), acctFlag, app, "")
+ if err != nil {
+ t.Fatalf("config GET after env set: %v", err)
+ }
+ var sawPort bool
+ for _, v := range cfg.Vars {
+ if v.Key == "PORT" {
+ sawPort = true
+ if v.Kind != "env" || v.Value != "8080" {
+ t.Fatalf("PORT var = %+v, want kind=env value=8080", v)
+ }
+ }
+ }
+ if !sawPort {
+ t.Fatalf("config GET vars = %+v, want PORT to be visible", cfg.Vars)
+ }
+
+ // --- (2) `secrets set BANK_KEY=hunter2-e2e`, no CARLOS_SECRETS_PUBKEY ---
+ //
+ // secretsPubKeyAPI (appconfig_cli.go) falls back to the console's served
+ // key exactly because CARLOS_SECRETS_PUBKEY is unset in this test's own
+ // env (t.Setenv above) — this is the console-served path, not the env
+ // var one.
+ runSecretsSet([]string{"-app", app, "BANK_KEY=hunter2-e2e"})
+
+ cfg, err = sess.configGet(context.Background(), acctFlag, app, "")
+ if err != nil {
+ t.Fatalf("config GET after secrets set: %v", err)
+ }
+ var sawBankKey bool
+ for _, v := range cfg.Vars {
+ if v.Key == "BANK_KEY" {
+ sawBankKey = true
+ if v.Kind != "secret" {
+ t.Fatalf("BANK_KEY var = %+v, want kind=secret", v)
+ }
+ if v.Value != "" {
+ t.Fatalf("BANK_KEY var carries a value over the wire: %+v — secret values must be structurally absent", v)
+ }
+ }
+ }
+ if !sawBankKey {
+ t.Fatalf("config GET vars = %+v, want BANK_KEY (name only) to be visible", cfg.Vars)
+ }
+ // secrets list (CLI) shows only the key name.
+ listOut := runCapturingRealStdout(t, func() {
+ runSecretsList([]string{"-app", app})
+ })
+ if !strings.Contains(listOut, "BANK_KEY") {
+ t.Fatalf("secrets list stdout = %q, want it to name BANK_KEY", listOut)
+ }
+ if strings.Contains(listOut, "hunter2-e2e") {
+ t.Fatalf("secrets list stdout = %q, leaked the plaintext value", listOut)
+ }
+
+ // Read the manifest straight from the DirStore: the envelope Opens with
+ // the test's own private key to EXACTLY the plaintext, and the raw
+ // stored bytes never contain the plaintext (the console only ever saw
+ // ciphertext — this is the whole point of issue #43's threat model).
+ raw, _, err := relStore.Get(context.Background(), appconfig.Key(release.OpsAccount, app))
+ if err != nil {
+ t.Fatalf("read manifest from the store: %v", err)
+ }
+ if bytes.Contains(raw, []byte("hunter2-e2e")) {
+ t.Fatalf("raw stored manifest contains the secret plaintext:\n%s", raw)
+ }
+ var m appconfig.Manifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatalf("decode stored manifest: %v", err)
+ }
+ bankVar, ok := m.Vars["BANK_KEY"]
+ if !ok || bankVar.Kind != "secret" || bankVar.Envelope == "" {
+ t.Fatalf("stored manifest BANK_KEY = %+v, want a sealed secret envelope", bankVar)
+ }
+ plaintext, err := appconfig.Open(secPriv, bankVar.Envelope)
+ if err != nil {
+ t.Fatalf("Open the stored envelope with the test's private key: %v", err)
+ }
+ if string(plaintext) != "hunter2-e2e" {
+ t.Fatalf("opened plaintext = %q, want %q", plaintext, "hunter2-e2e")
+ }
+
+ // --- (3) `env unset PORT` -----------------------------------------------
+ unsetOut := runCapturingRealStdout(t, func() {
+ runEnvUnset([]string{"-app", app, "PORT"})
+ })
+ if !strings.Contains(unsetOut, "unset 1 of 1") {
+ t.Fatalf("env unset stdout = %q, want it to say \"unset 1 of 1\"", unsetOut)
+ }
+ cfg, err = sess.configGet(context.Background(), acctFlag, app, "")
+ if err != nil {
+ t.Fatalf("config GET after env unset: %v", err)
+ }
+ for _, v := range cfg.Vars {
+ if v.Key == "PORT" {
+ t.Fatalf("config GET vars = %+v, want PORT gone after unset", cfg.Vars)
+ }
+ }
+
+ // --- (4) `env sync` moves the generation object's etag ------------------
+ genKey := appconfig.GenKey(release.OpsAccount, app)
+ _, beforeEtag, err := relStore.Get(context.Background(), genKey)
+ if err != nil {
+ // Every write path above (Apply via env set/secrets set/env unset)
+ // already touches the generation object as a side effect
+ // (generation.go's touchLogged), so by this point in the test it
+ // must exist.
+ t.Fatalf("generation object before sync: %v", err)
+ }
+ runEnvSync([]string{"-app", app})
+ _, afterEtag, err := relStore.Get(context.Background(), genKey)
+ if err != nil {
+ t.Fatalf("generation object after sync: %v", err)
+ }
+ if afterEtag == beforeEtag {
+ t.Fatalf("generation etag unchanged by env sync: before=%s after=%s", beforeEtag, afterEtag)
+ }
+
+ // --- (5) negative: a token for an address that is NOT a member ---------
+ //
+ // runEnvSet itself log.Fatals on this error (os.Exit — nothing an
+ // in-process test could recover from), so the refusal is asserted at
+ // the apiSession level instead — the exact call runEnvSet's API-mode
+ // branch makes, against the same real server, with a token for an
+ // address that was never added to the ops account. Same technique
+ // TestE2ECLIThroughConsole uses for the stable-promote refusal.
+ const outsider = "mallory@keymail.dev"
+ outsiderToken, _ := e2eDeviceLogin(t, ts.URL, cst, outsider, "e2e@mallory")
+ outsiderSess := &apiSession{
+ c: newConsoleClient(ts.URL, outsiderToken),
+ console: ts.URL,
+ address: outsider,
+ }
+ if _, err := outsiderSess.configApply(context.Background(), acctFlag, app,
+ apiConfigApply{Set: map[string]appconfig.Var{"X": {Kind: "env", Value: "1"}}}); err == nil {
+ t.Fatal("configApply for a non-member address must refuse")
+ } else if !strings.Contains(err.Error(), "not found: check -account and -app") {
+ t.Fatalf("non-member refusal = %q, want the membership message", err.Error())
+ }
+}
diff --git a/docs/superpowers/specs/2026-08-03-cli-console-api-design.md b/docs/superpowers/specs/2026-08-03-cli-console-api-design.md
index 3e98633e2..8f4fdb403 100644
--- a/docs/superpowers/specs/2026-08-03-cli-console-api-design.md
+++ b/docs/superpowers/specs/2026-08-03-cli-console-api-design.md
@@ -144,7 +144,8 @@ notification this slice.
and box-side (migrated names need operator judgment, as tito's titogo
just demonstrated).
- `-hotfix`, `carlos route/add/remove/instances`, env/secrets verbs —
- operator/box territory or future slices.
+ operator/box territory or future slices (moved in-scope by
+ 2026-08-04-config-console-api-design.md — issues #43/#44).
- Stable via pending-action handoff.
- `carlos canary` — uses ship+promote internals; follows automatically if
those go through the client seam, but is not a named requirement and is