carlos / platform Public

carlos services: mint, list, rotate and revoke from a terminal

Adds the `carlos services` CLI verbs (Task 7 of the service-principals
plan) on top of Task 6's four owner-gated API doors:

  POST   /api/cli/services/{account}                  create
  GET    /api/cli/services/{account}                   list
  POST   /api/cli/services/{account}/{name}/rotate     rotate
  POST   /api/cli/services/{account}/{name}/revoke     revoke

services.go: servicesUsage, RunServices dispatching create|ls|rotate|revoke,
servicesSignedInSession (fatals with a services-specific message in direct
bucket mode, mirroring fleetsSignedInSession), validServiceName and
validServiceHandle (client-side gates checked before any API call, per
apps.go's validAppName principle) using registry.ValidTunnelName plus the
64-hex listing-id grammar.

apimode.go: four apiSession methods beside createFleet — createService,
listServices, rotateService and revokeService — following its exact
postJSON/getJSON + status check + apiErrorFromBody + typed-result shape.
revokeService returns (matched bool, err error) rather than the brief's
guessed error-only shape: the wire's "matched" field is what tells the
caller whether anything was actually revoked, and the CLI must not render
a false matched as success.

main.go: wires `services` into both the verb switch and the usage block.

Three things the CLI gets right per the brief: a false `matched` on revoke
prints "nothing to revoke" and still exits 0 (the wanted state already
holds); an app-scoped credential's door list says "bound to app X ONLY"
next to the {app}-wildcarded patterns rather than letting them read as
"any app"; the secret is printed exactly once, with the deliver-it line
showing a literal <secret> placeholder rather than a second copy of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
Paul Campbell pushed by paul@keymail.dev 8a7a81f952b66c545f6d1094e687be2d256cceb5 parent 7866e5e
4 files changed, +1077 −0
  • apimode.go +139 −0
  • main.go +6 −0
  • services.go +363 −0
  • services_cli_test.go +569 −0
diff --git a/apimode.go b/apimode.go
index 92aa9e78c..cdb4f6afd 100644
--- a/apimode.go
+++ b/apimode.go
@@ -2421,3 +2421,142 @@ func (s *apiSession) place(ctx context.Context, a apiAcct, app, target string) e
}
return nil
}
+
+// ---- service credentials (services.go's `carlos services` verbs) ---------
+
+// servicePath builds one /api/cli/services/{account}... path — the service
+// credential family's account-scoped door (internal/console/apiservices.go),
+// the same sibling-of-apiPath shape fleetPath already established: identity
+// here is account + (name or listing id), never account + app. tail is a
+// bare path (any name/id segment the caller needs already
+// url.PathEscape'd into it) with no query string of its own — exactly
+// fleetPath's own contract, for the same reason: -account-id's exact=sqid
+// switch is appended by plain concatenation below.
+func servicePath(a apiAcct, tail string) string {
+ p := "/api/cli/services/" + url.PathEscape(a.Seg) + tail
+ if a.Exact {
+ p += "?exact=sqid"
+ }
+ return p
+}
+
+// apiServiceView is one listed credential's wire shape — deliberately the
+// same field set as serviceView (internal/console/apiservices.go): the
+// listing id (a sha256 hex, never a secret), name, role, app/label scope,
+// the created/rotated/last-used timestamps, and who minted it. There is
+// deliberately no "revoked" field — the door only ever lists live rows.
+type apiServiceView struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Role string `json:"role"`
+ Label string `json:"label"`
+ App string `json:"app"`
+ Created string `json:"created"`
+ Rotated string `json:"rotated"`
+ LastUsed string `json:"last_used"`
+ MintedBy string `json:"minted_by"`
+}
+
+// createService mints one account-owned service credential, optionally
+// scoped to a single app — `carlos services create`. The plaintext secret is
+// in the return value exactly once; RunServices prints it exactly once and
+// it must never be logged or retried into a second print (only its hash is
+// stored server-side, so there is no second copy to hand out later). doors
+// is the sorted list of /api/cli/ patterns the granted role reaches
+// (handleAPIServiceCreate's doorsFor) — read at print time, not
+// hand-maintained here, so this client cannot drift from what the
+// credential will actually be admitted to.
+func (s *apiSession) createService(ctx context.Context, a apiAcct, name, role, app string) (token string, doors []string, err error) {
+ in := struct {
+ Name string `json:"name"`
+ Role string `json:"role"`
+ App string `json:"app"`
+ }{name, role, app}
+ status, body, err := s.postJSON(ctx, servicePath(a, ""), in)
+ if err != nil {
+ return "", nil, err
+ }
+ if status != http.StatusCreated {
+ return "", nil, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Token string `json:"token"`
+ Doors []string `json:"doors"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return "", nil, fmt.Errorf("services create: unreadable response: %w", err)
+ }
+ return res.Token, res.Doors, nil
+}
+
+// listServices asks the console for every LIVE credential the caller's
+// account owns — `carlos services ls`. Never a secret and never a hash;
+// apiServiceView carries only what serviceView puts on the wire.
+func (s *apiSession) listServices(ctx context.Context, a apiAcct) ([]apiServiceView, error) {
+ status, body, err := s.getJSON(ctx, servicePath(a, ""))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Services []apiServiceView `json:"services"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("services ls: unreadable response: %w", err)
+ }
+ return res.Services, nil
+}
+
+// rotateService mints a fresh secret for a credential that already exists,
+// addressed by NAME only (handleAPIServiceRotate takes no listing-id form) —
+// `carlos services rotate`. This refuses the credential's NEXT call with the
+// OLD secret but does not stop anything already in flight; a credential that
+// may be compromised needs revokeService, not this. RunServices repeats that
+// distinction in its own output, the way printFleetToken does for fleets.
+func (s *apiSession) rotateService(ctx context.Context, a apiAcct, name string) (token string, err error) {
+ status, body, err := s.postJSON(ctx, servicePath(a, "/"+url.PathEscape(name)+"/rotate"), struct{}{})
+ if err != nil {
+ return "", err
+ }
+ if status != http.StatusOK {
+ return "", apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Token string `json:"token"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return "", fmt.Errorf("services rotate: unreadable response: %w", err)
+ }
+ return res.Token, nil
+}
+
+// revokeService is the hard stop rotation is not: the credential's next call
+// fails, whatever secret it holds — `carlos services revoke`, addressed by
+// name or by listing id (serviceHandle, internal/console/apiservices.go).
+//
+// Idempotent server-side: an unknown name, an unknown id, an
+// already-revoked credential and a live one all answer 200. matched is the
+// field that says which it was (C41) — false means nothing was actually
+// revoked, most often a typo'd name, and RunServices must never render that
+// as plain success. This method's own signature deliberately deviates from
+// the task brief's guessed `error`-only shape: the brief predates knowing
+// the wire carries "matched" at all, and dropping that bit here would leave
+// no way for the verb above to tell a real revoke from a no-op one.
+func (s *apiSession) revokeService(ctx context.Context, a apiAcct, name string) (matched bool, err error) {
+ status, body, err := s.postJSON(ctx, servicePath(a, "/"+url.PathEscape(name)+"/revoke"), struct{}{})
+ if err != nil {
+ return false, err
+ }
+ if status != http.StatusOK {
+ return false, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Matched bool `json:"matched"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return false, fmt.Errorf("services revoke: unreadable response: %w", err)
+ }
+ return res.Matched, nil
+}
diff --git a/main.go b/main.go
index fd96f0e7b..0029f3a87 100644
--- a/main.go
+++ b/main.go
@@ -94,6 +94,8 @@ func main() {
RunApps(args)
case "fleets":
RunFleets(args)
+ case "services":
+ RunServices(args)
case "env":
RunEnv(args)
case "secrets":
@@ -180,6 +182,10 @@ Your deployment — stand up and administer boxes, routes and accounts
detach-box | ls — box tokens print exactly once, at mint
or rotate, and a compromised box needs detach-box, not
rotate-token
+ services credentials a SERVER holds, not a person: create | ls |
+ rotate | revoke, owner-only and closed to service
+ credentials themselves — secrets print exactly once, and
+ a compromised one needs revoke, not rotate
add route a hostname on this box to an app
remove drop one of this box's routes
route repoint a route's channel or config environment, grant or
diff --git a/services.go b/services.go
new file mode 100644
index 000000000..dc2a2ded6
--- /dev/null
+++ b/services.go
@@ -0,0 +1,363 @@
+// The `carlos services` verbs: mint, list, rotate and revoke a service
+// credential — a principal a SERVER holds, not an address a person types a
+// password for. This is the CLI door onto internal/console/apiservices.go,
+// modelled on fleets.go the way that file's own doc comment says: same
+// account resolution, same mint-once discipline (the plaintext is printed
+// exactly once, here, because only its hash is ever stored), same
+// byte-identical refusals.
+//
+// Every door here is owner-gated and closed to service credentials
+// themselves — the escalation cut spec §3 (docs/superpowers/specs/
+// 2026-08-18-service-principals-design.md) turns on, enforced console-side
+// before any handler in apiservices.go runs. This file re-checks none of
+// that; it is the terminal surface on top of doors that already refuse
+// everything but an owner's own session.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "regexp"
+ "strings"
+ "text/tabwriter"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+const servicesUsage = `usage: carlos services <create|ls|rotate|revoke> [flags]
+
+ create -role <publish|instance|operate|admin> [-app <app>] [-account <sqid|name>] <name>
+ mint a credential a SERVER holds, bound to this account —
+ not to you. Unlike your own CLI token, its reach is fixed
+ at mint: adding you to another account later does not widen
+ it, and it draws on its own rate-limit budgets rather than
+ yours.
+ THE SECRET IS PRINTED EXACTLY ONCE, HERE. Only its hash is
+ stored, so losing this output means rotate, not a re-run.
+ -role admin INCLUDES ` + "`store grant`" + `, which mints a
+ path-scoped IAM user. It is a real handover, not a
+ convenience bundle. Prefer the narrowest role that works.
+ -app binds it to ONE app, which also means it cannot create
+ apps. Omit -app for an account-scoped credential.
+
+ ls [-account <sqid|name>]
+ list every LIVE credential the account owns — name, role,
+ scope, when it was minted and last rotated and used, and
+ who minted it. A revoked credential does not appear; there
+ is no history view here.
+
+ rotate [-account <sqid|name>] <name>
+ mint a FRESH secret for a credential that already exists,
+ printed exactly once. This refuses the credential's NEXT
+ call with the old secret. ROTATION IS NOT REVOCATION: it
+ does not stop work already in flight, and a credential that
+ may be COMPROMISED needs revoke.
+
+ revoke [-account <sqid|name>] <name-or-id>
+ the hard stop rotate is not: the credential's next call
+ fails, whatever secret it holds. Idempotent — revoking a
+ name twice, or one that never existed, answers the same.
+ Takes a credential's name, or the listing id ` + "`ls`" + ` shows
+ (the only handle a nameless legacy credential has).
+
+ -account <sqid|name> account the credential belongs to (default: your only
+ account, or the configured/chosen one) —
+ resolved by the console against your own
+ memberships.
+ -account-id <sqid> account sqid, matched as a sqid ONLY — supersedes
+ -account
+ -console <origin> which logged-in console to use (only needed with
+ several)
+
+Every verb here is signed-in only (carlos auth login), owner-only, and
+closed to a service credential itself — no service credential can create,
+rotate or revoke any credential, not even itself, not even an admin one.
+Service credentials are console-tracked state with no bucket-direct fallback.
+`
+
+// RunServices dispatches the services sub-verbs.
+func RunServices(args []string) {
+ if len(args) == 0 {
+ fmt.Fprint(os.Stderr, servicesUsage)
+ os.Exit(1)
+ }
+ switch args[0] {
+ case "create":
+ runServicesCreate(args[1:])
+ case "ls":
+ runServicesList(args[1:])
+ case "rotate":
+ runServicesRotate(args[1:])
+ case "revoke":
+ runServicesRevoke(args[1:])
+ default:
+ fmt.Fprint(os.Stderr, servicesUsage)
+ os.Exit(1)
+ }
+}
+
+// servicesSignedInSession resolves API mode and fatals with a
+// services-specific message when this shell is in direct bucket mode —
+// every services verb is console-API-only, the same posture
+// fleetsSignedInSession takes: a service credential is console-tracked
+// state with nothing in the bucket to fall back to.
+func servicesSignedInSession(consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal("services works through a console, and this shell is in direct bucket mode\n" +
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Service credentials are\n" +
+ "console-tracked state with no bucket-direct fallback: unset the store env and run\n" +
+ "carlos auth login -console <url> instead.")
+ }
+ return sess
+}
+
+// validServiceName is the client-side gate for a credential name, checked
+// BEFORE any API call — the same "a shape the server is certain to refuse
+// should cost nothing" principle apps.go's validAppName states, and fleets.go's
+// validFleetName/validBoxLabel already apply to the fleets family. Uses
+// registry.ValidTunnelName, the same grammar handleAPIServiceCreate checks
+// server-side.
+func validServiceName(name string) error {
+ if registry.ValidTunnelName(name) {
+ return nil
+ }
+ return fmt.Errorf("%q is not a usable service credential name — 1-32 characters of a-z, 0-9 and hyphen", name)
+}
+
+// listingIDRe mirrors internal/console/apiservices.go's own listingIDRe: the
+// shape of a listing id, 64 hex characters, accepted in either case. Kept as
+// its own copy rather than an import — this package has no reach into
+// internal/console, and the shape is simple enough that duplicating it here
+// costs nothing and drifts from nothing (both sides check "is this 64 hex
+// characters", not a computed value).
+var listingIDRe = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
+
+// validServiceHandle is revoke's client-side gate: a segment must be EITHER
+// a usable credential name OR a 64-character hex listing id — the same two
+// disjoint grammars serviceHandleFrom accepts server-side. Checked before
+// any API call, for the same cost-nothing reason validServiceName is.
+func validServiceHandle(handle string) error {
+ if registry.ValidTunnelName(handle) || listingIDRe.MatchString(handle) {
+ return nil
+ }
+ return fmt.Errorf("%q is not a usable service credential name or listing id — "+
+ "a name (1-32 characters of a-z, 0-9 and hyphen) or a 64-character hex id", handle)
+}
+
+func runServicesCreate(args []string) {
+ fs := flag.NewFlagSet("services create", flag.ExitOnError)
+ role := fs.String("role", "", "role: publish, instance, operate or admin (required)")
+ app := fs.String("app", "", "bind the credential to one app (default: account-wide)")
+ acctRaw := fs.String("account", "", "account (sqid or name) to mint the credential in (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)")
+ fs.Parse(args)
+ const usageMsg = "usage: carlos services create -role <publish|instance|operate|admin> [-app <app>] " +
+ "[-account <sqid|name>] [-account-id <sqid>] [-console <origin>] <name>"
+ if fs.NArg() != 1 || strings.TrimSpace(*role) == "" {
+ log.Fatal(usageMsg)
+ }
+ name := strings.TrimSpace(fs.Arg(0))
+ if err := validServiceName(name); err != nil {
+ log.Fatalf("services create: %v", err)
+ }
+ roleV := strings.TrimSpace(*role)
+ if _, ok := svcrole.Parse(roleV); !ok {
+ log.Fatalf("services create: unknown role %q — one of: %s", roleV, strings.Join(roleNames(), ", "))
+ }
+ appV := strings.TrimSpace(*app)
+
+ sess := servicesSignedInSession(*consoleFlag)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ token, doors, err := sess.createService(context.Background(), acct, name, roleV, appV)
+ if err != nil {
+ log.Fatalf("services create: %v", err)
+ }
+ printServiceCreated(acct.Seg, name, roleV, appV, token, doors)
+}
+
+// roleNames is svcrole's catalogue as strings, read from the package rather
+// than hand-listed here — the same "read it from the source of truth"
+// discipline doorsFor (internal/console/apiservices.go) applies to the door
+// list itself.
+func roleNames() []string {
+ roles := svcrole.All()
+ out := make([]string, len(roles))
+ for i, r := range roles {
+ out[i] = string(r)
+ }
+ return out
+}
+
+// printServiceCreated is the ONE place a service credential's secret
+// reaches stdout on create — printed exactly once, immediately, with the
+// scope it was minted into stated in words (requirement: an app-scoped
+// credential's door list must not read as "any app"; the {app} wildcard in
+// every releases/promote/etc. pattern below is only ever true for THIS app),
+// the doors it reaches, how to deliver it, and how to revoke it.
+//
+// The deliver line shows a literal "<secret>" placeholder rather than the
+// token itself: the secret appears exactly once in this output, on the
+// CARLOS_SERVICE_TOKEN= line above, not a second time inside a copy-paste
+// command. There is only one plaintext copy of this credential ever
+// (only its hash is stored), and this print is it.
+func printServiceCreated(acctSeg, name, role, app, token string, doors []string) {
+ scope := "account-wide"
+ if app != "" {
+ scope = "app " + app
+ }
+ fmt.Fprintf(stdout, "minted service:%s/%s (role %s, %s)\n\n", acctSeg, name, role, scope)
+ fmt.Fprintf(stdout, " CARLOS_SERVICE_TOKEN=%s\n\n", token)
+ fmt.Fprintln(stdout, " ^ shown once. Only its hash is stored — a lost secret means rotate, not a re-run.")
+ fmt.Fprintln(stdout)
+ if app != "" {
+ fmt.Fprintf(stdout, "it may (bound to app %s ONLY — despite the {app} wildcard shown below):\n", app)
+ } else {
+ fmt.Fprintln(stdout, "it may:")
+ }
+ for _, d := range doors {
+ method, path, ok := strings.Cut(d, " ")
+ if !ok {
+ fmt.Fprintf(stdout, " %s\n", d)
+ continue
+ }
+ fmt.Fprintf(stdout, " %-6s %s\n", method, path)
+ }
+ fmt.Fprintln(stdout)
+ fmt.Fprintln(stdout, "deliver it: carlos env set -app <app> -secret CARLOS_SERVICE_TOKEN=<secret>")
+ fmt.Fprintf(stdout, "revoke it: carlos services revoke -account %s %s\n", acctSeg, name)
+}
+
+func runServicesList(args []string) {
+ fs := flag.NewFlagSet("services ls", flag.ExitOnError)
+ acctRaw := fs.String("account", "", "account (sqid or name) to list credentials for (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)")
+ fs.Parse(args)
+
+ sess := servicesSignedInSession(*consoleFlag)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ rows, err := sess.listServices(context.Background(), acct)
+ if err != nil {
+ log.Fatalf("services ls: %v", err)
+ }
+ if len(rows) == 0 {
+ fmt.Fprintln(stdout, "no service credentials")
+ return
+ }
+ w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0)
+ fmt.Fprintln(w, "NAME\tROLE\tAPP\tLABEL\tCREATED\tROTATED\tLAST USED\tMINTED BY")
+ for _, r := range rows {
+ name, label, app := r.Name, r.Label, r.App
+ if name == "" {
+ name = "-"
+ }
+ if label == "" {
+ label = "-"
+ }
+ if app == "" {
+ app = "-"
+ }
+ created, rotated, lastUsed := r.Created, r.Rotated, r.LastUsed
+ if created == "" {
+ created = "-"
+ }
+ if rotated == "" {
+ rotated = "-"
+ }
+ if lastUsed == "" {
+ lastUsed = "-"
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
+ name, r.Role, app, label, created, rotated, lastUsed, r.MintedBy)
+ }
+ w.Flush()
+}
+
+func runServicesRotate(args []string) {
+ fs := flag.NewFlagSet("services rotate", flag.ExitOnError)
+ acctRaw := fs.String("account", "", "account (sqid or name) the credential 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")
+ consoleFlag := fs.String("console", "", "console to rotate through (default: the only logged-in one)")
+ fs.Parse(args)
+ if fs.NArg() != 1 {
+ log.Fatal("usage: carlos services rotate [-account <sqid|name>] [-account-id <sqid>] " +
+ "[-console <origin>] <name>")
+ }
+ name := strings.TrimSpace(fs.Arg(0))
+ if err := validServiceName(name); err != nil {
+ log.Fatalf("services rotate: %v", err)
+ }
+
+ sess := servicesSignedInSession(*consoleFlag)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ token, err := sess.rotateService(context.Background(), acct, name)
+ if err != nil {
+ log.Fatalf("services rotate: %v", err)
+ }
+ printServiceRotated(name, token)
+}
+
+// printServiceRotated is the ONE place a rotated secret reaches stdout —
+// printed exactly once, with the same ROTATION IS NOT REVOCATION warning
+// printFleetToken carries for fleets, in this family's own words: rotation
+// refuses the credential's NEXT call with the old secret, but does not stop
+// anything already in flight, and it is not what a compromised credential
+// needs — that is revoke.
+func printServiceRotated(name, token string) {
+ fmt.Fprintf(stdout, "rotated %s\n\n", name)
+ fmt.Fprintf(stdout, " CARLOS_SERVICE_TOKEN=%s\n\n", token)
+ fmt.Fprintln(stdout, " ^ shown once. Only its hash is stored — a lost secret means rotate again, not a re-run.")
+ fmt.Fprintln(stdout)
+ fmt.Fprintln(stdout, "ROTATION IS NOT REVOCATION: this refuses the credential's NEXT call with the OLD")
+ fmt.Fprintln(stdout, "secret, but does not stop work already in flight — a credential that may be")
+ fmt.Fprintf(stdout, "COMPROMISED needs `carlos services revoke -account <sqid|name> %s`, not another rotate.\n", name)
+}
+
+func runServicesRevoke(args []string) {
+ fs := flag.NewFlagSet("services revoke", flag.ExitOnError)
+ acctRaw := fs.String("account", "", "account (sqid or name) the credential 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")
+ consoleFlag := fs.String("console", "", "console to revoke through (default: the only logged-in one)")
+ fs.Parse(args)
+ if fs.NArg() != 1 {
+ log.Fatal("usage: carlos services revoke [-account <sqid|name>] [-account-id <sqid>] " +
+ "[-console <origin>] <name-or-id>")
+ }
+ handle := strings.TrimSpace(fs.Arg(0))
+ if err := validServiceHandle(handle); err != nil {
+ log.Fatalf("services revoke: %v", err)
+ }
+
+ sess := servicesSignedInSession(*consoleFlag)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ matched, err := sess.revokeService(context.Background(), acct, handle)
+ if err != nil {
+ log.Fatalf("services revoke: %v", err)
+ }
+ // matched is the field this door was given for (C41): the request
+ // itself is idempotent and always answers 200, but a false matched
+ // means the handle killed nothing — most often a typo. Printing that
+ // as plain "revoked" is the exact failure the field exists to catch,
+ // at the door used during an incident, so the two cases get visibly
+ // different wording. Both still exit 0: the state the caller wanted —
+ // no live credential of that handle — is the state that holds either
+ // way.
+ if matched {
+ fmt.Fprintf(stdout, "revoked %s — its next call fails, whatever secret it holds\n", handle)
+ return
+ }
+ fmt.Fprintf(stdout, "no live credential of %q — nothing to revoke (already revoked, or never existed)\n", handle)
+}
diff --git a/services_cli_test.go b/services_cli_test.go
new file mode 100644
index 000000000..06eb0b060
--- /dev/null
+++ b/services_cli_test.go
@@ -0,0 +1,569 @@
+// Tests for the `carlos services` verbs — the CLI door onto
+// internal/console/apiservices.go (Task 7). Same three layers
+// fleets_cli_test.go uses, for the same reasons (see that file's package
+// doc):
+//
+// - apiSession-level tests drive the four new client methods (apimode.go)
+// directly against an httptest fake console: path, body and wire-shape
+// round trips, with no flag parsing or os.Exit in the loop.
+// - RunServices-level tests drive the full verb (flag parsing, mode
+// resolution, printing) with GOOD arguments only, against the same kind
+// of fake console, credentials written straight to disk (mirroring
+// fleetsCLIFixture) — this is what proves the secret really reaches
+// stdout exactly once, the door list prints, and the rotate/revoke
+// wording is really in the output.
+// - subprocess tests (buildCarlos-style, like auth_e2e_test.go and
+// fleets_cli_test.go's TestFleetsAndPlaceCLIUsageErrors) drive the
+// bad-argument paths, which call log.Fatal/os.Exit and would kill an
+// in-process test binary outright.
+//
+// EVERY test here that shells out to the built carlos binary builds its env
+// through carlosTestEnv (masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE) and
+// sets cmd.Dir to its own scratch dir — CLAUDE.md's rule, restated in this
+// task's brief: a subprocess that inherited a real login has minted real
+// accounts on the flagship before.
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+)
+
+// ---- pure grammar gate -----------------------------------------------------
+
+func TestValidServiceName(t *testing.T) {
+ for _, good := range []string{"acme", "amadan-hub", "a", strings.Repeat("a", 32)} {
+ if err := validServiceName(good); err != nil {
+ t.Errorf("validServiceName(%q) = %v, want nil", good, err)
+ }
+ }
+ for _, bad := range []string{"", "Acme", "has spaces", "under_score", strings.Repeat("a", 33)} {
+ if err := validServiceName(bad); err == nil {
+ t.Errorf("validServiceName(%q) = nil, want an error", bad)
+ }
+ }
+}
+
+// TestValidServiceHandle: revoke's grammar is a name OR a 64-hex listing id,
+// in either case — the two grammars are disjoint by length so both can be
+// accepted by the same gate with no ambiguity.
+func TestValidServiceHandle(t *testing.T) {
+ for _, good := range []string{
+ "acme",
+ strings.Repeat("a", 64),
+ strings.ToUpper(strings.Repeat("a", 64)),
+ strings.Repeat("0123456789abcdef", 4),
+ } {
+ if err := validServiceHandle(good); err != nil {
+ t.Errorf("validServiceHandle(%q) = %v, want nil", good, err)
+ }
+ }
+ for _, bad := range []string{"", "Acme", "has spaces", strings.Repeat("a", 63), strings.Repeat("a", 65), strings.Repeat("g", 64)} {
+ if err := validServiceHandle(bad); err == nil {
+ t.Errorf("validServiceHandle(%q) = nil, want an error", bad)
+ }
+ }
+}
+
+// ---- apiSession-level: path + body + wire-shape round trips ---------------
+
+// TestAPISessionServiceCreateAndList: createService POSTs to the account
+// door with name/role/app in the body and reads back token+doors; listServices
+// GETs the same door and unmarshals the "services" wrapper.
+func TestAPISessionServiceCreateAndList(t *testing.T) {
+ var gotPath, gotMethod string
+ var gotBody struct{ Name, Role, App string }
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod = r.URL.Path, r.Method
+ if r.Method == http.MethodPost {
+ json.NewDecoder(r.Body).Decode(&gotBody)
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{
+ "name": gotBody.Name, "role": gotBody.Role, "app": gotBody.App,
+ "account": "bab", "token": "svc-secret-once",
+ "doors": []string{"POST /api/cli/apps/{account}/new", "GET /api/cli/apps"},
+ })
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "services": []map[string]any{
+ {"id": strings.Repeat("a", 64), "name": "amadan-hub", "role": "publish", "app": "",
+ "label": "", "created": "2026-08-18T00:00:00Z", "rotated": "2026-08-18T00:00:00Z",
+ "last_used": "", "minted_by": "paul@keymail.dev"},
+ },
+ })
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ token, doors, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "")
+ if err != nil {
+ t.Fatalf("createService: %v", err)
+ }
+ if gotPath != "/api/cli/services/bab" || gotMethod != http.MethodPost {
+ t.Fatalf("request = %s %s, want POST /api/cli/services/bab", gotMethod, gotPath)
+ }
+ if gotBody.Name != "amadan-hub" || gotBody.Role != "publish" || gotBody.App != "" {
+ t.Fatalf("body = %+v", gotBody)
+ }
+ if token != "svc-secret-once" || len(doors) != 2 {
+ t.Fatalf("createService result: token=%q doors=%v", token, doors)
+ }
+
+ rows, err := s.listServices(context.Background(), apiAcct{Seg: "bab"})
+ if err != nil {
+ t.Fatalf("listServices: %v", err)
+ }
+ if gotPath != "/api/cli/services/bab" || gotMethod != http.MethodGet {
+ t.Fatalf("request = %s %s, want GET /api/cli/services/bab", gotMethod, gotPath)
+ }
+ if len(rows) != 1 || rows[0].Name != "amadan-hub" || rows[0].Role != "publish" || rows[0].MintedBy != "paul@keymail.dev" {
+ t.Fatalf("listServices result = %+v", rows)
+ }
+}
+
+// TestAPISessionServiceCreateExactAccountID: -account-id travels as
+// exact=sqid, same as every other apiPath/fleetPath-built call.
+func TestAPISessionServiceCreateExactAccountID(t *testing.T) {
+ var gotQuery string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotQuery = r.URL.RawQuery
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{"name": "x", "role": "publish", "app": "", "account": "bab", "token": "t", "doors": []string{}})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ if _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab", Exact: true}, "x", "publish", ""); err != nil {
+ t.Fatalf("createService: %v", err)
+ }
+ if gotQuery != "exact=sqid" {
+ t.Fatalf("query = %q, want exact=sqid", gotQuery)
+ }
+}
+
+// TestAPISessionServiceCreateDuplicateName: the server's 409 sentence
+// surfaces verbatim, same convention as fleets create's duplicate refusal.
+func TestAPISessionServiceCreateDuplicateName(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ json.NewEncoder(w).Encode(map[string]string{"error": "a credential named amadan-hub already exists in this account"})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ if _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", ""); err == nil ||
+ err.Error() != "a credential named amadan-hub already exists in this account" {
+ t.Fatalf("createService duplicate = %v, want the server's sentence", err)
+ }
+}
+
+// TestAPISessionServiceRotateAndRevoke: rotate POSTs to the name-addressed
+// rotate door and returns a fresh token; revoke POSTs to the revoke door and
+// returns the matched boolean — the field that tells the caller whether
+// anything was actually revoked.
+func TestAPISessionServiceRotateAndRevoke(t *testing.T) {
+ var gotPath string
+ step := "rotate"
+ matched := true
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ switch step {
+ case "rotate":
+ json.NewEncoder(w).Encode(map[string]any{"name": "amadan-hub", "account": "bab", "token": "fresh-secret"})
+ case "revoke":
+ json.NewEncoder(w).Encode(map[string]any{"result": "revoked", "matched": matched})
+ }
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ acct := apiAcct{Seg: "bab"}
+
+ tok, err := s.rotateService(context.Background(), acct, "amadan-hub")
+ if err != nil {
+ t.Fatalf("rotateService: %v", err)
+ }
+ if gotPath != "/api/cli/services/bab/amadan-hub/rotate" || tok != "fresh-secret" {
+ t.Fatalf("rotateService: path=%s token=%s", gotPath, tok)
+ }
+
+ step = "revoke"
+ m, err := s.revokeService(context.Background(), acct, "amadan-hub")
+ if err != nil {
+ t.Fatalf("revokeService: %v", err)
+ }
+ if gotPath != "/api/cli/services/bab/amadan-hub/revoke" || !m {
+ t.Fatalf("revokeService: path=%s matched=%v, want matched=true", gotPath, m)
+ }
+
+ matched = false
+ m, err = s.revokeService(context.Background(), acct, "typo-name")
+ if err != nil {
+ t.Fatalf("revokeService (unmatched): %v", err)
+ }
+ if m {
+ t.Fatal("revokeService matched=true, want false — the server said nothing matched")
+ }
+}
+
+// TestAPISessionServiceDoorsSurfaceServerErrors: a non-2xx from any of the
+// four doors surfaces the server's own message, not a swallowed one.
+func TestAPISessionServiceDoorsSurfaceServerErrors(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ json.NewEncoder(w).Encode(map[string]string{"error": "only an account owner can manage service credentials"})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ acct := apiAcct{Seg: "bab"}
+
+ if _, _, err := s.createService(context.Background(), acct, "x", "publish", ""); err == nil {
+ t.Fatal("createService against a 403 must error")
+ }
+ if _, err := s.listServices(context.Background(), acct); err == nil {
+ t.Fatal("listServices against a 403 must error")
+ }
+ if _, err := s.rotateService(context.Background(), acct, "x"); err == nil {
+ t.Fatal("rotateService against a 403 must error")
+ }
+ if _, err := s.revokeService(context.Background(), acct, "x"); err == nil {
+ t.Fatal("revokeService against a 403 must error")
+ }
+}
+
+// ---- RunServices-level: full verb, good args, real printing ---------------
+
+// fakeServicesConsole answers the four CLI endpoints RunServices reaches,
+// stateful enough to prove create then rotate return DISTINCT secrets and
+// that revoke's matched flag really drives what RunServices prints.
+type fakeServicesConsole struct {
+ ts *httptest.Server
+
+ tokenSeq atomic.Int64
+
+ lastCreateBody atomic.Value // struct{ Name, Role, App string }
+ lastRevokeName atomic.Value // string
+ revokeMatched atomic.Bool
+}
+
+func newFakeServicesConsole(t *testing.T) *fakeServicesConsole {
+ t.Helper()
+ fc := &fakeServicesConsole{}
+ fc.revokeMatched.Store(true)
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /api/cli/services/{account}", func(w http.ResponseWriter, r *http.Request) {
+ var req struct{ Name, Role, App string }
+ json.NewDecoder(r.Body).Decode(&req)
+ fc.lastCreateBody.Store(req)
+ n := fc.tokenSeq.Add(1)
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{
+ "name": req.Name, "role": req.Role, "app": req.App, "account": "bab",
+ "token": tokenFor(n),
+ "doors": []string{
+ "POST /api/cli/apps/{account}/{app}/releases",
+ "POST /api/cli/apps/{account}/{app}/promote",
+ "GET /api/cli/apps",
+ },
+ })
+ })
+ mux.HandleFunc("GET /api/cli/services/{account}", func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{
+ "services": []map[string]any{
+ {"id": strings.Repeat("a", 64), "name": "amadan-hub", "role": "publish", "app": "titogo",
+ "label": "", "created": "2026-08-18T00:00:00Z", "rotated": "2026-08-18T00:00:00Z",
+ "last_used": "2026-08-18T01:00:00Z", "minted_by": "paul@keymail.dev"},
+ {"id": strings.Repeat("b", 64), "name": "", "role": "instance", "app": "router",
+ "label": "router", "created": "2026-08-01T00:00:00Z", "rotated": "",
+ "last_used": "", "minted_by": "paul@keymail.dev"},
+ },
+ })
+ })
+ mux.HandleFunc("POST /api/cli/services/{account}/{name}/rotate", func(w http.ResponseWriter, r *http.Request) {
+ n := fc.tokenSeq.Add(1)
+ json.NewEncoder(w).Encode(map[string]any{"token": tokenFor(n)})
+ })
+ mux.HandleFunc("POST /api/cli/services/{account}/{name}/revoke", func(w http.ResponseWriter, r *http.Request) {
+ fc.lastRevokeName.Store(r.PathValue("name"))
+ json.NewEncoder(w).Encode(map[string]any{"result": "revoked", "matched": fc.revokeMatched.Load()})
+ })
+ fc.ts = httptest.NewServer(mux)
+ t.Cleanup(fc.ts.Close)
+ return fc
+}
+
+// servicesCLIFixture wires up a fake console plus a credential pointed at
+// it, entirely in-process (no subprocess, no real login flow) — the same
+// shortcut fleetsCLIFixture takes.
+func servicesCLIFixture(t *testing.T) *fakeServicesConsole {
+ 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)
+ fc := newFakeServicesConsole(t)
+ writeTestCredential(t, creds, fc.ts.URL)
+ return fc
+}
+
+// TestServicesCreatePrintsTheSecretAndTheDoors — the secret appears exactly
+// once, in this output, and the door list is printed with it so an operator
+// sees what they granted at the moment they grant it.
+func TestServicesCreatePrintsTheSecretAndTheDoors(t *testing.T) {
+ fc := servicesCLIFixture(t)
+ buf := captureStdout(t)
+
+ RunServices([]string{"create", "-role", "publish", "-account", "bab", "amadan-hub"})
+
+ got := fc.lastCreateBody.Load().(struct{ Name, Role, App string })
+ if got.Name != "amadan-hub" || got.Role != "publish" || got.App != "" {
+ t.Fatalf("console saw create body = %+v", got)
+ }
+ out := buf.String()
+ tok := tokenFor(1)
+ if n := strings.Count(out, tok); n != 1 {
+ t.Fatalf("secret %q appeared %d times in stdout, want exactly 1:\n%s", tok, n, out)
+ }
+ if !strings.Contains(out, "shown once") {
+ t.Fatalf("stdout missing the shown-once warning:\n%s", out)
+ }
+ for _, want := range []string{
+ "POST", "/api/cli/apps/{account}/{app}/releases",
+ "/api/cli/apps/{account}/{app}/promote",
+ "GET", "/api/cli/apps",
+ } {
+ if !strings.Contains(out, want) {
+ t.Fatalf("stdout missing door %q:\n%s", want, out)
+ }
+ }
+ if !strings.Contains(out, "account-wide") {
+ t.Fatalf("stdout missing the account-wide scope label for an -app-less create:\n%s", out)
+ }
+}
+
+// TestServicesCreateAppScopedSaysSoNextToTheDoors — requirement 2: the door
+// list contains {app}-wildcarded patterns that read as "any app"; when the
+// credential is scoped to one app, RunServices must say so right where that
+// list is rendered, not just leave it to the -app flag the operator typed.
+func TestServicesCreateAppScopedSaysSoNextToTheDoors(t *testing.T) {
+ servicesCLIFixture(t)
+ buf := captureStdout(t)
+
+ RunServices([]string{"create", "-role", "publish", "-app", "titogo", "-account", "bab", "amadan-hub"})
+
+ out := buf.String()
+ if !strings.Contains(out, "titogo") {
+ t.Fatalf("stdout missing the app scope:\n%s", out)
+ }
+ if strings.Contains(out, "account-wide") {
+ t.Fatalf("stdout claims account-wide for an -app-scoped credential:\n%s", out)
+ }
+}
+
+// TestServicesCreateRefusesABadNameBeforeCalling — a shape the server is
+// certain to refuse should cost nothing, the principle apps.go's
+// validAppName states. Subprocess: the refusal is a log.Fatal, which would
+// kill an in-process test binary. Builds its env through carlosTestEnv (with
+// CARLOS_CREDENTIALS overridden to point at a REAL, reachable fake console)
+// and runs from a scratch cmd.Dir, so this proves the name gate runs before
+// any network call even when a call would otherwise succeed.
+func TestServicesCreateRefusesABadNameBeforeCalling(t *testing.T) {
+ bin := buildCarlos(t)
+ var reqs atomic.Int64
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ reqs.Add(1)
+ json.NewEncoder(w).Encode(map[string]any{})
+ }))
+ defer ts.Close()
+
+ creds := filepath.Join(t.TempDir(), "credentials")
+ writeTestCredential(t, creds, ts.URL)
+ scratch := t.TempDir()
+
+ cmd := exec.Command(bin, "services", "create", "-role", "publish", "-account", "bab", "Bad Name")
+ cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+creds)
+ cmd.Dir = scratch
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ t.Fatalf("exited 0, want nonzero (bad name refused):\n%s", out)
+ }
+ if !strings.Contains(string(out), "not a usable service credential name") {
+ t.Fatalf("output missing the name refusal:\n%s", out)
+ }
+ if n := reqs.Load(); n != 0 {
+ t.Fatalf("console recorded %d requests, want 0 — the name gate must run before any API call", n)
+ }
+}
+
+// TestRunServicesListPrintsEveryRow: `carlos services ls` prints one row per
+// credential, including a nameless legacy one, from the fake console's fixed
+// listing.
+func TestRunServicesListPrintsEveryRow(t *testing.T) {
+ servicesCLIFixture(t)
+ buf := captureStdout(t)
+
+ RunServices([]string{"ls", "-account", "bab"})
+
+ out := buf.String()
+ for _, want := range []string{"amadan-hub", "publish", "titogo", "instance", "router"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("services ls stdout missing %q:\n%s", want, out)
+ }
+ }
+}
+
+// TestServicesRotateSaysWhatRotationIsNot — rotation refuses the
+// credential's next call with the old secret; it does not stop work already
+// in flight, and it is not what a compromised credential needs. That is
+// `revoke`. The fleets CLI makes the same distinction in words and this must
+// too.
+func TestServicesRotateSaysWhatRotationIsNot(t *testing.T) {
+ servicesCLIFixture(t)
+ buf := captureStdout(t)
+
+ RunServices([]string{"rotate", "-account", "bab", "amadan-hub"})
+
+ out := buf.String()
+ tok := tokenFor(1)
+ if n := strings.Count(out, tok); n != 1 {
+ t.Fatalf("rotated secret appeared %d times, want exactly 1:\n%s", n, out)
+ }
+ if !strings.Contains(out, "shown once") {
+ t.Fatalf("stdout missing the shown-once warning:\n%s", out)
+ }
+ lower := strings.ToLower(out)
+ if !strings.Contains(lower, "not revocation") && !strings.Contains(lower, "is not revocation") {
+ t.Fatalf("stdout missing the ROTATION IS NOT REVOCATION wording:\n%s", out)
+ }
+ if !strings.Contains(out, "revoke") {
+ t.Fatalf("stdout missing the pointer to revoke for a COMPROMISED credential:\n%s", out)
+ }
+}
+
+// TestRunServicesRevokeMatchedTrue: an ordinary revoke of a live credential
+// prints an unambiguous "revoked" and exits 0.
+func TestRunServicesRevokeMatchedTrue(t *testing.T) {
+ fc := servicesCLIFixture(t)
+ fc.revokeMatched.Store(true)
+ buf := captureStdout(t)
+
+ RunServices([]string{"revoke", "-account", "bab", "amadan-hub"})
+
+ if got := fc.lastRevokeName.Load(); got != "amadan-hub" {
+ t.Fatalf("console saw revoke name %v, want amadan-hub", got)
+ }
+ if !strings.Contains(buf.String(), "revoked") {
+ t.Fatalf("stdout = %q, want it to confirm the revoke", buf.String())
+ }
+}
+
+// TestRunServicesRevokeMatchedFalseDoesNotPrintSuccess — the door used
+// during an incident. `matched: false` must not print as plain "revoked":
+// that is the exact failure this field was added to prevent (a typo'd name
+// reading as success). It must still exit 0 — the state the caller wanted
+// (no live credential of that name) is the state that holds.
+func TestRunServicesRevokeMatchedFalseDoesNotPrintSuccess(t *testing.T) {
+ fc := servicesCLIFixture(t)
+ fc.revokeMatched.Store(false)
+ buf := captureStdout(t)
+
+ RunServices([]string{"revoke", "-account", "bab", "typo-name"})
+
+ out := buf.String()
+ if strings.TrimSpace(out) == "" {
+ t.Fatal("revoke with matched=false printed nothing")
+ }
+ lower := strings.ToLower(out)
+ if lower == "revoked\n" || strings.HasPrefix(strings.TrimSpace(lower), "revoked typo-name") {
+ t.Fatalf("stdout reads as plain success for matched=false:\n%s", out)
+ }
+ if !strings.Contains(lower, "nothing to revoke") && !strings.Contains(lower, "no live credential") {
+ t.Fatalf("stdout missing the matched=false wording:\n%s", out)
+ }
+}
+
+// ---- subprocess: the usage-error / bad-argument paths ---------------------
+
+// TestServicesCLIUsageErrors drives every services verb with missing or
+// malshaped arguments as a real subprocess (buildCarlos, same helper
+// auth_e2e_test.go's TestAuthCLI and fleets_cli_test.go's
+// TestFleetsAndPlaceCLIUsageErrors use) — the ONLY way to observe a
+// log.Fatal/os.Exit refusal without killing the test binary. Every case
+// here must exit nonzero without ever reaching the network:
+// carlosTestEnv masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is
+// a scratch dir (CLAUDE.md's rule — a subprocess that inherited a real login
+// has minted real accounts on the flagship before), so a case that DID try
+// to dial out would fail on "not logged in", not silently succeed.
+func TestServicesCLIUsageErrors(t *testing.T) {
+ bin := buildCarlos(t)
+ scratch := t.TempDir()
+ env := carlosTestEnv(t)
+
+ run := func(args ...string) (string, error) {
+ cmd := exec.Command(bin, args...)
+ cmd.Env = env
+ cmd.Dir = scratch
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ for _, tc := range []struct {
+ name string
+ args []string
+ want string // substring the combined output must contain
+ }{
+ {"services no args", []string{"services"}, "usage: carlos services"},
+ {"services bogus verb", []string{"services", "bogus"}, "usage: carlos services"},
+ {"services create no args", []string{"services", "create"}, "usage: carlos services create"},
+ {"services create missing role", []string{"services", "create", "-account", "bab", "amadan-hub"}, "usage: carlos services create"},
+ {"services create bad role", []string{"services", "create", "-role", "wizard", "-account", "bab", "amadan-hub"}, "unknown role"},
+ {"services create bad name", []string{"services", "create", "-role", "publish", "-account", "bab", "Bad Name"}, "not a usable service credential name"},
+ {"services rotate no args", []string{"services", "rotate"}, "usage: carlos services rotate"},
+ {"services rotate bad name", []string{"services", "rotate", "-account", "bab", "Bad Name"}, "not a usable service credential name"},
+ {"services revoke no args", []string{"services", "revoke"}, "usage: carlos services revoke"},
+ {"services revoke bad handle", []string{"services", "revoke", "-account", "bab", "Bad Name"}, "not a usable"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ out, err := run(tc.args...)
+ if err == nil {
+ t.Fatalf("carlos %s exited 0, want a nonzero (usage/refusal) exit:\n%s", strings.Join(tc.args, " "), out)
+ }
+ if !strings.Contains(out, tc.want) {
+ t.Fatalf("carlos %s output missing %q:\n%s", strings.Join(tc.args, " "), tc.want, out)
+ }
+ })
+ }
+}
+
+// TestServicesDirectBucketModeRefusesWithServicesSpecificMessage: signed-out
+// / direct-bucket-mode shells get a services-specific fatal message, the
+// same posture fleetsSignedInSession takes for `carlos fleets`.
+func TestServicesDirectBucketModeRefusesWithServicesSpecificMessage(t *testing.T) {
+ bin := buildCarlos(t)
+ storeDir := filepath.Join(t.TempDir(), "store")
+ if err := os.MkdirAll(storeDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ scratch := t.TempDir()
+
+ cmd := exec.Command(bin, "services", "ls")
+ cmd.Env = carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir)
+ cmd.Dir = scratch
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ t.Fatalf("exited 0, want nonzero (direct bucket mode has no services door):\n%s", out)
+ }
+ if !strings.Contains(string(out), "services works through a console") {
+ t.Fatalf("output missing the services-specific direct-bucket-mode message:\n%s", out)
+ }
+}