mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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.
225 lines
8.9 KiB
Go
225 lines
8.9 KiB
Go
package webapp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// sessionHub is permHub with read telemetry that also keeps session detail —
|
|
// the shape a served hub has (cmd/bdrive/web.go).
|
|
func sessionHub(t *testing.T) (http.Handler, *Server, map[string]*http.Cookie, Project, string) {
|
|
t.Helper()
|
|
h, srv, c, p, root := permHubAt(t)
|
|
reads, err := OpenReadLedger(filepath.Join(t.TempDir(), "reads.json"), 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.Reads = reads.WithSessions(OpenSessionReadRepo(filepath.Join(t.TempDir(), "sessions.json")), 0)
|
|
return h, srv, c, p, root
|
|
}
|
|
|
|
func sessionPaths(t *testing.T, h http.Handler, p Project, c *http.Cookie, session, device string) []string {
|
|
t.Helper()
|
|
rec := doAs(t, h, "GET",
|
|
"/api/p/"+p.ID+"/heat?session="+session+"&device="+device, nil, c)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("session heat: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var out struct {
|
|
Paths []string `json:"paths"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out.Paths
|
|
}
|
|
|
|
func reportRead(t *testing.T, h http.Handler, p Project, c *http.Cookie, device string, reads []map[string]string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
return secfixDo(t, h, "POST", "/api/p/"+p.ID+"/reads",
|
|
map[string]any{"reads": reads}, c, map[string]string{"X-Bdrive-Device": device})
|
|
}
|
|
|
|
// The join, end to end: a session's reads come back for the session+device
|
|
// pair its own ops carry, and only for files the project actually has.
|
|
func TestSessionReadsRoundTrip(t *testing.T) {
|
|
h, _, c, p, root := sessionHub(t)
|
|
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
|
|
f.putAs("dev1", "alice@x.io", "Alice", "wiki/plan.md", "# plan")
|
|
f.putAs("dev1", "alice@x.io", "Alice", "wiki/spec.md", "# spec")
|
|
|
|
// dev1 is alice's: one sync registers it, as /store/* traffic does.
|
|
if rec := secfixSync(t, h, p.ID, c["alice"], "dev1", "laptop", "mac"); rec.Code != 200 {
|
|
t.Fatalf("alice sync: %d %s", rec.Code, rec.Body)
|
|
}
|
|
rec := reportRead(t, h, p, c["alice"], "dev1", []map[string]string{
|
|
{"path": "wiki/plan.md", "session": "8f21e4"},
|
|
{"path": "wiki/spec.md", "session": "8f21e4"},
|
|
{"path": "wiki/gone.md", "session": "8f21e4"}, // landmine 3: no such file, records nothing
|
|
{"path": "wiki/plan.md", "session": "other"}, // a different session, its own row
|
|
})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("report: %d %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
got := sessionPaths(t, h, p, c["alice"], "8f21e4", "dev1")
|
|
if len(got) != 2 || got[0] != "wiki/plan.md" || got[1] != "wiki/spec.md" {
|
|
t.Fatalf("session paths = %v, want plan.md + spec.md (gone.md is not in the project)", got)
|
|
}
|
|
if got := sessionPaths(t, h, p, c["alice"], "other", "dev1"); len(got) != 1 || got[0] != "wiki/plan.md" {
|
|
t.Fatalf("second session = %v, want only plan.md — sessions must not merge", got)
|
|
}
|
|
// A member who is not the reporting device still sees the run's reads:
|
|
// the card is project-wide, and this response carries no identities.
|
|
if got := sessionPaths(t, h, p, c["bob"], "8f21e4", "dev1"); len(got) != 2 {
|
|
t.Fatalf("member view = %v, want the same two paths", got)
|
|
}
|
|
// An unknown session is an empty list, never an error and never a hint
|
|
// that some other session exists.
|
|
if got := sessionPaths(t, h, p, c["alice"], "no-such-session", "dev1"); len(got) != 0 {
|
|
t.Fatalf("unknown session = %v, want empty", got)
|
|
}
|
|
}
|
|
|
|
// Landmine 1's read-half twin: the session id in a read report is a CLIENT
|
|
// string, so bob can report reads naming alice's session. The row is pinned
|
|
// to the device the hub validated — bob's, never alice's — and the query
|
|
// requires both, so his rows can never surface on her run card.
|
|
func TestSessionReadsCannotBePaintedOntoAnotherDevicesRun(t *testing.T) {
|
|
h, srv, c, p, root := sessionHub(t)
|
|
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
|
|
f.putAs("dev1", "alice@x.io", "Alice", "wiki/plan.md", "# plan")
|
|
f.putAs("dev1", "alice@x.io", "Alice", "payroll.md", "secret")
|
|
|
|
// alice-mbp is claimed by alice, as `bdrive login` claims it
|
|
// (DeviceRegistry.Bind) — the state in which MayActAs has something to
|
|
// refuse.
|
|
srv.Devices.Observe(DeviceInfo{ID: "alice-mbp", Name: "laptop", OS: "mac", User: "alice@x.io"})
|
|
if rec := secfixSync(t, h, p.ID, c["bob"], "bob-mbp", "laptop", "linux"); rec.Code != 200 {
|
|
t.Fatalf("bob sync: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if rec := reportRead(t, h, p, c["alice"], "alice-mbp", []map[string]string{
|
|
{"path": "wiki/plan.md", "session": "8f21e4"},
|
|
}); rec.Code != 200 {
|
|
t.Fatalf("alice report: %d %s", rec.Code, rec.Body)
|
|
}
|
|
// bob reports under ALICE's session id, from his own device.
|
|
if rec := reportRead(t, h, p, c["bob"], "bob-mbp", []map[string]string{
|
|
{"path": "payroll.md", "session": "8f21e4"},
|
|
}); rec.Code != 200 {
|
|
t.Fatalf("bob report: %d %s", rec.Code, rec.Body)
|
|
}
|
|
// bob naming alice's DEVICE outright is refused by ownsDevice, so it
|
|
// records for nobody.
|
|
if rec := reportRead(t, h, p, c["bob"], "alice-mbp", []map[string]string{
|
|
{"path": "payroll.md", "session": "8f21e4"},
|
|
}); rec.Code != 200 {
|
|
t.Fatalf("bob's forged-device report: %d %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
got := sessionPaths(t, h, p, c["alice"], "8f21e4", "alice-mbp")
|
|
if len(got) != 1 || got[0] != "wiki/plan.md" {
|
|
t.Fatalf("alice's run card = %v, want only her own read — bob painted onto it", got)
|
|
}
|
|
}
|
|
|
|
// The API shape: ?session= is a filter INPUT that requires its device, and
|
|
// the route is membership-gated exactly as /heat is.
|
|
func TestSessionHeatQueryContract(t *testing.T) {
|
|
h, _, c, p, _ := sessionHub(t)
|
|
base := "/api/p/" + p.ID + "/heat"
|
|
|
|
for _, u := range []string{base + "?session=8f21e4", base + "?device=dev1"} {
|
|
if rec := doAs(t, h, "GET", u, nil, c["alice"]); rec.Code != 400 {
|
|
t.Fatalf("GET %s: %d, want 400 (session and device are required together)", u, rec.Code)
|
|
}
|
|
}
|
|
// dave is in no org here: a non-member is walled out of the session
|
|
// query exactly as they are out of plain heat.
|
|
for _, u := range []string{base, base + "?session=8f21e4&device=dev1"} {
|
|
if rec := doAs(t, h, "GET", u, nil, c["dave"]); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("outsider GET %s: %d, want 403", u, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The privacy ruling, tested: nothing enumerates sessions. ?by=device output
|
|
// is byte-identical with session rows present, and plain /heat never grows a
|
|
// session column.
|
|
func TestSessionIdsAreNeverEnumerated(t *testing.T) {
|
|
h, _, c, p, root := sessionHub(t)
|
|
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
|
|
f.putAs("dev1", "alice@x.io", "Alice", "wiki/plan.md", "# plan")
|
|
if rec := secfixSync(t, h, p.ID, c["alice"], "dev1", "laptop", "mac"); rec.Code != 200 {
|
|
t.Fatalf("alice sync: %d %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
if rec := reportRead(t, h, p, c["alice"], "dev1", []map[string]string{
|
|
{"path": "wiki/plan.md", "session": "8f21e4"},
|
|
}); rec.Code != 200 {
|
|
t.Fatalf("report: %d %s", rec.Code, rec.Body)
|
|
}
|
|
for _, body := range []string{
|
|
doAs(t, h, "GET", "/api/p/"+p.ID+"/heat?by=device", nil, c["alice"]).Body.String(),
|
|
doAs(t, h, "GET", "/api/p/"+p.ID+"/heat", nil, c["alice"]).Body.String(),
|
|
} {
|
|
if strings.Contains(body, "8f21e4") || strings.Contains(body, "session") {
|
|
t.Fatalf("a heat response enumerated a session: %s", body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Retention: session rows past the horizon are DELETED, not folded, and the
|
|
// path's heat totals — which were never derived from them — are unchanged.
|
|
func TestSessionReadRetentionPrunes(t *testing.T) {
|
|
repo := OpenSessionReadRepo(filepath.Join(t.TempDir(), "sessions.json"))
|
|
l, _ := openTestLedger(t, 0)
|
|
l.WithSessions(repo, 1) // one day
|
|
|
|
l.Record("p-1", "a.md", ReadKindAgent, "dev1")
|
|
l.RecordSession("p-1", "old", "dev1", "a.md")
|
|
l.RecordSession("p-1", "new", "dev1", "a.md")
|
|
// Age the "old" session past the horizon, then force a prune.
|
|
l.mu.Lock()
|
|
for k, sr := range l.pendingSess {
|
|
if k.Session == "old" {
|
|
sr.Last = time.Now().UTC().Add(-48 * time.Hour)
|
|
l.pendingSess[k] = sr
|
|
}
|
|
}
|
|
l.lastSessPrun = time.Time{}
|
|
l.flushSessionsLocked()
|
|
l.mu.Unlock()
|
|
|
|
if got, _ := repo.ListBySession("p-1", "old", "dev1"); len(got) != 0 {
|
|
t.Fatalf("expired session rows survived: %+v", got)
|
|
}
|
|
if got, _ := repo.ListBySession("p-1", "new", "dev1"); len(got) != 1 {
|
|
t.Fatalf("recent session rows = %+v, want the one row", got)
|
|
}
|
|
// The aggregate is untouched by any of it.
|
|
if e := l.Heat("p-1", "", time.Time{})["a.md"]; e.Agent != 1 {
|
|
t.Fatalf("heat after the session prune = %+v, want agent 1", e)
|
|
}
|
|
}
|
|
|
|
// With no session repo the ledger behaves exactly as before: recording is a
|
|
// no-op and a lookup is empty, never a panic.
|
|
func TestSessionReadsOffByDefault(t *testing.T) {
|
|
l, _ := openTestLedger(t, 0)
|
|
l.RecordSession("p-1", "s1", "dev1", "a.md")
|
|
if got := l.SessionPaths("p-1", "s1", "dev1"); len(got) != 0 {
|
|
t.Fatalf("session paths with no repo = %v, want none", got)
|
|
}
|
|
var nilLedger *ReadLedger
|
|
nilLedger.RecordSession("p-1", "s1", "dev1", "a.md")
|
|
if got := nilLedger.SessionPaths("p-1", "s1", "dev1"); got != nil {
|
|
t.Fatalf("nil ledger = %v", got)
|
|
}
|
|
}
|