diff --git a/CLAUDE.md b/CLAUDE.md index 4258fb2..d4b8727 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,6 +163,7 @@ dashboard: auto_start: true # `vxd req` forks a detached `vxd dashboard --web` daemon (or reuses one running) auto_open: false # off by default; URL still printed. true = also open the user's default browser; auto-detect headless (SSH, no DISPLAY, non-TTY) port: 8787 # web server port; daemon pidfile at ~/.vxd/dashboard.pid, bootstrap nonce at ~/.vxd/dashboard.bootstrap (0o600) + token_ttl_hours: 168 # rotate ~/.vxd/dashboard.token at startup when older than this (0 = default 168 = 7 days; negative = never); manual: vxd dashboard rotate-token improve: enabled: false # EXPERIMENTAL self-improve pipeline gate — vxd-improve is a no-op (exit 0) unless true or VXD_IMPROVE_ENABLED=1 ``` @@ -179,6 +180,7 @@ improve: | `vxd dashboard` | TUI dashboard (`--web` for browser version). Web mode supports `--pidfile` and `--bootstrap-file` for the daemon path. | | `vxd dashboard status` | Show whether the always-on dashboard daemon is running (PID, port, URL). | | `vxd dashboard stop` | Stop the always-on dashboard daemon (SIGTERM, removes pidfile, idempotent). | +| `vxd dashboard rotate-token` | Mint a fresh dashboard bearer token (replaces `~/.vxd/dashboard.token`, emits `DASHBOARD_TOKEN_ROTATED`); restart a running daemon to complete the rotation. Tokens also auto-rotate at web-dashboard startup when older than `dashboard.token_ttl_hours` (default 168 = 7 days). | | `vxd metrics` | Success rates, timing, escalations, SLA breaches per requirement | | `vxd estimate "req"` | Cost estimation with `--quick`, `--json`, `--rate` | | `vxd report ` | Client delivery report (`--html`, `--internal`) | diff --git a/README.md b/README.md index b218e1a..7cd742e 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,7 @@ vhs docs/demo.tape | `vxd dashboard --web [--port 8787]` | Launch the web dashboard (browser-based, default port 8787). Supports `--pidfile` and `--bootstrap-file` for the always-on daemon path. | | `vxd dashboard status` | Show whether the always-on dashboard daemon is running (PID, port, URL). | | `vxd dashboard stop` | SIGTERM the always-on dashboard daemon and remove its pidfile (idempotent). | +| `vxd dashboard rotate-token` | Mint a fresh dashboard bearer token (replaces `~/.vxd/dashboard.token`); restart a running daemon to complete rotation. Tokens also auto-rotate at startup when older than `dashboard.token_ttl_hours`. | | `vxd watch [req-id]` | Terminal-friendly always-on status: tails events for one requirement (defaults to the newest in the current repo) until terminal status or Ctrl+C. | | `vxd preflight` | Run pre-flight environment checks (19 checks, 3 severity tiers) | | `vxd estimate ` | Estimate cost (`--quick`, `--json`, `--rate`, `--save`) | @@ -403,7 +404,7 @@ Run `vxd init` to generate `vxd.yaml` with sensible defaults, then customize: | `notify` | Outbound Slack webhook URL and per-event triggers. A configured webhook always sends `PIPELINE_STALLED` (human-intervention signal); `notify_on_sla` additionally sends `STORY_SLA_BREACHED`; `notify_on_complete` additionally sends terminal requirement outcomes (`REQ_COMPLETED` / `REQ_BLOCKED`) | Disabled by default (empty `slack_webhook_url`); both flags `false` | | `autoresearch` | Per-repo Karpathy-style experiment loop: metric command, editable_paths allowlist, gate (`auto`/`winning`/`pr`), experiment budget, and Bayesian sampler | Disabled by default (`enabled: false`); requires `metric.command` and `editable_paths` when enabled | | `devdb` | Per-story ephemeral Postgres: backend (`ghost`/`docker`/`null`), template DB to fork from, on-failure retention policy, and provider-specific settings | Disabled by default (`provider: null`); requires `template` when enabled. See "Ephemeral Databases" section below. | -| `dashboard` | Always-on status surface: `auto_start` (fork the web dashboard daemon on `vxd req`), `auto_open` (try to open a browser), and `port` (web server port). Auto-open is suppressed automatically on SSH sessions, headless Linux hosts, or when stdout is not a TTY. | `auto_start: true`, `auto_open: false`, `port: 8787`. Override per run with `--no-dashboard`. | +| `dashboard` | Always-on status surface: `auto_start` (fork the web dashboard daemon on `vxd req`), `auto_open` (try to open a browser), `port` (web server port), and `token_ttl_hours` (auto-rotate the bearer token at startup when the token file is older than this; negative disables; manual rotation via `vxd dashboard rotate-token`). Auto-open is suppressed automatically on SSH sessions, headless Linux hosts, or when stdout is not a TTY. | `auto_start: true`, `auto_open: false`, `port: 8787`, `token_ttl_hours: 168`. Override per run with `--no-dashboard`. | | `improve` | **Experimental** self-improvement pipeline gate. The daily `vxd-improve` run is a no-op unless `enabled: true` (or `VXD_IMPROVE_ENABLED=1`) — the pipeline has produced 0 code-actionable findings to date and is retained as research scaffolding, not a shipping capability. | `enabled: false` | ## Ephemeral Databases diff --git a/internal/cli/dashboard.go b/internal/cli/dashboard.go index 73cb4af..f7b6568 100644 --- a/internal/cli/dashboard.go +++ b/internal/cli/dashboard.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "log" "net/http" "os" "os/signal" @@ -38,9 +39,77 @@ func newDashboardCmd() *cobra.Command { cmd.AddCommand(newDashboardStatusCmd()) cmd.AddCommand(newDashboardStopCmd()) + cmd.AddCommand(newDashboardRotateTokenCmd()) return cmd } +// newDashboardRotateTokenCmd mints a fresh dashboard bearer token, replacing +// ~/.vxd/dashboard.token, and records a DASHBOARD_TOKEN_ROTATED event. +func newDashboardRotateTokenCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "rotate-token", + Short: "Rotate the dashboard bearer token", + Long: `Generate a fresh dashboard bearer token and replace the token file +(~/.vxd/dashboard.token, mode 0o600). The previous token stops working for +new sessions immediately; a RUNNING dashboard daemon keeps its in-memory +token until restarted — run 'vxd dashboard stop' and let the next 'vxd req' +respawn it (or start it manually) to complete the rotation. + +Tokens also rotate automatically at web-dashboard startup when the file is +older than dashboard.token_ttl_hours (default 168 = 7 days).`, + RunE: runDashboardRotateToken, + SilenceUsage: true, + } + cmd.Flags().String("token-file", "", "Override the token file path (default ~/.vxd/dashboard.token)") + return cmd +} + +func runDashboardRotateToken(cmd *cobra.Command, _ []string) error { + path, _ := cmd.Flags().GetString("token-file") + if path == "" { + path = web.DefaultTokenPath() + } + tok, err := web.RotateTokenFile(path) + if err != nil { + return fmt.Errorf("rotate dashboard token: %w", err) + } + + // Audit trail — best-effort: rotation succeeded even if the event store + // is unavailable (e.g. outside a project dir). + if s, err := loadStores(cmd); err == nil { + evt := state.NewEvent(state.EventDashboardTokenRotated, "cli", "", map[string]any{ + "reason": "manual", + "token_path": path, + }) + if aerr := s.Events.Append(evt); aerr != nil { + log.Printf("dashboard token rotation event append: %v", aerr) + } else if perr := s.Proj.Project(evt); perr != nil { + log.Printf("dashboard token rotation event project: %v", perr) + } + s.Close() + } + + out := cmd.OutOrStdout() + fmt.Fprintln(out, "Dashboard token rotated.") + fmt.Fprintf(out, "New token: %s\n", tok) + fmt.Fprintf(out, "Token file: %s\n", path) + fmt.Fprintln(out, "If a dashboard daemon is running, restart it to pick up the new token: vxd dashboard stop") + return nil +} + +// dashboardTokenTTL maps dashboard.token_ttl_hours to the rotation duration: +// 0 selects the 168h (7 day) default; negative disables rotation. +func dashboardTokenTTL(hours int) time.Duration { + switch { + case hours == 0: + return 168 * time.Hour + case hours < 0: + return 0 + default: + return time.Duration(hours) * time.Hour + } +} + // newDashboardStatusCmd prints a one-line summary of the running dashboard // daemon, or "not running" if none is detected. Pure read-only operation — // no file is created or removed. @@ -106,6 +175,7 @@ func runDashboard(cmd *cobra.Command, _ []string) error { srv.NoOpen = noOpen srv.Pidfile = pidfile srv.BootstrapFile = bootstrapFile + srv.TokenTTL = dashboardTokenTTL(projectRuntimeConfig(s).Dashboard.TokenTTLHours) if err := srv.Start(ctx); err != nil && err != http.ErrServerClosed { return fmt.Errorf("web server: %w", err) } diff --git a/internal/cli/dashboard_daemon_test.go b/internal/cli/dashboard_daemon_test.go index 8ee9c2c..6b04f65 100644 --- a/internal/cli/dashboard_daemon_test.go +++ b/internal/cli/dashboard_daemon_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func TestDashboardStatus_NotRunning(t *testing.T) { @@ -96,3 +97,57 @@ func TestRunWatch_UnknownRequirement(t *testing.T) { t.Fatalf("expected get-requirement error for unknown req, got %v", err) } } + +// TestDashboardRotateTokenCmd pins the manual rotation command +// (WEAKNESSES.md P0-04): the token file is replaced with a fresh 0o600 +// token, the new token is printed, and the operator is told to restart a +// running daemon. +func TestDashboardRotateTokenCmd(t *testing.T) { + tokenFile := filepath.Join(t.TempDir(), "dashboard.token") + if err := os.WriteFile(tokenFile, []byte("old-token\n"), 0o600); err != nil { + t.Fatal(err) + } + + cmd := newDashboardRotateTokenCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{"--token-file", tokenFile}) + if err := cmd.Execute(); err != nil { + t.Fatalf("rotate-token: %v", err) + } + + data, err := os.ReadFile(tokenFile) + if err != nil { + t.Fatal(err) + } + newTok := strings.TrimSpace(string(data)) + if newTok == "old-token" { + t.Fatal("token file was not replaced") + } + if len(newTok) != 64 { + t.Fatalf("expected 32-byte hex token, got %d chars", len(newTok)) + } + if fi, _ := os.Stat(tokenFile); fi.Mode().Perm() != 0o600 { + t.Errorf("token file perms = %o, want 600", fi.Mode().Perm()) + } + if !strings.Contains(out.String(), newTok) { + t.Error("command must print the new token") + } + if !strings.Contains(out.String(), "vxd dashboard stop") { + t.Error("command must tell the operator to restart a running daemon") + } +} + +// TestDashboardTokenTTL pins the token_ttl_hours mapping: 0 → 168h default, +// negative → disabled, positive → verbatim. +func TestDashboardTokenTTL(t *testing.T) { + if got := dashboardTokenTTL(0); got != 168*time.Hour { + t.Errorf("default = %v, want 168h", got) + } + if got := dashboardTokenTTL(-1); got != 0 { + t.Errorf("negative must disable rotation, got %v", got) + } + if got := dashboardTokenTTL(24); got != 24*time.Hour { + t.Errorf("verbatim = %v, want 24h", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 6e01d3c..4eb8089 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,6 +50,13 @@ type DashboardConfig struct { AutoStart bool `yaml:"auto_start"` // default true AutoOpen bool `yaml:"auto_open"` // default true Port int `yaml:"port"` // default 8787 + // TokenTTLHours rotates the persistent dashboard bearer token at web- + // dashboard startup when the token file is older than this many hours. + // A leaked token therefore expires instead of granting access until + // someone manually deletes ~/.vxd/dashboard.token. 0 uses the default + // (168 = 7 days); negative disables rotation. Manual rotation: + // `vxd dashboard rotate-token`. + TokenTTLHours int `yaml:"token_ttl_hours,omitempty"` } // SecretsConfig configures the secrets provider. diff --git a/internal/config/loader.go b/internal/config/loader.go index d3da05e..b61143a 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -135,9 +135,10 @@ func DefaultConfig() Config { }, }, Dashboard: DashboardConfig{ - AutoStart: true, - AutoOpen: false, - Port: 8787, + AutoStart: true, + AutoOpen: false, + Port: 8787, + TokenTTLHours: 168, // 7 days — a leaked token expires instead of living forever }, } } diff --git a/internal/state/events.go b/internal/state/events.go index e6b599d..35f4cea 100644 --- a/internal/state/events.go +++ b/internal/state/events.go @@ -96,6 +96,9 @@ const ( // Pipeline-health events. EventPipelineStalled EventType = "PIPELINE_STALLED" // unfinished stories exist but none are dispatchable (all tiers exhausted) + // Operational security events. + EventDashboardTokenRotated EventType = "DASHBOARD_TOKEN_ROTATED" // dashboard bearer token rotated (TTL expiry or `vxd dashboard rotate-token`) + // Autoresearch harness events. See docs/superpowers/specs/2026-05-02-autoresearch-harness-design.md. EventBaselineMeasured EventType = "BASELINE_MEASURED" EventExperimentProposed EventType = "EXPERIMENT_PROPOSED" diff --git a/internal/state/sqlite.go b/internal/state/sqlite.go index 05d5c31..8392d75 100644 --- a/internal/state/sqlite.go +++ b/internal/state/sqlite.go @@ -243,6 +243,10 @@ func (s *SQLiteStore) Project(evt Event) error { // Informational: standalone scan results + knowledge-base growth are // recorded in the event log; no projection state to mutate. return nil + case EventDashboardTokenRotated: + // Informational: token rotations are recorded in the event log for the + // audit trail; no projection state to mutate. + return nil case EventStoryPRCreated: return s.projectStoryPRCreated(evt.StoryID, payload) case EventStoryMerged: diff --git a/internal/web/auth.go b/internal/web/auth.go index c98d17e..2fd16b0 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "sync" + "time" ) // TokenCookieName is the cookie set on the first authenticated request so @@ -35,34 +36,82 @@ var authBypassedPaths = map[string]struct{}{ // 2. Existing token file (mode 0o600). // 3. Newly generated 32-byte hex token, written to the file with 0o600. // -// An empty path skips the file fallback (used by tests). +// An empty path skips the file fallback (used by tests). No TTL rotation — +// see LoadOrGenerateTokenWithTTL. func LoadOrGenerateToken(path string) (string, error) { + tok, _, err := LoadOrGenerateTokenWithTTL(path, 0) + return tok, err +} + +// LoadOrGenerateTokenWithTTL is LoadOrGenerateToken plus age-based rotation +// (WEAKNESSES.md P0-04): when ttl > 0 and the token file is older than ttl, +// a fresh token is minted and written, invalidating the stale one. rotated +// reports whether that happened so callers can log / emit +// DASHBOARD_TOKEN_ROTATED. The VXD_DASHBOARD_TOKEN env override is +// operator-managed and never rotated. ttl <= 0 disables rotation. +func LoadOrGenerateTokenWithTTL(path string, ttl time.Duration) (token string, rotated bool, err error) { if t := strings.TrimSpace(os.Getenv("VXD_DASHBOARD_TOKEN")); t != "" { - return t, nil + return t, false, nil } if path != "" { if data, err := os.ReadFile(path); err == nil { tok := strings.TrimSpace(string(data)) if tok != "" { - return tok, nil + if ttl > 0 { + if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > ttl { + newTok, werr := RotateTokenFile(path) + if werr != nil { + return "", false, werr + } + return newTok, true, nil + } + } + return tok, false, nil } } } tok, err := generateToken() if err != nil { - return "", err + return "", false, err } if path != "" { - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return "", fmt.Errorf("create token dir: %w", err) - } - if err := os.WriteFile(path, []byte(tok+"\n"), 0o600); err != nil { - return "", fmt.Errorf("write token file: %w", err) + if werr := writeTokenFile(path, tok); werr != nil { + return "", false, werr } } + return tok, false, nil +} + +// RotateTokenFile unconditionally mints a fresh token and replaces the file +// (mode 0o600). Used by TTL expiry and the `vxd dashboard rotate-token` +// command. A running daemon keeps its in-memory token until restarted — +// callers should tell the operator to restart it. +func RotateTokenFile(path string) (string, error) { + tok, err := generateToken() + if err != nil { + return "", err + } + if err := writeTokenFile(path, tok); err != nil { + return "", err + } return tok, nil } +func writeTokenFile(path, tok string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create token dir: %w", err) + } + if err := os.WriteFile(path, []byte(tok+"\n"), 0o600); err != nil { + return fmt.Errorf("write token file: %w", err) + } + return nil +} + +// DefaultTokenPath exposes the canonical dashboard token location +// (~/.vxd/dashboard.token) for CLI commands like `vxd dashboard +// rotate-token`. +func DefaultTokenPath() string { return defaultDashboardTokenPath() } + func generateToken() (string, error) { var b [32]byte if _, err := rand.Read(b[:]); err != nil { diff --git a/internal/web/auth_rotation_test.go b/internal/web/auth_rotation_test.go new file mode 100644 index 0000000..195c6a7 --- /dev/null +++ b/internal/web/auth_rotation_test.go @@ -0,0 +1,101 @@ +package web + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestAuth_TokenRotatesAfterTTL pins the age-based rotation contract +// (WEAKNESSES.md P0-04): a token file older than the TTL is replaced with a +// fresh token at load; a fresh file is kept; ttl<=0 never rotates; the +// VXD_DASHBOARD_TOKEN env override is never rotated. +func TestAuth_TokenRotatesAfterTTL(t *testing.T) { + t.Setenv("VXD_DASHBOARD_TOKEN", "") // isolate from operator env + + path := filepath.Join(t.TempDir(), "dashboard.token") + + // First load mints a token. + original, rotated, err := LoadOrGenerateTokenWithTTL(path, 168*time.Hour) + if err != nil { + t.Fatal(err) + } + if rotated { + t.Error("first mint must not report rotated") + } + if len(original) != 64 { + t.Fatalf("expected 32-byte hex token, got %d chars", len(original)) + } + + // Fresh file within TTL: same token back. + same, rotated, err := LoadOrGenerateTokenWithTTL(path, 168*time.Hour) + if err != nil || rotated || same != original { + t.Fatalf("fresh token must be kept: tok match=%v rotated=%v err=%v", same == original, rotated, err) + } + + // Age the file past the TTL: rotation. + old := time.Now().Add(-169 * time.Hour) + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + fresh, rotated, err := LoadOrGenerateTokenWithTTL(path, 168*time.Hour) + if err != nil { + t.Fatal(err) + } + if !rotated { + t.Fatal("stale token must rotate") + } + if fresh == original { + t.Fatal("rotation must mint a NEW token") + } + // The file now holds the new token with 0o600. + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(data)) != fresh { + t.Error("token file not replaced with the fresh token") + } + if fi, _ := os.Stat(path); fi.Mode().Perm() != 0o600 { + t.Errorf("token file perms = %o, want 600", fi.Mode().Perm()) + } + + // ttl<=0 disables rotation even for an ancient file. + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + kept, rotated, err := LoadOrGenerateTokenWithTTL(path, 0) + if err != nil || rotated || kept != fresh { + t.Fatalf("ttl=0 must never rotate: rotated=%v err=%v", rotated, err) + } + + // Env override is operator-managed — returned verbatim, never rotated. + t.Setenv("VXD_DASHBOARD_TOKEN", "operator-token") + envTok, rotated, err := LoadOrGenerateTokenWithTTL(path, time.Nanosecond) + if err != nil || rotated || envTok != "operator-token" { + t.Fatalf("env override must win untouched: tok=%q rotated=%v err=%v", envTok, rotated, err) + } +} + +// TestRotateTokenFile pins unconditional rotation used by +// `vxd dashboard rotate-token`. +func TestRotateTokenFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "dashboard.token") + first, err := RotateTokenFile(path) + if err != nil { + t.Fatal(err) + } + second, err := RotateTokenFile(path) + if err != nil { + t.Fatal(err) + } + if first == second { + t.Fatal("each rotation must mint a distinct token") + } + data, _ := os.ReadFile(path) + if strings.TrimSpace(string(data)) != second { + t.Error("file must hold the latest token") + } +} diff --git a/internal/web/server.go b/internal/web/server.go index faf121a..02de176 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -48,6 +48,10 @@ type Server struct { // single-use bootstrap nonce there with mode 0o600 so other CLI // processes can build a one-shot dashboard URL without scraping logs. BootstrapFile string + // TokenTTL, when > 0, rotates the persistent dashboard token at startup + // if the token file is older than this duration (dashboard.token_ttl_hours, + // default 168h). Zero/negative disables rotation. + TokenTTL time.Duration // rotator is captured during Start so the loopback /internal/bootstrap // endpoint can mint fresh per-tab nonces against the live auth state. @@ -112,10 +116,14 @@ func (s *Server) Start(ctx context.Context) error { if tokenPath == "" { tokenPath = defaultDashboardTokenPath() } - token, err := LoadOrGenerateToken(tokenPath) + token, rotated, err := LoadOrGenerateTokenWithTTL(tokenPath, s.TokenTTL) if err != nil { return fmt.Errorf("dashboard token: %w", err) } + if rotated { + log.Printf("Dashboard token was older than %s — rotated (previous token is now invalid)", s.TokenTTL) + s.emitTokenRotated("ttl_expired", tokenPath) + } nonce, err := generateToken() if err != nil { return fmt.Errorf("dashboard bootstrap nonce: %w", err) @@ -303,6 +311,27 @@ func redactTokenForLog(token string) string { return token } +// emitTokenRotated records a DASHBOARD_TOKEN_ROTATED event (best-effort — +// a store failure logs and never blocks dashboard startup). +func (s *Server) emitTokenRotated(reason, tokenPath string) { + if s.eventStore == nil { + return + } + evt := state.NewEvent(state.EventDashboardTokenRotated, "dashboard", "", map[string]any{ + "reason": reason, + "token_path": tokenPath, + }) + if err := s.eventStore.Append(evt); err != nil { + log.Printf("dashboard token rotation event append: %v", err) + return + } + if s.projStore != nil { + if err := s.projStore.Project(evt); err != nil { + log.Printf("dashboard token rotation event project: %v", err) + } + } +} + // defaultDashboardTokenPath returns the on-disk location of the bearer // token. We persist under the user's HOME so multiple `vxd dashboard` // invocations share one token across sessions (avoids printing a fresh