Files
Snow Lee (Sungwon)andGitHub 5f1ac98dae feat(hub): see what each agent session read, not just what it changed (BEA-98) (#135)
History showed what an agent run CHANGED. What it read lived in a daily
aggregate with no session dimension, so the two could not be joined and
nobody could answer "when my agent answered, what did it look at — and was
it the fresh version or archive/retired-spec.md?".

The join is one string carried through four places: hook -> spool -> hub ->
run card. A run card now marks each change the run also read, lists the
files it read and never touched, and says on screen why a read can be
missing.

The three landmines the issue asks be named here:

1. Op.Note is USER-SETTABLE (`bdrive sync --note`), so joining reads to
   writes on the note string would let any member with write access forge a
   note that collides with a teammate's run card and hang their reads off
   it. Fixed by adding journal.Op.Session — set only by `bdrive sync
   --hook`, never by --note — and joining on that. The note stays settable
   and stays untrusted; the join simply never reads it. Op.Session is
   additive JSONL and, like Mtime, is never an input to Less or Replay, so
   replay determinism is untouched and older ops carry "".

   The read half has the same hole one step further on: POST /reads takes
   the session id from the CLIENT, so a member could report reads under a
   teammate's session and paint files onto their card. Every session row is
   therefore pinned to the ownsDevice-validated device, and the query
   requires ?session= AND ?device= together — a forged row can only be found
   under the forger's own device, which MayActAs guarantees is never
   somebody else's.

2. BUCKET CARDINALITY. Putting the session in the read_stats key would take
   a 2k-file project from ~2k to ~100k rows/day, into a table ReadLedger
   loads whole at boot and full-scans on every heat request, hub-wide — so
   it would slow the Dashboard for projects that never ran an agent. This
   is the escape hatch the spec itself names, taken up front: session rows
   live in their own read_sessions repo, outside ReadLedger.byKey. No
   read_stats PK migration, no change to the resident-row count, ?by=device
   byte-identical. They get their own retention (session_retention_days,
   default 30) which DELETES rather than folds — no heat total was ever
   derived from them.

3. READS ARE RECORDED ONLY FOR PATHS IN THE CURRENT REPLAY, so a session
   that read a file it then deleted shows a change with no read. That is by
   design, and the run card says so in its footer rather than leaving it to
   read as a bug.

Privacy ruling, written into internal/webapp/reads.go before anything
serves it: a session id appears only in History responses on the op that
carries it, and as a ?session= filter INPUT. It is never enumerated — no
listing endpoint, no session column in /heat output, nothing new in
?by=device.

Also: PendingReads now dedupes on (path, session), not path alone. Two
agent sessions on one device between syncs used to collapse into one event
carrying whichever session flushed last — one session's reads silently
credited to another.

Tests: journal round-trip + Less-ignores-Session; the forge test (`sync
--note "claude-code session <someone-else's>"` leaves Session empty); a
multi-device syncer test carrying the session through convergence; spool
per-session dedup; hub round-trip, cross-device forge, query contract and
non-enumeration; db_conformance on file, sqlite AND postgres; runs.ts
grouping incl. legacy fallback; a Playwright spec on the seeded run card.
2026-08-11 04:18:53 +09:00

441 lines
16 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/webapp"
)
// webConfig mirrors the serve command's flags so a server can be configured
// from a file (bdrive serve -c config.json). Explicitly-passed flags win over
// file values.
type webConfig struct {
Remote string `json:"remote,omitempty"`
Dir string `json:"dir,omitempty"`
Addr string `json:"addr,omitempty"`
Volume string `json:"volume,omitempty"`
Refresh string `json:"refresh,omitempty"` // duration, e.g. "10s"
Upload *bool `json:"upload,omitempty"`
UploadTTL string `json:"upload_ttl,omitempty"` // duration, e.g. "15m"
ProjectsDB string `json:"projects_db,omitempty"` // hub project registry path
ShareRPM int `json:"share_rpm,omitempty"` // per-IP rate on /s/* (default 120/min)
// TrustProxy makes the rate limiters read the client address from
// X-Forwarded-For sent by ANY peer. Usually unnecessary: a proxy that
// reaches the hub over loopback or a private network is trusted with no
// configuration. Set it only for a proxy on a PUBLIC address — on a
// directly-reachable hub the header is client-supplied, and trusting it
// lets one connection get a fresh bucket per request, which disables the
// share limiter and the login brute-force limiter alike.
TrustProxy bool `json:"trust_proxy,omitempty"`
// Auth tunes the hub's (always-on) authentication; hubs require
// sign-in unconditionally, only these knobs are optional.
Auth *struct {
AllowSignup *bool `json:"allow_signup,omitempty"` // default true
UsersDB string `json:"users_db,omitempty"` // default $BDRIVE_HOME/auth.json
AllowedDomains []string `json:"allowed_domains,omitempty"` // signup email must match one (e.g. ["runbear.io"])
RequireVerification *bool `json:"require_verification,omitempty"` // new accounts verify email before activation
RequireApproval *bool `json:"require_approval,omitempty"` // new accounts await admin approval
Admins []string `json:"admins,omitempty"` // hub admin emails (approve users, govern shares)
Brand string `json:"brand,omitempty"` // name shown on the sign-in page
BaseURL string `json:"base_url,omitempty"` // public origin used for MAILED links (never the request's Host)
SMTP *struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user,omitempty"`
Pass string `json:"pass,omitempty"`
From string `json:"from,omitempty"`
} `json:"smtp,omitempty"`
} `json:"auth,omitempty"`
// Database selects where hub metadata (accounts, projects, orgs, invites,
// shares, devices) lives. Default "file" (JSON under BDRIVE_HOME). Blobs
// and journals always stay in the object store, never the database.
Database *struct {
Driver string `json:"driver,omitempty"` // file (default) | sqlite | postgres
DSN string `json:"dsn,omitempty"` // sqlite file path, or a Postgres/Supabase URL
} `json:"database,omitempty"`
// Reads tunes read telemetry (the heat API): aggregate view counts per
// file, split human/agent/share. On by default in hub mode; counts only,
// no reader identities in any API.
Reads *struct {
Enabled *bool `json:"enabled,omitempty"` // default true
RetentionDays int `json:"retention_days,omitempty"` // default 400; older days fold into all-time
// SessionRetentionDays bounds the per-session read detail behind
// History's run cards (which files an agent session read). Default
// 30, and deliberately much shorter than RetentionDays: this is
// event-shaped rather than aggregate, and rows past it are deleted,
// which changes no heat total.
SessionRetentionDays int `json:"session_retention_days,omitempty"`
} `json:"reads,omitempty"`
}
func loadWebConfig(path string) (webConfig, error) {
var c webConfig
data, err := os.ReadFile(path)
if err != nil {
return c, err
}
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&c); err != nil {
return c, fmt.Errorf("parse %s: %w", path, err)
}
return c, nil
}
func webCmd() *cobra.Command {
var remoteURL, dir, volume string
var addr, configPath, projectsDB string
var refresh, uploadTTL time.Duration
var upload bool
var cfg webConfig
c := &cobra.Command{
Use: "serve [folder | storage-root-url]",
Aliases: []string{"web"},
Short: "Serve the bdrive web server: viewer, uploads, and sync hub",
Long: `Serve the bdrive web server: browse folders and files, read rendered
markdown (Obsidian-style, including [[wikilinks]]), and download any file.
Two modes:
- a local folder, served straight from disk (the default — on a mounted
folder the daemon keeps it fresh, so this is the simplest viewer);
- a hub: point it at an object-storage root and it hosts many projects,
each stored under its own prefix, managed by a file-backed project
registry. Client devices run "bdrive login <this server>" once, then
"bdrive init" per project, and sync whole folders through it without
ever seeing the storage location or holding cloud credentials.
The server is read-only unless --upload is set. With uploads on, content
travels directly between clients and the object store through short-lived
presigned URLs when the backend supports it (S3, GCS with signing
credentials); otherwise it is relayed through this server.`,
Example: ` bdrive serve # serve the current directory
bdrive serve ./notes # serve a folder
bdrive serve -c config.json # everything from a config file
bdrive serve s3://bucket/root --upload # multi-project sync hub
bdrive web # deprecated alias for "bdrive serve"`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Config file first; flags that were explicitly passed override
// its values.
if configPath != "" {
c, err := loadWebConfig(configPath)
if err != nil {
return err
}
cfg = c
set := cmd.Flags().Changed
if c.Remote != "" && !set("remote") {
remoteURL = c.Remote
}
if c.Dir != "" && !set("dir") {
dir = c.Dir
}
if c.Addr != "" && !set("addr") {
addr = c.Addr
}
if c.Volume != "" && !set("volume") {
volume = c.Volume
}
if c.Upload != nil && !set("upload") {
upload = *c.Upload
}
if c.Refresh != "" && !set("refresh") {
d, err := time.ParseDuration(c.Refresh)
if err != nil {
return fmt.Errorf("config refresh: %w", err)
}
refresh = d
}
if c.UploadTTL != "" && !set("upload-ttl") {
d, err := time.ParseDuration(c.UploadTTL)
if err != nil {
return fmt.Errorf("config upload_ttl: %w", err)
}
uploadTTL = d
}
if c.ProjectsDB != "" && !set("projects-db") {
projectsDB = c.ProjectsDB
}
}
// Positional argument: a URL selects remote mode, anything else
// is a folder. With nothing specified, serve the current dir.
if remoteURL == "" && dir == "" && len(args) > 0 {
if strings.Contains(args[0], "://") {
remoteURL = args[0]
} else {
dir = args[0]
}
}
if remoteURL != "" && dir != "" {
return fmt.Errorf("--remote and --dir are mutually exclusive")
}
if remoteURL == "" && dir == "" {
dir = "."
}
// A config that asks for gating this mode cannot provide is
// refused, not silently honoured in part. The whole auth block —
// allowed_domains, require_approval, allow_signup, admins — is
// built only in the hub branch, so with a `dir` the operator got a
// server anyone can read and a config file that said otherwise.
// Same posture as ValidateSignupPolicy, which exists for exactly
// this and lives inside the branch that never runs here.
if dir != "" && cfg.Auth != nil {
return fmt.Errorf("the `auth` block configures a hub's sign-in and a `dir` selects " +
"the single-volume viewer, which is auth-free by design: remove one of them " +
"(use `remote:` to run a hub)")
}
srv := &webapp.Server{
Refresh: refresh,
Upload: webapp.UploadConfig{Enabled: upload, TTL: uploadTTL},
ShareRPM: cfg.ShareRPM,
TrustProxy: cfg.TrustProxy,
}
var display string
var meta webapp.MetaStore // hub metadata store; nil means the file backend
if dir != "" {
// Single-volume viewer over a plain folder.
abs, err := filepath.Abs(dir)
if err != nil {
return err
}
if fi, err := os.Stat(abs); err != nil || !fi.IsDir() {
return fmt.Errorf("%s is not a directory", abs)
}
srv.Source = &webapp.DirSource{Root: abs}
srv.Volume = filepath.Base(abs)
display = abs
} else {
// Hub: many projects on one storage root.
be, err := remote.Open(cmd.Context(), remoteURL)
if err != nil {
return err
}
defer be.Close()
if projectsDB == "" {
home, err := config.Home()
if err != nil {
return err
}
projectsDB = filepath.Join(home, "projects.json")
}
// Pick the metadata backend: file (default) or a SQL database
// (sqlite locally, Postgres/Supabase in production).
if cfg.Database != nil {
switch cfg.Database.Driver {
case "", "file":
case "sqlite", "postgres", "pgx":
drv := cfg.Database.Driver
if drv == "postgres" {
drv = "pgx"
}
meta, err = webapp.OpenSQLStore(drv, cfg.Database.DSN)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer meta.Close()
default:
return fmt.Errorf("unknown database driver %q (use file, sqlite, or postgres)", cfg.Database.Driver)
}
}
var db *webapp.ProjectDB
if meta != nil {
db, err = webapp.NewProjectDB(meta.Projects())
} else {
db, err = webapp.OpenProjectDB(projectsDB)
}
if err != nil {
return fmt.Errorf("open project registry: %w", err)
}
dev, err := config.LoadDevice()
if err != nil {
return fmt.Errorf("load device identity: %w", err)
}
srv.Root = be
srv.Projects = db
srv.Device = webapp.Identity{ID: dev.ID, Name: dev.Name, Author: dev.Author}
// Display name only — and it goes out in /api/config, which is
// readable anonymously, so it must not be derived from the
// storage URL: on s3://acme-prod-drive that named the bucket
// to the whole internet. `--volume` / `volume:` stays the one
// way an operator puts a storage-flavoured string on the wire.
srv.Volume = "BearDrive"
if meta != nil {
display = remoteURL
} else {
display = remoteURL + " (projects: " + projectsDB + ")"
}
}
if volume != "" {
srv.Volume = volume
}
// Hubs always require sign-in: every op needs a real account
// behind it (history, device registry). The plain-folder viewer
// stays auth-free.
if srv.Root != nil {
usersDB := ""
// Invite-only by default: a fresh hub doesn't accept self-signup
// until an admin opts in (with a gate). Owners onboard people
// with /join/<token> invite links.
allowSignup := false
var mail *webapp.Mailer
if cfg.Auth != nil {
usersDB = cfg.Auth.UsersDB
if cfg.Auth.AllowSignup != nil {
allowSignup = *cfg.Auth.AllowSignup
}
if cfg.Auth.SMTP != nil {
mail = &webapp.Mailer{
Host: cfg.Auth.SMTP.Host, Port: cfg.Auth.SMTP.Port,
User: cfg.Auth.SMTP.User, Pass: cfg.Auth.SMTP.Pass,
From: cfg.Auth.SMTP.From,
}
}
}
home, err := config.Home()
if err != nil {
return err
}
if usersDB == "" {
usersDB = filepath.Join(home, "auth.json")
}
var auth *webapp.BuiltinAuth
if meta != nil {
auth, err = webapp.NewBuiltinAuth(meta.Accounts(), allowSignup, mail)
} else {
auth, err = webapp.OpenBuiltinAuth(usersDB, allowSignup, mail)
}
if err != nil {
return fmt.Errorf("open account registry: %w", err)
}
if cfg.Auth != nil {
auth.AllowedDomains = cfg.Auth.AllowedDomains
// Toggles: an explicit config value pins the setting each
// boot; otherwise the UI-saved policy (loaded from auth.json)
// stands.
if cfg.Auth.RequireVerification != nil {
auth.RequireVerification = *cfg.Auth.RequireVerification
}
if cfg.Auth.RequireApproval != nil {
auth.RequireApproval = *cfg.Auth.RequireApproval
}
auth.Brand = cfg.Auth.Brand
auth.BaseURL = cfg.Auth.BaseURL
if len(cfg.Auth.Admins) > 0 {
auth.Admins = make(map[string]bool, len(cfg.Auth.Admins))
for _, e := range cfg.Auth.Admins {
auth.Admins[strings.ToLower(strings.TrimSpace(e))] = true
}
}
}
// Refuse to boot an incoherent signup posture (open with no
// gate, or verification without a mailer) rather than silently
// leaving the door open.
if err := auth.ValidateSignupPolicy(); err != nil {
return err
}
srv.Auth = auth
var orgs *webapp.OrgDB
if meta != nil {
orgs, err = webapp.NewOrgDB(meta.Orgs())
} else {
orgs, err = webapp.OpenOrgDB(filepath.Join(filepath.Dir(projectsDB), "orgs.json"))
}
if err != nil {
return fmt.Errorf("open org registry: %w", err)
}
srv.Dir = webapp.LocalDirectory{OrgDB: orgs}
// Invite links can bootstrap an account on an invite-only hub.
auth.InviteValid = orgs.ValidInvite
// A hub that predates organizations: sweep its projects into
// a default org so it keeps working with zero manual steps.
if err := webapp.MigrateOrgs(srv.Projects, orgs, auth.Accounts()); err != nil {
return fmt.Errorf("migrate projects into orgs: %w", err)
}
var devices *webapp.DeviceRegistry
if meta != nil {
devices, err = webapp.NewDeviceRegistry(meta.Devices())
} else {
devices, err = webapp.OpenDeviceRegistry(filepath.Join(filepath.Dir(projectsDB), "devices.json"))
}
if err != nil {
return fmt.Errorf("open device registry: %w", err)
}
srv.Devices = devices
var shares *webapp.ShareDB
if meta != nil {
shares, err = webapp.NewShareDB(meta.Shares())
} else {
shares, err = webapp.OpenShareDB(filepath.Join(filepath.Dir(projectsDB), "shares.json"))
}
if err != nil {
return fmt.Errorf("open share registry: %w", err)
}
srv.Shares = shares
readsOn, retention, sessRetention := true, 0, 0
if cfg.Reads != nil {
if cfg.Reads.Enabled != nil {
readsOn = *cfg.Reads.Enabled
}
retention = cfg.Reads.RetentionDays
sessRetention = cfg.Reads.SessionRetentionDays
}
if readsOn {
var reads *webapp.ReadLedger
var sessions webapp.SessionReadRepo
if meta != nil {
reads, err = webapp.NewReadLedger(meta.Reads(), retention)
sessions = meta.SessionReads()
} else {
dir := filepath.Dir(projectsDB)
reads, err = webapp.OpenReadLedger(filepath.Join(dir, "reads.json"), retention)
sessions = webapp.OpenSessionReadRepo(filepath.Join(dir, "sessions.json"))
}
if err != nil {
return fmt.Errorf("open read ledger: %w", err)
}
reads.WithSessions(sessions, sessRetention)
defer reads.Close()
srv.Reads = reads
}
if meta != nil {
display += " (db: " + cfg.Database.Driver + ")"
} else {
display += " (auth: " + usersDB + ")"
}
}
shown := addr
if strings.HasPrefix(shown, ":") {
shown = "localhost" + shown
}
fmt.Printf("serving %s\n volume: %s\n url: http://%s\n", display, srv.Volume, shown)
return http.ListenAndServe(addr, srv.Handler())
},
}
c.Flags().StringVarP(&remoteURL, "remote", "r", "", "remote to serve (s3://bucket/prefix, gs://bucket/prefix, file:///path)")
c.Flags().StringVar(&dir, "dir", "", "local folder to serve (default: current directory)")
c.Flags().StringVar(&addr, "addr", ":4173", "address to listen on")
c.Flags().StringVarP(&volume, "volume", "v", "", "volume display name (default: folder or remote basename)")
c.Flags().DurationVar(&refresh, "refresh", 10*time.Second, "how long to cache the file listing")
c.Flags().BoolVar(&upload, "upload", false, "allow clients to upload files")
c.Flags().DurationVar(&uploadTTL, "upload-ttl", webapp.DefaultUploadTTL, "lifetime of presigned direct-upload URLs")
c.Flags().StringVarP(&configPath, "config", "c", "", "JSON config file; explicit flags override its values")
c.Flags().StringVar(&projectsDB, "projects-db", "", "hub project registry file (default: $BDRIVE_HOME/projects.json)")
return c
}