Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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 <req-id>` | Client delivery report (`--html`, `--internal`) |
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <requirement>` | Estimate cost (`--quick`, `--json`, `--rate`, `--save`) |
Expand Down Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions internal/cli/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down
55 changes: 55 additions & 0 deletions internal/cli/dashboard_daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)

func TestDashboardStatus_NotRunning(t *testing.T) {
Expand Down Expand Up @@ -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)
}
}
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
}
}
Expand Down
3 changes: 3 additions & 0 deletions internal/state/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions internal/state/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
67 changes: 58 additions & 9 deletions internal/web/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"
"sync"
"time"
)

// TokenCookieName is the cookie set on the first authenticated request so
Expand All @@ -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 {
Expand Down
Loading
Loading