carlos / platform Public

cli: carlos instances door-key create, list, revoke (#175 P7)

Task 3 of the P7 plan: the CLI front over the three console doors. Built by
a Sonnet subagent from the plan; reviewed before commit. Its user-facing
strings are drafts pending the Task 8 copy review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Human

Prompt

Go ahead. (start P7)

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

P7 Task 3: the door-key CLI verbs.
claude agent, operated by paul@keymail.dev c65ceff350bd80cefb4185be14a402c5e8f4988a parent 3098e4d
3 files changed, +440 −0
  • cli.go +3 −0
  • doorkeys_cli.go +238 −0
  • doorkeys_cli_test.go +199 −0
diff --git a/cli.go b/cli.go
index ad82f5462..9b96c907f 100644
--- a/cli.go
+++ b/cli.go
@@ -301,6 +301,9 @@ func runInstances(args []string) {
case "delete":
runInstancesDelete(args[1:])
return
+ case "door-key":
+ runInstancesDoorKey(args[1:])
+ return
case "set-upstreams":
runInstancesSetUpstreams(args[1:])
return
diff --git a/doorkeys_cli.go b/doorkeys_cli.go
new file mode 100644
index 000000000..40aafd064
--- /dev/null
+++ b/doorkeys_cli.go
@@ -0,0 +1,238 @@
+// The `carlos instances door-key` verb family (instance-moves P7, #175): the
+// CLI front over the console's three door-key doors
+// (internal/console/apidoorkeys.go) — mint, list and revoke the app-scoped
+// credential that authenticates an app's own processes on a box to the
+// edge-local door (a unix socket), whatever box the instance lives on. API
+// mode only, the same as `carlos instances create/list/delete`: a door key
+// is a console-authorized write, the console's database is the authority,
+// and there is no store-mode equivalent. Modelled on runInstancesDelete
+// (instances_cli.go) for the verb shape and mail_cli.go's emailDeclare/
+// emailStatus for the wire calls.
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "strings"
+ "time"
+)
+
+const doorKeyUsage = `usage: carlos instances door-key create --app <a> [--label <text>]
+ carlos instances door-key list --app <a>
+ carlos instances door-key revoke --app <a> <id>
+
+ --account <a> account (sqid or name; default: your only account,
+ or the configured/chosen one)
+ --account-id <id> account sqid, matched as a sqid ONLY — supersedes
+ --account
+ --console <origin> console to act through (default: the only
+ logged-in one)
+
+Signed-in only (carlos auth login): a door key is a console-authorized
+write — the console's own database is the authority, and there is no
+direct-bucket equivalent.
+
+create mints a fresh key for the app and prints it once. store it now,
+it cannot be shown again — a new "carlos instances door-key list" only
+ever shows the id and label, never the key itself.
+
+list shows the app's live keys: id, label and when each was created.
+
+revoke takes the id from list and stops that key working.
+`
+
+// ---- wire types -------------------------------------------------------------
+
+// apiDoorKeyMint is handleAPIDoorKeyMint's 201 reply
+// (internal/console/apidoorkeys.go): the raw key, shown here and nowhere
+// else — the console keeps only its digest from this point on.
+type apiDoorKeyMint struct {
+ Key string `json:"key"`
+ ID string `json:"id"`
+ Account string `json:"account"`
+ App string `json:"app"`
+}
+
+// apiDoorKeyEntry is one row of handleAPIDoorKeyList's reply — never the key
+// itself, which the console does not keep in a recoverable form.
+type apiDoorKeyEntry struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// apiDoorKeyRevokeReply is handleAPIDoorKeyRevoke's 200 reply.
+type apiDoorKeyRevokeReply struct {
+ Revoked string `json:"revoked"`
+}
+
+// ---- apiSession wire calls ---------------------------------------------------
+
+func (s *apiSession) doorKeyMint(ctx context.Context, a apiAcct, app, label string) (apiDoorKeyMint, error) {
+ in := struct {
+ Label string `json:"label,omitempty"`
+ }{label}
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/door-keys"), in)
+ if err != nil {
+ return apiDoorKeyMint{}, err
+ }
+ if status != http.StatusCreated {
+ return apiDoorKeyMint{}, apiErrorFromBody(status, body)
+ }
+ var out apiDoorKeyMint
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiDoorKeyMint{}, fmt.Errorf("door-key create: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+func (s *apiSession) doorKeyList(ctx context.Context, a apiAcct, app string) ([]apiDoorKeyEntry, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/door-keys"))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Keys []apiDoorKeyEntry `json:"keys"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("door-key list: unreadable response: %w", err)
+ }
+ return res.Keys, nil
+}
+
+func (s *apiSession) doorKeyRevoke(ctx context.Context, a apiAcct, app, id string) (string, error) {
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/door-keys/"+pathEscape(id)+"/revoke"), struct{}{})
+ if err != nil {
+ return "", err
+ }
+ if status != http.StatusOK {
+ return "", apiErrorFromBody(status, body)
+ }
+ var out apiDoorKeyRevokeReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return "", fmt.Errorf("door-key revoke: unreadable response: %w", err)
+ }
+ return out.Revoked, nil
+}
+
+// ---- verbs --------------------------------------------------------------
+
+// runInstancesDoorKey dispatches the door-key sub-verbs — create/list/revoke
+// are positional words, not flags, and must never reach fs.Parse, the same
+// shape runInstances and runDomains use for their own sub-verbs.
+func runInstancesDoorKey(args []string) {
+ if len(args) > 0 {
+ switch args[0] {
+ case "create":
+ runInstancesDoorKeyCreate(args[1:])
+ return
+ case "list":
+ runInstancesDoorKeyList(args[1:])
+ return
+ case "revoke":
+ runInstancesDoorKeyRevoke(args[1:])
+ return
+ }
+ }
+ log.Fatal(doorKeyUsage)
+}
+
+func runInstancesDoorKeyCreate(args []string) {
+ fs := flag.NewFlagSet("instances door-key create", flag.ExitOnError)
+ app := appFlag(fs, "app to mint a door key for")
+ label := fs.String("label", "", "short note stored alongside the key, shown by list (default: none)")
+ consoleFlag := consoleOption(fs, "console to create through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
+ fs.Usage = flagUsage(fs, doorKeyUsage)
+ fs.Parse(args)
+ sess := requireAPISession("door-key create", *consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, doorKeyUsage)
+ res, err := sess.doorKeyMint(context.Background(), acct, app2, strings.TrimSpace(*label))
+ if err != nil {
+ log.Fatalf("instances door-key create: %v", err)
+ }
+ printDoorKeyCreate(stdout, res)
+}
+
+// printDoorKeyCreate prints the raw key on its own line, so it is the one
+// thing a script capturing this command's stdout has to parse out, then a
+// warning that this is the only time it will ever be shown — the console
+// keeps only its digest from here on, the same rule email credentials
+// create follows for a standalone credential's password.
+func printDoorKeyCreate(w io.Writer, res apiDoorKeyMint) {
+ fmt.Fprintln(w, res.Key)
+ warnf(w, "shown once. store it now, it cannot be shown again.")
+}
+
+func runInstancesDoorKeyList(args []string) {
+ fs := flag.NewFlagSet("instances door-key list", flag.ExitOnError)
+ app := appFlag(fs, "app whose door keys to list")
+ consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
+ fs.Usage = flagUsage(fs, doorKeyUsage)
+ fs.Parse(args)
+ sess := requireAPISession("door-key list", *consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, doorKeyUsage)
+ keys, err := sess.doorKeyList(context.Background(), acct, app2)
+ if err != nil {
+ log.Fatalf("instances door-key list: %v", err)
+ }
+ printDoorKeyList(stdout, keys)
+}
+
+// printDoorKeyList never prints a key — the list reply this reads from does
+// not carry one (apiDoorKeyEntry has no such field at all), so there is
+// nothing here to leak by construction.
+func printDoorKeyList(w io.Writer, keys []apiDoorKeyEntry) {
+ if len(keys) == 0 {
+ mutedf(w, "no door keys")
+ return
+ }
+ t := newTable(w)
+ fmt.Fprintf(t, "ID\tLABEL\tCREATED\n")
+ for _, k := range keys {
+ label := k.Label
+ if label == "" {
+ label = "-"
+ }
+ fmt.Fprintf(t, "%s\t%s\t%s\n", k.ID, label, k.CreatedAt.Format(time.RFC3339))
+ }
+ t.Flush()
+}
+
+func runInstancesDoorKeyRevoke(args []string) {
+ fs := flag.NewFlagSet("instances door-key revoke", flag.ExitOnError)
+ app := appFlag(fs, "app the door key belongs to")
+ consoleFlag := consoleOption(fs, "console to revoke through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
+ fs.Usage = flagUsage(fs, doorKeyUsage)
+ fs.Parse(args)
+ id := strings.TrimSpace(fs.Arg(0))
+ if id == "" || fs.NArg() > 1 {
+ log.Fatal(doorKeyUsage)
+ }
+ sess := requireAPISession("door-key revoke", *consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, doorKeyUsage)
+ revoked, err := sess.doorKeyRevoke(context.Background(), acct, app2, id)
+ if err != nil {
+ log.Fatalf("instances door-key revoke: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("revoked"), revoked)
+}
diff --git a/doorkeys_cli_test.go b/doorkeys_cli_test.go
new file mode 100644
index 000000000..fc8dd8a8c
--- /dev/null
+++ b/doorkeys_cli_test.go
@@ -0,0 +1,199 @@
+// Tests for `carlos instances door-key` (instance-moves P7, #175): the
+// apiSession wire calls (doorkeys_cli.go) against a bare httptest server —
+// method, path and body round trips, the shape instances_cli_test.go and
+// mail_cli_test.go pin their own wire calls with — plus RunInstancesDoorKey-
+// level tests against a fake console with a credential written straight to
+// disk (fleetsCLIFixture/servicesCLIFixture's pattern), proving the key
+// really reaches stdout exactly once, the list table prints, and revoke's
+// confirmation names the id.
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// ---- apiSession-level: path + body + wire-shape round trips ---------------
+
+func TestAPISessionDoorKeyMintWireShape(t *testing.T) {
+ var gotPath, gotMethod string
+ var gotBody struct {
+ Label string `json:"label"`
+ }
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod = r.URL.Path, r.Method
+ json.NewDecoder(r.Body).Decode(&gotBody)
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{
+ "key": "cdk1.bab.titogo.0123456789abcdef." + strings.Repeat("A", 43),
+ "id": "0123456789abcdef", "account": "bab", "app": "titogo",
+ })
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).doorKeyMint(context.Background(), apiAcct{Seg: "bab"}, "titogo", "router")
+ if err != nil {
+ t.Fatalf("doorKeyMint: %v", err)
+ }
+ if gotMethod != http.MethodPost || gotPath != "/api/cli/apps/bab/titogo/door-keys" {
+ t.Fatalf("method/path = %s %s", gotMethod, gotPath)
+ }
+ if gotBody.Label != "router" {
+ t.Fatalf("body label = %q, want %q", gotBody.Label, "router")
+ }
+ if res.ID != "0123456789abcdef" || !strings.HasPrefix(res.Key, "cdk1.bab.titogo.") {
+ t.Fatalf("result = %+v", res)
+ }
+}
+
+func TestAPISessionDoorKeyListWireShape(t *testing.T) {
+ var gotPath, gotMethod string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod = r.URL.Path, r.Method
+ json.NewEncoder(w).Encode(map[string]any{
+ "keys": []map[string]any{
+ {"id": "0123456789abcdef", "label": "router", "created_at": "2026-09-28T00:00:00Z"},
+ },
+ })
+ }))
+ defer ts.Close()
+
+ keys, err := newTestSession(ts.URL).doorKeyList(context.Background(), apiAcct{Seg: "bab"}, "titogo")
+ if err != nil {
+ t.Fatalf("doorKeyList: %v", err)
+ }
+ if gotMethod != http.MethodGet || gotPath != "/api/cli/apps/bab/titogo/door-keys" {
+ t.Fatalf("method/path = %s %s", gotMethod, gotPath)
+ }
+ if len(keys) != 1 || keys[0].ID != "0123456789abcdef" || keys[0].Label != "router" {
+ t.Fatalf("keys = %+v", keys)
+ }
+}
+
+func TestAPISessionDoorKeyRevokeWireShape(t *testing.T) {
+ var gotPath, gotMethod string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod = r.URL.Path, r.Method
+ json.NewEncoder(w).Encode(map[string]any{"revoked": "0123456789abcdef"})
+ }))
+ defer ts.Close()
+
+ revoked, err := newTestSession(ts.URL).doorKeyRevoke(context.Background(), apiAcct{Seg: "bab"}, "titogo", "0123456789abcdef")
+ if err != nil {
+ t.Fatalf("doorKeyRevoke: %v", err)
+ }
+ if gotMethod != http.MethodPost || gotPath != "/api/cli/apps/bab/titogo/door-keys/0123456789abcdef/revoke" {
+ t.Fatalf("method/path = %s %s", gotMethod, gotPath)
+ }
+ if revoked != "0123456789abcdef" {
+ t.Fatalf("revoked = %q", revoked)
+ }
+}
+
+func TestAPISessionDoorKeyRevokeUnknownID404(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ json.NewEncoder(w).Encode(map[string]any{"error": "no door key with that id"})
+ }))
+ defer ts.Close()
+
+ if _, err := newTestSession(ts.URL).doorKeyRevoke(context.Background(), apiAcct{Seg: "bab"}, "titogo", "deadbeef"); err == nil {
+ t.Fatal("doorKeyRevoke: want an error on 404")
+ }
+}
+
+// ---- RunInstancesDoorKey-level: full verb, good args, real printing -------
+
+// doorKeysCLIFixture wires up a fake console stubbing the three door-key
+// doors plus a credential pointed at it, entirely in-process — the same
+// shortcut fleetsCLIFixture/servicesCLIFixture take.
+func doorKeysCLIFixture(t *testing.T) *httptest.Server {
+ t.Helper()
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("CARLOS_CONSOLE", "")
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", "")
+ t.Chdir(t.TempDir())
+ creds := filepath.Join(home, "carlos-credentials")
+ t.Setenv("CARLOS_CREDENTIALS", creds)
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /api/cli/apps/{account}/{app}/door-keys", func(w http.ResponseWriter, r *http.Request) {
+ var req struct{ Label string }
+ json.NewDecoder(r.Body).Decode(&req)
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{
+ "key": "cdk1." + r.PathValue("account") + "." + r.PathValue("app") + ".0123456789abcdef." + strings.Repeat("A", 43),
+ "id": "0123456789abcdef", "account": r.PathValue("account"), "app": r.PathValue("app"),
+ })
+ })
+ mux.HandleFunc("GET /api/cli/apps/{account}/{app}/door-keys", func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{
+ "keys": []map[string]any{
+ {"id": "0123456789abcdef", "label": "router", "created_at": "2026-09-28T00:00:00Z"},
+ {"id": "fedcba9876543210", "label": "", "created_at": "2026-09-27T00:00:00Z"},
+ },
+ })
+ })
+ mux.HandleFunc("POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke", func(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if id != "0123456789abcdef" {
+ w.WriteHeader(http.StatusNotFound)
+ json.NewEncoder(w).Encode(map[string]any{"error": "no door key with that id"})
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{"revoked": id})
+ })
+ ts := httptest.NewServer(mux)
+ t.Cleanup(ts.Close)
+ writeTestCredential(t, creds, ts.URL)
+ return ts
+}
+
+func TestRunInstancesDoorKeyCreatePrintsKeyOnce(t *testing.T) {
+ doorKeysCLIFixture(t)
+ buf := captureStdout(t)
+
+ runInstancesDoorKey([]string{"create", "--app", "titogo", "--account", "bab", "--label", "router"})
+
+ out := buf.String()
+ if got := strings.Count(out, "cdk1.bab.titogo.0123456789abcdef."); got != 1 {
+ t.Fatalf("stdout = %q, want the key exactly once", out)
+ }
+ if !strings.Contains(out, "shown once") {
+ t.Fatalf("stdout = %q, want a shown-once warning", out)
+ }
+}
+
+func TestRunInstancesDoorKeyListPrintsTable(t *testing.T) {
+ doorKeysCLIFixture(t)
+ buf := captureStdout(t)
+
+ runInstancesDoorKey([]string{"list", "--app", "titogo", "--account", "bab"})
+
+ out := buf.String()
+ if !strings.Contains(out, "0123456789abcdef") || !strings.Contains(out, "router") {
+ t.Fatalf("stdout = %q, want the key id and label", out)
+ }
+ if !strings.Contains(out, "fedcba9876543210") {
+ t.Fatalf("stdout = %q, want the second key id", out)
+ }
+}
+
+func TestRunInstancesDoorKeyRevokePrintsConfirmation(t *testing.T) {
+ doorKeysCLIFixture(t)
+ buf := captureStdout(t)
+
+ runInstancesDoorKey([]string{"revoke", "--app", "titogo", "--account", "bab", "0123456789abcdef"})
+
+ out := buf.String()
+ if !strings.Contains(out, "revoked") || !strings.Contains(out, "0123456789abcdef") {
+ t.Fatalf("stdout = %q, want a confirmation naming the id", out)
+ }
+}