Files
xarmian 665f1918a7 feat(cli): headless admin bootstrap via --email/--name/--password (BUG-988) (#761)
* feat(cli): headless admin bootstrap via --email/--name/--password (BUG-988)

Adds non-interactive flags to `pad auth setup` and `pad init` so agents
running inside Claude Code or other non-TTY environments can bootstrap a
fresh Pad instance without hitting interactive prompts that block forever.

- New flags --email, --name, --password on both commands; all three must
  be supplied together when any one is present (clear error naming the
  missing flag otherwise). Checked before the remote-mode guard so the
  headless path works on any server host — the loopback gate is enforced
  server-side.
- runHeadlessSetup() drives the existing POST /api/v1/auth/bootstrap
  endpoint directly, saves credentials, and respects --format json (emits
  a LoginResponse-shaped object with user + token). Already-initialized
  conflict produces a structured JSON error object under --format json.
- `pad init` slots headless bootstrap into the bootstrap step only; the
  rest of init (config, workspace creation, skill install) continues.
- Hardens readPassword() with an early non-TTY guard (generic message).
- Hardens promptAndBootstrap() with a bootstrap-specific non-TTY guard
  (points at --email/--name/--password flags) so --cli-prompt on a pipe
  exits immediately rather than blocking.
- Extends `pad init` non-TTY error message to mention the new flags.
- Five new tests in cmd/pad/setup_headless_test.go covering success,
  missing-flag validation, non-TTY guard, already-initialized conflict,
  and the full init flow including workspace creation.

NOTE: --password is visible in process listings (inherent to flag-based
injection). Env-var bootstrap (PAD_ADMIN_*) is the tracked follow-up.

Claude-Session: https://claude.ai/code/session_01WK9cUjxniBBAihGD5ygjDr

* fix(cli): thread bootstrap token, factor shared core, restore readPassword fallback

Round-1 codex findings:

1. Bootstrap token not sent on headless path (BLOCKER)
   Add BootstrapWithToken(email, name, password, token) to cli.Client that
   sets X-Bootstrap-Token when token is non-empty. Export ReadBootstrapToken
   from internal/cli/bootstrap.go (was readBootstrapToken) so cmd/pad can
   call it. Extract doHeadlessBootstrap(cfg, client, email, name, password)
   as the shared core for both setupCmd and padInitCmd: reads the on-disk
   token best-effort (absent → empty → loopback gate still covers that case),
   calls BootstrapWithToken, saves credentials, sets auth token on client.
   Both headless paths now go through this single function — no divergence.

2. readPassword bufio fallback removed by accident (REGRESSION)
   Restore the pre-round-1 bufio fallback in readPassword so piped-password
   flows (e.g. pad auth login --interactive in CI) keep working. The bootstrap
   wedge is already prevented by the top-of-promptAndBootstrap TTY guard; the
   generic readPassword fallback is only reached by non-bootstrap callers.

3. Shared core (CLEANUP)
   padInitCmd now calls doHeadlessBootstrap instead of duplicating Bootstrap +
   saveCredentials + SetAuthToken. The --format json asymmetry (init vs setup)
   is resolved by design: pad init is a multi-step flow; for machine-readable
   bootstrap output agents should use `pad auth setup --email … --format json`.
   Documented in the inline comment on the headless branch in padInitCmd.

Tests added: TestHeadlessSetupSendsBootstrapToken, TestHeadlessSetupNoTokenFileOK,
TestReadPasswordFallback. Update internal/cli/bootstrap_test.go for the rename.

Claude-Session: https://claude.ai/code/session_01WK9cUjxniBBAihGD5ygjDr

* BUG-988 round-2: surface token-read errors, wrap 403 with hint, rescope readPassword test

doHeadlessBootstrap: distinguish os.ErrNotExist (absent token → best-effort
empty, proceed without header) from other read errors (permissions, etc.
→ surface with the file path so operators can diagnose rather than silently
hitting a confusing 403). Wrap 403/forbidden from BootstrapWithToken with an
actionable multi-bullet hint covering loopback gate, token-file path, and
PAD_BYPASS_SETUP_TOKEN.

ReadBootstrapToken (internal/cli/bootstrap.go): add %w to the ErrNotExist
branch so errors.Is(err, os.ErrNotExist) propagates to callers; existing
tests and --cli-prompt hint text preserved.

TestReadPasswordFallback → TestReadPasswordBufioFallback: rescoped to assert
readPassword isolation only; added comment citing BUG-1886 (pre-existing
doInteractiveLogin double-bufio.Reader bug). BUG-1886 filed in docapp.
2026-06-24 10:50:33 -04:00

254 lines
11 KiB
Go

package cli
// Browser-driven first-admin bootstrap (TASK-1216 / IDEA-1179).
//
// `pad auth setup` and `pad init` previously prompted for email / name /
// password in the terminal, calling POST /api/v1/auth/bootstrap directly.
// That worked but lost out to the browser /setup flow on every UX axis:
// no password manager, no HTML5 email validation, no live strength meter.
//
// TASK-1167 / PLAN-1166 shipped a logs-token bootstrap so Docker / Unraid
// operators can claim the first admin from a remote browser. That same
// token sits at <DataDir>/.bootstrap-token on a local install, and the
// CLI runs as the same UID as the server, so we can read it directly and
// hand the operator a deep link into the same browser flow.
//
// RunBrowserBootstrap is the helper. It checks /api/v1/auth/session, reads
// the token if one is configured, prints the /setup URL, and polls until
// the session check reports setup_required: false (or 5 minutes elapses).
// Caller (setupCmd / pad init) is responsible for wiring up SIGINT to the
// passed-in context and for any post-bootstrap login plumbing.
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/fatih/color"
"github.com/PerpetualSoftware/pad/internal/config"
)
// bootstrapTokenFilename mirrors internal/server/bootstrap.go's constant of
// the same name. Kept in sync rather than imported because internal/cli
// otherwise has no dependency on internal/server, and the filename is a
// trivial stable contract — both places own it together. If this ever
// drifts, the symptom is "bootstrap token file not found" on a server that
// did generate one, which is an immediate, loud failure.
const bootstrapTokenFilename = ".bootstrap-token"
// bootstrapPollInterval is how often the helper re-checks /api/v1/auth/session
// once the URL has been printed. 2s matches doBrowserLogin's CLI auth poll
// cadence — slow enough to not hammer the server, fast enough that the
// "✓ Setup complete" line lands within a couple seconds of the operator
// finishing the form. var (not const) so the test suite can shrink it for
// timing-sensitive assertions without making real users wait minutes.
var bootstrapPollInterval = 2 * time.Second
// bootstrapPollTimeout caps how long RunBrowserBootstrap waits for the
// browser side to finish. It MUST stay >= the server's setup-session TTL
// (cliAuthSetupSessionTTL, 20m in internal/store/cli_auth_sessions.go):
// the unified setup handoff (BUG-1843) pre-creates a 20-minute CLI auth
// session, so the terminal must keep polling for setup at least that long
// — otherwise a user who takes >5m on the admin form would have the CLI
// give up here while the browser session is still valid, exactly the
// half-fixed expiry class this aligns away. Still finite so an abandoned
// terminal doesn't wait forever; the caller's SIGINT path cuts it short
// via ctx. var (not const) so tests can exercise the timeout branch in
// milliseconds.
var bootstrapPollTimeout = 20 * time.Minute
// RunBrowserBootstrap walks the operator through the browser-based first-
// admin bootstrap. Returns nil on success (server has flipped to
// setup_required: false), an error if the helper can't proceed (no token
// configured, token file unreadable, timeout, ctx cancelled).
//
// Callers in this PR: only setupCmd (`pad auth setup`). TASK-1217 adds
// `pad init` as the second caller — until that lands, `pad init` keeps
// using the legacy promptAndBootstrap path. Splitting the wiring across
// two PRs is deliberate (CONVE-2 "tasks should be PR-sized").
//
// On success the server has a first admin but the CLI has not been issued
// any credentials — the browser owns the session cookie. Callers that
// want the CLI to be authenticated afterwards should chain a CLI-auth-
// session login (see doBrowserLogin in cmd/pad/main.go) once this returns.
//
// next, when non-empty, is a local path the /setup page navigates to after
// the admin account is created (instead of dropping the operator at the
// console). Callers pass "/auth/cli/<code>" for a pre-created CLI auth
// session so account creation flows straight into the CLI-authorize step
// in the SAME browser tab — no second URL to copy back in the terminal
// (BUG-1843).
//
// The helper is idempotent: if the server already reports
// setup_required: false on entry, it returns nil immediately without
// touching the token file or printing anything. That matters for any
// caller invoking it against a server where setup is already done.
func RunBrowserBootstrap(ctx context.Context, client *Client, cfg *config.Config, next string) error {
if client == nil {
return errors.New("RunBrowserBootstrap: nil client")
}
if cfg == nil {
return errors.New("RunBrowserBootstrap: nil config")
}
session, err := client.CheckSession()
if err != nil {
return fmt.Errorf("check server status: %w", err)
}
if !session.SetupRequired {
// Already bootstrapped — nothing to do.
return nil
}
setupURL, err := buildBootstrapURL(cfg, session.SetupMethod, next)
if err != nil {
return err
}
bold := color.New(color.Bold).SprintFunc()
fmt.Println()
fmt.Println(" Open this URL in your browser to finish setup:")
fmt.Println()
fmt.Printf(" %s\n", bold(setupURL))
fmt.Println()
fmt.Println(" Waiting for setup to complete (Ctrl+C to cancel)...")
if err := pollUntilSetupDone(ctx, client); err != nil {
return err
}
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf(" %s Setup complete\n", green("✓"))
return nil
}
// buildBootstrapURL constructs the /setup URL the operator should open.
//
// setup_method dispatch (kept in sync with handleSessionCheck in
// internal/server/handlers_auth.go):
//
// - "logs_token" — server generated a one-time token and persisted it to
// <DataDir>/.bootstrap-token. We read it and hand the operator a deep
// link with the token in the URL fragment (#token=...). The fragment
// is scrubbed from the address bar by /setup's onMount before paint
// so the secret doesn't survive in browser history (TASK-1167 F10).
//
// - "open" — operator started the server with PAD_BYPASS_SETUP_TOKEN=true
// on a self-host deployment. The /setup form works directly, no token
// needed. We just print the bare /setup URL.
//
// - "local_cli" or "" — the server failed to provision a bootstrap token
// (read-only DataDir, etc.) and the bypass flag isn't set, so the only
// working path is the loopback-gated POST /api/v1/auth/bootstrap. The
// browser flow can't proceed; tell the user to use --cli-prompt.
//
// - anything else — newer server speaking a method this CLI doesn't know.
// Bail loudly with the same --cli-prompt fallback hint.
func buildBootstrapURL(cfg *config.Config, setupMethod, next string) (string, error) {
base := cfg.BrowserURL()
// The next= handoff target rides as a query param, which MUST sit
// before any #token fragment (a query after the fragment would be
// parsed as part of the fragment and never reach the page's
// searchParams). url.QueryEscape keeps the leading slash and any
// nested path safe to round-trip through the address bar.
query := ""
if next != "" {
query = "?next=" + url.QueryEscape(next)
}
switch setupMethod {
case "logs_token":
token, err := ReadBootstrapToken(cfg.DataDir)
if err != nil {
return "", err
}
return fmt.Sprintf("%s/setup%s#token=%s", base, query, token), nil
case "open":
return base + "/setup" + query, nil
case "", "local_cli":
return "", fmt.Errorf("server has no bootstrap token configured (setup_method=%q); re-run with --cli-prompt to use the legacy TTY flow", setupMethod)
default:
return "", fmt.Errorf("server reported unknown setup_method=%q; this CLI may be older than the server. Re-run with --cli-prompt to use the legacy TTY flow", setupMethod)
}
}
// ReadBootstrapToken reads <DataDir>/.bootstrap-token. The file is created
// by EnsureBootstrapToken in internal/server/bootstrap.go with mode 0600
// and contains the base64url-encoded token followed by a trailing newline.
//
// Errors are wrapped with the absolute path so the operator can find the
// file (or confirm it's actually missing) without guessing where DataDir
// resolves to. The "--cli-prompt" hint is appended because that's the
// recoverable fallback for every failure mode here (file consumed already,
// permissions wrong, DataDir on a read-only mount).
//
// Callers that want a best-effort token (absent → empty, not an error) should
// ignore the returned error and use the token only when non-empty.
func ReadBootstrapToken(dataDir string) (string, error) {
path := filepath.Join(dataDir, bootstrapTokenFilename)
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// Wrap with %w so callers can errors.Is(err, os.ErrNotExist) to
// distinguish "file absent" from other read failures (e.g. to
// treat absence as best-effort rather than a hard error).
return "", fmt.Errorf("bootstrap token file %s not found (%w) — the server may have already consumed it, or token generation failed at startup. Re-run with --cli-prompt to use the legacy TTY flow", path, os.ErrNotExist)
}
return "", fmt.Errorf("read bootstrap token %s: %w (re-run with --cli-prompt to use the legacy TTY flow)", path, err)
}
token := strings.TrimSpace(string(data))
if token == "" {
return "", fmt.Errorf("bootstrap token file %s is empty; delete it and restart the server, or re-run with --cli-prompt to use the legacy TTY flow", path)
}
return token, nil
}
// pollUntilSetupDone tickets every bootstrapPollInterval and returns nil
// the first time CheckSession reports setup_required: false. Returns
// ctx.Err() on caller cancellation, a wrapped timeout error after
// bootstrapPollTimeout elapses inside the helper. Transient CheckSession
// errors are tolerated — we keep polling, since a momentary network blip
// during the human form-filling window is almost always recoverable.
// Only ctx.Done() and the timeout end the loop.
//
// The internal timeout is a separate timer rather than a wrapped
// context.WithTimeout so caller-ctx cancellation surfaces as ctx.Err()
// (DeadlineExceeded or Canceled) instead of being misreported as the
// helper's own 5-minute timeout.
func pollUntilSetupDone(ctx context.Context, client *Client) error {
timeout := time.NewTimer(bootstrapPollTimeout)
defer timeout.Stop()
ticker := time.NewTicker(bootstrapPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-timeout.C:
return fmt.Errorf("timed out waiting for setup after %s. Re-run when finished, or use --cli-prompt for the legacy TTY flow", bootstrapPollTimeout)
case <-ticker.C:
session, err := client.CheckSession()
if err != nil {
// Transient — keep polling. A stable disconnect will surface as
// the timeout above; a one-off will recover on the next tick.
continue
}
if !session.SetupRequired {
return nil
}
}
}
}