mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
e19fec05345874efb425e477d510e2cb767d52ac
152
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e19fec0534 |
feat(webapp): old URLs follow a moved file, and share links follow the file (BEA-81) (#130)
There is no rename in beardrive: the scanner emits a put at the new path and a delete at the old, same device, same blob, one cycle. Everything keyed on a path therefore broke the moment a file moved — the viewer 404'd, history lost the file's own past versions, restore refused them, and a share link either 404'd or silently served whatever unrelated file later took its address. internal/webapp/moves.go derives the pairing from the ops the replay already walks, cached with the snapshot. Deliberately not a rename op: journal.Less and Replay are what every device converges to, and every already-shipped journal would still need the heuristic to read its own history. The two rules point in opposite directions on purpose. A viewer URL is an address, so a LIVE path always wins and only an empty one redirects. A share token is a promise about one file, so it follows the file even when a new one takes the old address — and 404s forever once the file is deleted. Nothing here writes an op or touches sync. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
aa33ba55cc |
docs: the ⌘K palette description matches what it actually offers (BEA-84) (#132)
README.md:499 promised "share/history/upload actions" in the command palette. Upload has never existed there (browser upload is deliberately absent, Browser.tsx:175), download went unmentioned, and share/download are file-scoped — so a reader on a folder route goes hunting for controls that are correctly hidden. The doc comment at Palette.tsx:8 carried the same wrong list, so fixing only the README would leave the next reader of the component with the same picture. Prose and a comment only; no code path changes. `npm run build` produces a byte-identical internal/webapp/static (source comments are stripped from the bundle), so no assets to commit. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1894646093 |
fix(webapp): the new-project dialog no longer opens itself on arrival (BEA-80) (#129)
A signed-in account with zero projects landed on / with the create dialog already open over the onboarding page. The page renders its own "New project" button (#ob-new), so the same call to action appeared twice and the rear one was permanently pointer-intercepted — Playwright retried a click against it for 30s before failing. The auto-open was deliberate: "With no projects at all there is nothing else on the page to do." That stopped being true when the empty state gained the agent paste-prompt card — the route the docs treat as primary, which the dialog was covering. Delete the effect and its ref; fix the two comments that documented it; rewrite the e2e test that asserted the auto-open by name. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6bb7debe2b |
fix(webapp): #content shows it scrolls, Public links moves up (BEA-79) (#128)
Project settings at 1440x900 ended at People. Public links — the section that answers "is anything of ours public right now?" — was below the fold of #content, the app's only scroll container, and with macOS overlay scrollbars nothing at rest said there was more. #content now takes a real scrollbar. The plan called for scrollbar-width + scrollbar-color alongside ::-webkit-scrollbar; measured, that combination is a no-op: Chromium drops every ::-webkit-scrollbar rule as soon as either standard property is set on the same element, and the standard properties alone leave the macOS overlay bar, which takes no width and is invisible at rest. So the standard properties sit behind @supports not selector(::-webkit-scrollbar) — Firefox only. Verified: 10px reserved in Chromium, 0 on touch. Public links also moves from fourth card to second (General → Public links → People → About → Danger zone), so the security answer is above the fold even without the cue. browse.spec reached the shares table by .last(), which the reorder would have silently repointed at the members table — both render through AdminTable. It selects .shares-table now. Known gap: Firefox on macOS always uses overlay scrollbars and no CSS overrides it, so the bar there stays hidden at rest — that is an OS setting, not something this can fix. |
||
|
|
7a20849631 |
fix(webapp): Public links says "Loading…", not a premature "no" (BEA-78) (#127)
The public-links panel is where a member answers "is anything of ours public right now?". SharesTable read an empty array as a settled answer, and both callers hand it `shares || []` while the request is still in flight — so the panel printed "No public links." as a confident NO, then swapped to a table listing an active, never-expiring link. Fix at the choke point: an optional `loading` prop honored above the empty branch, in the same .admin-list/.admin-empty shell so the section is one row tall either way and doesn't jump when the data lands. Wired at both call sites — project Settings and the org-wide cross-project audit, which had the identical bug. `isLoading`, not `isPending`: OrgAdmin's shares query is `enabled: owner`, and TanStack reports isPending true forever for a disabled query. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7dd5d097fe |
docs: one answer on Windows support — macOS and Linux only (BEA-77) (#126)
"Can my Windows teammate join?" got three answers from three official surfaces: README's feature list said macOS & Linux, its CLI table documented an HKCU Run entry on Windows, and the hub's Installation page named no OS at all. Windows autostart is real code that cannot run — internal/store's flock and internal/daemon's Kill/Setsid are unix-only, so GOOS=windows does not build. The fix is the docs, not the port: every Windows claim comes out of README, web/docs and `bdrive autostart`'s own help, and the Installation page gains one line naming the supported systems. No Go source is deleted; internal/autostart/autostart_windows.go stays compiled-but-dormant for whenever the port happens. |
||
|
|
826d13795f |
fix(webapp): show how many times a public link has been opened (BEA-76) (#125)
Every /s/<token> hit was already recorded as a share-kind read, carrying
both a count and a timestamp — and then thrown away at the UI layer. The
Public links table showed only who shared a file and when, six inches
below a file header that already said "1 shared". Two personas filed it
independently on the same tour.
The number now rides the shares list:
* ReadLedger.ShareOpens(project) aggregates share-kind buckets per path,
all-time. Share-kind only is what makes Last mean *last opened* —
HeatEntry.LastRead is cross-kind, so a member viewing the file in the
hub would otherwise move the date.
* shareJSON takes the project's opens map, built ONCE per project by the
caller and indexed per row. Both callers — the project list and the
org-wide audit — hoist it above their loops; a per-share call would be
a full byKey scan per row.
* Counts, never openers. The share actor is token+"/"+IP, a public
credential joined to an IP, and it stays in the ledger. There is no
distinct-openers field, deliberately.
* Reads off means the keys are ABSENT, not zero: `0` would claim nobody
has opened a link on a hub that never looked.
shareDetail() is the leverage — the settings table, the org-wide audit and
the file page's share banner all render through it, so one string function
covers three surfaces. Once the row carried the receipt it truncated to
"3 op…", so the detail cell wraps instead of ellipsizing; the path keeps
its ellipsis, since it is a link with a tooltip and the column that can be
arbitrarily long.
Counted per FILE, not per link: heat is keyed by path, so two tokens on one
file report the same number. Worded that way in the section copy, alongside
the other honesty — opens are debounced visits, not requests.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
119d4abf79 |
feat(webapp): render .csv/.tsv as a table instead of a wall of monospace (BEA-74) (#124)
A .csv already previewed — as raw text in a <pre>, columns lining up only if the file happened to be padded. It now renders as an HTML table with the first row as a header. The parser is a new pure lib/csv.ts (~50 lines of RFC 4180: quoted delimiters, "" as a literal quote, newlines inside quotes), so no papaparse. It never throws: null means "not a table" — an unterminated quote, or a file with no delimiter at all — and the caller falls back to the very <pre> it renders today. That fallback is structural rather than a second code path, because TextView gained a `delim` prop instead of a new component: it also keeps the ["text", fileURL] query key a restore invalidates and the retry:false a pinned ?v= version needs. .tsv is new here — it used to fall through to SniffView and render as text. The delimiter comes from the extension, never from sniffing. Big files are capped at 5,000 rows with the count stated on screen (virtualization is out of scope). Wide files scroll inside .csvbox, whose rules are scoped under that class on purpose: the file pane carries .markdown, and the plain .markdown table rules — including the ≤900px one that turns a table into its own scroller — would otherwise out-specify a bare .csvview and give the page two nested scrollers. Not doing: sorting, filtering, search, editing, XLSX. |
||
|
|
27cc558ac7 |
fix(webapp): new-project dialog preselects the RECOMMENDED row (BEA-72) (#123)
The RECOMMENDED badge renders on options[0]; the initial selection was hard-wired to "" (the last row, Empty project), so on any hub shipping templates the dialog contradicted its own advice — and the default was the option that produces a project with nothing to look at. Hoist the options array above the state and seed `template` from options[0].value: badge row and checked row are now the same element by construction, not by coincidence. On a template-less hub options[0] is "I already have a folder", which still creates an empty project. The e2e test that pinned the old default now asserts the invariant (badged row === checked row) rather than a title, and checks that creating without touching the radios actually seeds the template. |
||
|
|
5d399c3d31 |
fix(webapp): share page dark mode no longer shows white slabs (BEA-71) (#122)
The share page already followed the system, but its dark @media block sat ABOVE the light pre/code/blockquote/table rules at equal specificity, so every light rule after it won. A dark-system visitor got a white frontmatter slab, a white code block and light-grey table borders on a near-black page — the surface strangers see first reading as a different product. Source order is the whole fix: the block moves to the end of the stylesheet and gets completed there, which repairs pre/code for free. Values are the hub's @theme tokens from frontend/src/tw.css instead of the hand-picked greys (#c6cbd3 body text, #3a3a44/#888 footer) that were the "different product" half of the complaint. Light mode is pixel-identical — only the dark block moved. Response headers (sandbox CSP, nosniff, referrer) are untouched. The test asserts placement, not presence: "a dark rule exists" passed while the page was still wrong, so it checks the dark block comes after the last light literal. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5fb834e3a4 |
fix(webapp): keep the provenance line on mobile, unclipped in History (BEA-70) (#121)
Below 900px the file view hid the who/when/how-hot line outright
(`#meta { display: none }`) and the History run header ellipsised its
note and author to `claude-…` / `Alice <ali…` — the line the product is
differentiated by, gone exactly on the surface people catch up from.
CSS-only, inside the existing `@media (max-width: 900px)` block:
- `#topbar` wraps (`height: auto; min-height: 52px`) and `#meta` takes its
own full-width row, left-aligned and wrapping. `order: 1` is what keeps
Search / Share / ⋯ on row 1 — meta precedes them in the DOM, so a bare
flex-wrap would drag them down. `#meta:empty` keeps folder, dashboard
and history routes from gaining a blank strip.
- `.hrun-head` wraps; the note loses its 46% cap and both spans stop
ellipsising, with `.hrun-meta` on its own line under the note and the
time still on row 1.
Desktop (≥901px) is untouched — every rule lives inside the breakpoint.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
631e5be947 |
test(webapp): pin "a read member sees grants and public links" (BEA-69) (#120)
A read-only member seeing the People matrix and every active public link is deliberate — the owner re-confirmed it against Google Drive, where a viewer can see who has access. Nothing asserted it: TestReadOnlyMemberRoutes only checks GET .../shares is not 403, so a hardening pass that returned an empty list (or dropped the creator field) to read members would pass every test in the repo. Adds TestReadMemberSeesSharesAndGrants — 200 plus the link's token, path, url and creator from a read-only session — and extends the two rationale comments the next reader lands on instead of adding a third copy. No route change, no UI change; the ShareBanner edit is a comment, so internal/webapp/static is unchanged. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
00581f2839 |
fix(hub): the Dashboard can filter by shared reads, not just human and agent (#117)
The page named three read types and let you filter two. The missing chip was share — traffic from public links an owner minted, which is the category they most want to isolate. Everything but the lens already spoke "share": heatTotal, heatText and hotPathSplit all carry it, and /heat returns it per path. So widening the union makes the treemap, the scatter and the Hot path arithmetic correct for free. The one place that decided anything was the Hot path bar, which branched agent-vs-human and would have painted shared reads in somebody else's colour; it now looks the pure lens up in a table instead. The e2e seed had no share reads at all, so the lens could not be asserted. It gains one on notes/deep/topic.md — a path no other assertion counts, so nothing else's totals move. BEA-62 |
||
|
|
e6c166c85f |
fix(webapp): treemap goes grey when the age range can't rank anything (BEA-68) (#119)
The Dashboard treemap painted the full 0-300d green->amber->red ramp even when every file in scope was the same age, so an all-fresh project read as "everything is healthy" - while the legend directly underneath admitted the colour carried no signal. The flag that legend computed never reached the cells that made the claim. isFlatRange now lives one component up, in Treemap, which owns both the cells and the legend. Flat scope: one neutral grey fill (deliberately off the ramp), an inert legend swatch, and a statement instead of an apology. Non-flat scope is untouched. Computed over every scoped file, so it follows the folder scope and never the read-type lens. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
83613f4c85 |
fix(webapp): /history?path=<file> shows that file's history instead of the whole project (BEA-64) (#118)
* fix(webapp): /history?path=<file> shows that file's history instead of the whole project (BEA-64) The History API takes ?path=/?prefix=, so a reader who has seen the API types the query form at the page too. The router dropped it: viewTarget stayed empty, the project-wide feed rendered, and nothing said a parameter had been ignored — read as "this file has no history" or "history is broken". Honour it and normalize, the same shape as the legacyView and trailingSlash redirects already there: parseRoute sets viewTarget from ?path=/?prefix= (only on the history view, only when no path segment named a target) and flags the URL for replacement; HubApp swaps in the canonical /history/<target> URL with the filters intact. The legacyView redirect next door dropped history filters on its own hop — one-argument fix in the same call shape. * docs(architecture): Route.queryTarget joins the normalization flags (BEA-64) |
||
|
|
71e52d5704 |
fix(webapp): show changes works in every history feed, not just per-file (BEA-58) (#116)
The unified diff already shipped, but HistoryView passed the `diff` prop only when the route targeted a single file. A reviewer who opened the project-wide feed first — the natural entry point for "what did this agent run change?" — found only Open this version / Download and concluded the product had no diff at all. Pass it unconditionally. `prevBlob` was already a per-path lookup over the whole loaded window, so a mixed-path feed diffs each row against its own predecessor with no new lookup; `run.idx[k]` does the same for rows inside a run card. The existing gates are untouched: deletes never diff, a row with no earlier version in the window says so, binary/too-large keep their download-both message, and nothing fetches until a row is expanded. The folder listing's "Recent changes" teaser stays diff-free on purpose: it fetches n=20 with no Load more, so nearly every row would read "First version". Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5330532f7f |
fix(webapp): landing on / opens the project you last used (BEA-75) (#115)
Hitting the hub with no project in the URL always resolved to projects[0] — alphabetically first, unrelated to what you were doing — and HubApp then rewrote the address bar to it, so the wrong choice was the one that got bookmarked. The browser now remembers the project it last opened and prefers it. The precedence chain gains one clause between the just-joined org and the fallback; a remembered id is looked up in the project list, so one that was deleted, or that this account can no longer see, simply doesn't match and projects[0] takes over with nothing on screen to say so. localStorage, per browser and origin-scoped, so two hubs never share an answer. Both helpers swallow — storage throws in Safari private mode, and a preference is never worth a broken page. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e02bd5330d |
feat(cloud): launch pricing guardrails — egress caps, ignore defaults, storage tiering (#114)
QuotaProvider grows a read half: CheckRead(org, bytes) and RecordEgress(org, bytes). CheckRead is enforced on /s/* only — a public share link is the sole unauthenticated door to stored bytes, so it is the only egress a plan can cap. The sync proxy and viewer merely RecordEgress: refusing a device mid-cycle surfaces as ErrForbidden, which the syncer reads as "access is gone — pause and touch nothing", and sync must never break over a bill. UnlimitedQuota stays the OSS default. countingWriter bills what actually reached the client rather than a size claimed before the write. bdrive init warns past 1 GiB or 20k files and says how to narrow scope; syncer.Measure sizes that through the real Filter and the one walkFolder predicate. starterIgnore gains video/archive/disk-image globs and Library/ — every version is kept forever, so a big binary committed once is paid for forever on every device. deploy: a Nearline-at-30-days lifecycle rule and the arithmetic for why it stops there. Coldline and Archive only pay off below roughly one read per month, and a first sync pulls every historical blob rather than just the current tree, so blob read rate tracks device onboarding. docs/launch-plan.md said Cloud was waitlist-only and framed Product Hunt as an OSS launch whose goal was not signups or revenue; both are stale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
77a68542ea |
feat(security): 318 hardening fixes across hub, sync, CLI and SPA (#112)
* fix(security): close 12 authorization holes found by adversarial round 1
Four offensive agents attacked the hub's trust boundaries in isolated
worktrees; every finding is a Go test that failed on the tree before the
fix and passes after. 43 TestSec_* regression tests land with the fixes.
The two that matter most:
- A grant outlived org membership: projectPerm consulted p.Perms before
checking the org role, so removing someone from the org through the API
left their explicit project grant working. Offboarding did not offboard.
- Any device could PUT any other device's journal key — the hub never
compared the key to X-Bdrive-Device. That is the "each device writes
only its own journal" invariant, enforced nowhere.
Also: uploads accepted .bdrive/ and .git/ paths (and materialize applied
only filter.Skip, never neverSync, so a hostile peer journal could too);
the org share audit handed every member public /s/ URLs for projects they
were denied; password reset left old sessions and device tokens valid;
blobs were not verified against their content address; quota was bypassed
by chunked encoding and by a client-declared size; X-Forwarded-For
defeated both rate limiters, login brute-force included; the /s/* sandbox
CSP was missing on error paths; expired shares were revocable by anyone;
and projectPerm failed open to admin for org-less and unknown projects.
Known open, recorded in .claude/security-goal.md: the device header is
self-asserted (the fix buys an audit trail, not identity), and the
Dir==nil/Auth==nil admin escape needs a design decision first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 17 more holes found by adversarial round 2
Round 2 attacked the five boundaries round 1 never reached (read heat,
path handling, secret leakage, the agent hook guard, the metadata store)
plus the places round 1's coverage was overstated. 43 more regression
tests; 86 TestSec_* now green.
The critical one was not on the original board. A journal op's Blob field
is a raw storage key that nothing validates: handleStorePut checks blob
keys and their content hash, but a journal is arbitrary JSONL that never
passes through that validator. One PUT of your own journal with
"blob":"../../<other-project>/blobs/<sha>" — or "../../../etc/passwd" —
then reads back through the ordinary /file, /download and /render routes.
Any member read any file on the hub host, across orgs and outside the
storage root. Guarded where content resolves, and localBackend now
refuses a key that escapes root at all.
Also: /blob served HTML and SVG inline on the hub origin with no sandbox
CSP (stored XSS via History); bdrive init deleted the hooks it had just
written whenever $HOME is a git repo, silently disabling sync hooks
machine-wide; any account could rewrite another org's device registry row
and forge History attribution; a planted device id became a heat "reader",
putting an identity in an API response that must never carry one; storage
errors relayed the hub's absolute paths (and on S3 the bucket and key);
/auth/login?next= was an open redirect via backslash and TAB; the org and
project registries handed out their live maps (self-promotion to owner,
plus a hub-killing concurrent map iteration); revoked invites came back
after a restart and refused writes applied in memory anyway; a share
minted by someone since removed from the org kept serving publicly;
a newline in a folder name made the hook guard spawn bdrive outside any
mount; single-volume upload escaped through a symlink; the seat check was
check-then-act; and the hub data dir holding auth.json ended up 0755.
Known open and recorded in .claude/security-goal.md: nothing expires (no
TTL on tokens or sessions), the Dir==nil/Auth==nil admin escape, and
client-asserted Op.User. Postgres was never exercised — row 14's
SQL-injection result covers file and sqlite only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 17 more holes found by adversarial round 3
Round 3 attacked the journal fields nobody had audited, the fixes rounds
1 and 2 had just landed, the real `bdrive serve -c` config path, and the
read ledger on a live Postgres. 133 TestSec_* now green.
The two worst are in the syncer, and both are arbitrary file write on
every teammate's machine. materialize guarded Path with neverSync, which
splits on "/" and looks segments up in ReservedDirs — ".." is not a
member and no ignore rule mentions it, so one JSONL line pushed to your
own journal key reached ~/.ssh/authorized_keys on every device that
synced. And the reserved-directory guard was case-sensitive, so
.GIT/hooks/pre-commit sailed past it and APFS resolved it into the real
.git/hooks. Op.Mode was applied verbatim, setuid bit included.
Attacking our own round-2 fixes paid for the slot twice over: ownsDevice
turned out to be a one-request speed bump (the refused report is what
registered the forged id, so the second identical request passed) and
its first-caller-wins rule made device registration a claim-staking
primitive an outsider could use to forge History attribution and
silently kill the real owner's read heat forever. Both dissolve by
keying the registry on (account, id) instead of treating the client's
device header as a hub-wide namespace. trust_proxy, added in round 2,
took the first X-Forwarded-For hop — but XFF grows left-to-right, so
turning it on disabled the login brute-force limiter instead of fixing
it.
Also: a peer's Lamport: MaxInt64 wrapped a victim's clock and silently
reverted its own edits on its own disk; History leaked other orgs'
device names and was a hub-wide device-existence oracle; anonymous
/api/config named the storage bucket; /auth/reset enumerated accounts by
timing and was not rate limited; the signed-in hub UI was frameable and
sniffable; an asset miss returned the app shell marked immutable for a
year; Op.Size forged Content-Length; a share on an org-less project
survived its creator's offboarding; a refused upload still created
directories outside the served folder; and one NUL in a read report
wedged the entire hub's read telemetry permanently on Postgres.
Verified against a real Postgres 16 this round. Known open and recorded
in .claude/security-goal.md: NUL round-trip on Postgres text columns
(refusal vs encoding is a design call), nothing expires, the
Dir==nil/Auth==nil escape, client-asserted Op.User, and store/sign on a
backend that can actually presign — never reached in three rounds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 32 more holes found by adversarial round 4
Round 4 opened five packages no previous round had touched — store,
journal, config, remote and cmd/bdrive — and attacked the fixes rounds
1-3 had landed. It broke five of them. 166 TestSec_* now green.
The four criticals:
- ownJournal bound the journal key to the X-Bdrive-Device header of the
same request, and nothing bound that header to an account. Round 1's
test varied the key while holding the header fixed — the one
combination that was refused. Move both together and any member wrote
and replaced any peer's journal: her ops vanish, every device replays
the attacker's deletes, History credits them to her.
- store.BlobPath joined Op.Blob straight onto the blob dir with no check
that it was a sha256, and HasBlob answering true made syncer.pull skip
hash verification — so a peer's journal op read any file on every
teammate's machine into their working folder.
- A folder's .bdrive/config.json chose where this device's hub token was
sent. The file travels with the folder, so a zip or a colleague's copy
redirected the credential to any host, http:// included.
- sync --prune read .bdriveignore before the cycle and pruned against the
version the cycle had just pulled, so it deleted for the whole team
under exactly the ! rules it refuses to run with. A teammate running
bdrive scope was enough; no attacker needed.
Also: two symlink escapes in materialize (unsafeRel judges spelling, not
disk); three ways one peer op killed sync permanently on every device
that pulled it, including a panic on a short blob string; the ignore-file
reload dropped the nested-mount boundary; round 3's Lamport ceiling was
inclusive and so still reachable; the (account, id) device rekey held
neither on the read path nor on disk, so a restart handed the device to
the squatter; presigned uploads bypassed the content-address guard
entirely — that whole branch had never executed under a test, since every
fixture used file:// which cannot sign; remote.Prefixed, the single
containment primitive for multi-tenancy, did not contain; and a mount id
from the untrusted folder config escaped $BDRIVE_HOME.
Plus: bdrive export wrote hub-named keys as tar members unvalidated; the
device token followed cross-origin redirects; a symlink in the file://
storage root read and wrote anywhere on the host; GCS presigned PUTs
bound no size; presigned device uploads were never billed; trust_proxy
was wrong for the third round running; one bad line voided an entire
journal; Op.Path was not byte-exact through JSON; Less was not a total
order, so Replay's determinism rested on a caller's accident; and client
journals were 0644.
Three new scoreboard rows: client local state, the project archive, and
the device as client of a hostile hub. Still zero tests after four
rounds: internal/daemon and internal/autostart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 29 more holes found by adversarial round 5
Round 5 opened internal/daemon and internal/autostart (zero tests after
four rounds), drove the browser presign flow, and attacked round 4's
fixes. It broke seven of them, including the one round 4's commit message
called "the critical". 247 TestSec_* now green.
ownJournal failed four separate ways. The write doors called
observeDevice BEFORE ownJournal, so for an unclaimed id the request
manufactured the fact that authorized it — and device ids are not secret,
History publishes them. The claim was then permanent and unrecoverable:
no Delete on DeviceRepo, no release, no admin route, no CLI re-mint, and
a 403 naming no remedy, so any member could deny any colleague's laptop
the ability to sync forever. Rows were global but visibility per project,
so offboarding a teammate released her journal to whoever was left.
And because an ownerless row ranked as the earliest claim, every hub
upgraded from before rows had owners had the binding switched off for
precisely its established devices. Ownership is now hub-wide, an
ownerless row authorizes nobody, observation happens after the decision,
and project admin is the documented recovery path.
The other critical is a divergence primitive: pull resumed at an op
COUNT, and round 4 had just taught Parse to skip bad lines silently. One
undecodable line inserted among lines a device already counted shifts
every appended op down by one, so two devices replaying one journal hold
different states permanently — and the peer picks the split. Now resumed
at a byte offset.
Also: a mid-run edit to .bdrive/config.json moved a whole project to a
remote of the writer's choosing, with no restart and no credential, and
the daemon then pulled from it; verify-on-read was defeated by uploading
honest bytes first, since a presigned URL is replayable for its TTL;
appendOp's lamport wrapped int64 and silently broke last-writer-wins for
every later upload in the project; peer journal strings reached bdrive
log's terminal unescaped, so the audited party could rewrite the audit
(OSC 52 to the clipboard, \r to repaint a delete as a put); path_raw let
one journal line name two different files to two reader versions; Stop
signalled whatever pid a 0644 file named; locked() failed open, so status
lied and stop stopped nothing; and a macOS path containing "&" made the
autostart plist unparseable while Install reported success.
Two four-round deferrals are now answered rather than carried.
Dir==nil/Auth==nil is not reachable — nine real configurations, both
arms, real project ids — and is a guarded invariant. The Postgres NUL
question was swept across seven stored-record surfaces on a live
Postgres: no silent-loss path, and cleanUploadPath now refuses control
characters so it is unreachable through the API.
Quota became a reservation with reconciliation: reserved at the grant so
concurrent grants cannot oversubscribe, charged on arrival, released free
on expiry, and charged once.
Recorded, not hidden: permHub builds a hub with Devices == nil, so round
4's ownership binding was inert in that fixture and earlier "clean"
results measured through it proved less than they looked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 30 more holes found by adversarial round 6
Round 6 attacked round 5's fixes, the completeness floor (templates, CLI
commands, the untested exported API), and — new this round — the test
suite itself. 290 TestSec_* now green.
One hacker reverted 33 of the accumulated fixes one at a time to see
which the suite actually caught. 28 held. Five did not, and those five
are the most valuable result of the round: permHub built its hub with
Devices == nil, so a dozen journal-pushing tests had been proving
permission and never ownership; the ownerless-legacy-row test passed
because its helper never set Op.Device, so the r5 hole it guards would
have gone undetected; nothing asserted that an outstanding presigned
grant counts against the quota cap, which is half of reserve.go's
contract; row 6 claimed cleanUploadPath refuses control characters and
named no test, and that guard is what keeps the Postgres NUL divergence
unreachable; and unsafeRel — round 3's headline client fix — could be
deleted with the suite green, surviving on round 4's UnderRoot. It also
accepted ".", contained only because hashFile happens to fail on a
directory first.
The criticals: round 5's byte-offset pull resume was the same divergence
primitive it replaced, twice. A peer that publishes in two stages and
cuts the first mid-line makes one chosen device permanently skip the op
that straddles the cut while every other device applies it. And round 5
deleted the shrink guard, so a peer withdraws an op every device already
applied — the file vanishes from teammates' folders with no delete op,
nothing in the journal, nothing in History. Separately, Deny removed an
account but every authorization decision downstream keys on email, so
grants and org roles stayed attached to the address: re-registering it
walked back in as project admin, and its public share links kept
serving.
Also: reset and verification mail took its link host from the request, so
an unauthenticated stranger could have the hub mail a victim a genuine
reset link pointing at the attacker's server; a refused password reset
reported "Password updated"; share revocation, approval, policy and
account removal all took effect in memory after the store refused them,
each failing in the widening direction; one journal push with a year-2300
timestamp overflowed the History cursor and hid the whole audit feed past
page one; account ids were 32 bits with no uniqueness check, and the
birthday bound is ~9,300 accounts for a 1% chance of silently
transferring one account's credentials onto another; the new reservation
ledger had a data race on the billing path, a check-then-act that let 5
of 16 concurrent grants through a cap fitting one, and released arrived
bytes unbilled on expiry; safeField stripped C0 but not the 8-bit C1
controls that are CSI/OSC/DCS/NEL in any xterm-lineage terminal, nor
bidi overrides; and internal/templates — first contact — bypassed
cleanUploadPath entirely and wrote through symlinks using the shipped
template with no hostile input at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 29 more holes found by adversarial round 7
Round 7 drove `bdrive init` end to end for the first time — named the
largest gap by two consecutive CISOs — and tripled the sabotage sweep to
53 reversions. 326 TestSec_* functions green.
init held two criticals the moment it was actually run. `init --server`
took the no-auth branch when a server answered {"auth":{"enabled":false}}
and rewrote settings.Server without touching settings.Token — and
settings.Server is the entirety of round 4's token binding, so a 30-line
HTTP server collects the real hub's bearer token. And `init $BDRIVE_HOME`
was accepted, because the .bdrive reserved-directory rule only applies to
segments below the mount root: from there settings.json is an ordinary
top-level file, so the first cycle pushed this device's token to the hub
as project content, for every member and every teammate's disk.
Two more criticals were old fixes on doors they never covered. Round 4
bound the device token to settings.Server's origin in remote.deviceToken
— the sync backend's door — while share.go reads its destination from the
folder's .bdrive/config.json and hands the token straight to it, and the
CLI's own http.Client had no CheckRedirect at all. And round 6's offboard
only log.Printf'd RemoveMember's "cannot remove the last owner", so
anyone signing up on a removed sole-owner's address inherited org
ownership and admin on every project in it.
The sabotage sweep found 8 more guards deletable with the whole suite
green, including MayActAs (every existing test planted an id that
validDeviceID rejects first, so the ownership loop was never consulted),
both framing headers (round 3's test held the disjunction, not the code),
and sqlAccountRepo's id guard — where the untested backend is the one
managed and Postgres deployments run. For the first time the sweep also
covered the three choke points themselves: reverting requirePerm turns 30
tests red, projectPerm 21, authGate 9.
Also: two more journal-undo primitives past round 6's count guard, now
keyed on identity; reset-mail poisoning survived because the pin was
first-request-wins and round 6's own reproducer sent the honest request
first; the /store/* journal door accepted paths /upload/commit refuses,
so the three spellings of one path rule became one exported predicate;
bdrive forget injected .bdriveignore rules outside any managed block;
bdrive resume built a volume path from an unvalidated registry key; round
5's $HOME-is-a-git-repo fix broke again on a string compare of two
spellings of one path, silently disabling every agent hook on the machine
while init reported success; and the CheckWrite call round 6 moved under
the hub-wide ledger mutex stalled every project's sync cycle.
Known open: a peer can still un-publish an applied op by corrupting its
line and appending as many as it removed — the clean close is hub-side
append-only enforcement on /store/*, a behaviour change no failing test
demands yet. The init tests all ran with auth.enabled false, so the login
flow inside init — where the first critical lives — is still untested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 26 more holes found by adversarial round 8
Round 8 drove the authenticated login flow, the last five CLI commands,
and sabotaged row 15 exhaustively. 621 TestSec assertions green.
Three of round 7's fixes were half-fixes. Its $BDRIVE_HOME guard closed
the child direction only, so `bdrive init` on any ANCESTOR of the home
still pushed this device's bearer token to the hub as project content —
and a relative $BDRIVE_HOME disabled the guard entirely, reopening the
original critical. Its mail fix still seeded the pin from r.Host, so on a
fresh process an anonymous stranger could have the hub mail a VICTIM a
valid reset link on the attacker's server; the mirror image turned
password recovery off hub-wide with one request. And the hole round 7
declined to close got a reproducer that also invalidated its proposed
remedy: a peer publishes its last op unterminated, the next append fuses
onto that line and stops it decoding, so an op every teammate has on disk
vanishes with no delete op — through a byte-level PURE APPEND, which
hub-side append-only cannot stop.
Two more criticals were first contact. The loopback login callback had no
proof of possession: its only binding is a `state` that is printed to
stdout and passed to xdg-open as argv[1], so any local process that can
run `ps` signs the device in as its own account and the user's folders
sync into the attacker's project. And the three /store/* READ doors call
observeDevice as their first statement — round 5 moved it after the
decision on the write door and never touched the read doors — so one GET
with a victim's device id first-claims it hub-wide and locks that device
out of its own journal, from read permission on any single project.
The sabotage sweep is the round's most important result. 48 guards in row
15 reverted one at a time: only 20 were caught. A 57% false-negative
rate, nearly 4x rounds 6 and 7. The materialize DELETE loop's three
guards — one of which ends in os.Remove — were held up by nothing,
masked because scan's delete pass applies the same rule to the same cache
first, so no whole-Cycle fixture can tell which guard refused.
absorbLamport's ceiling and tickLamport's stop mask each other, so the
existing test passes with either removed. Eight of the 26 misses now have
tests; 18 remain open, and row 17 was never reached at all.
Also: a hostile export archive chose which of your existing projects it
landed in (create-or-join-by-name, emptiness checked after the join); a
folder that merely arrived on disk stole an enrolled mount's registry
row, so at next login the real project's daemon ran on the arriving
folder; one device approval minted N tokens and bound them to a device
the human never approved; logout left the credential live with no
revocation route; and two unbounded reads on the device side had the
declared size in scope at the call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 5 more holes found by adversarial round 9
Round 9 spent most of its budget on sabotage rather than new attacks, and
the numbers are the point. 705 TestSec assertions green.
Row 17 swept in full: 44 reversions, 9 missed (20.5%). Rows 13 and 18-22:
57 reversions, 12 missed (21% overall — row 13 at 40%, row 20 at 33%,
row 18 at 0%, the only perfect row swept in any round). And row 15's 18
remaining untested guards got tests, each verified red under its own
reversion — including both of round 8's flagged leads, which turned out
to be correct guards that were merely untested. That agent reported dry.
So roughly 29 previously-deletable guards are now genuinely pinned.
All five live holes are regressions in round 8's own fixes. Its
re-assertion fix re-published a withdrawn op at its original low lamport,
which made it a losing local unpushed op the instant it was written — so
conflictCopies did what it exists to do and the victim created, signed
and pushed a file holding content the peer chose, at a path that never
existed. The same admission rule had no guard for withdrawn deletes and
consulted neither the ignore filter nor neverSync, so a device
republished paths it deliberately refuses to materialize. Re-assertion
now requires that this folder's own cache stands behind the op.
Its sizeBound fix returned on a sha mismatch instead of skipping, so one
understated Op.Size in one line of a peer's journal permanently withheld
every blob queued behind it — before round 8 the read was unbounded, the
sha matched, and the files arrived. The bound was not the defect and is
unchanged; the error is now remembered and returned after the batch.
Also: the org heir was chosen by the smallest email address rather than
the longest-standing member, so the newest member inherited ownership and
project-admin on every project when a hub admin removed a departed
employee; $BDRIVE_HOME was created 0755 by LoadDevice, which runs before
almost everything, so listing alone named every project, every device in
the fleet and every content hash without opening one of the 0600 files;
and round 8's ResolveMount condition stranded a genuinely moved project
behind a leftover config, with init itself blocked on the same check.
Move-vs-copy is now decided by dev+ino, which a rename preserves and a
copy cannot reproduce.
Judgement call recorded: the nested-mount carry reverts green but stays.
Deleting a defence-in-depth guard because the tests did not notice is
exactly the reasoning the sabotage table exists to distrust. It is marked
as not counting toward coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 30 more holes found by adversarial round 10
Round 10 drove three surfaces end to end instead of sweeping, and the
contrast is the round's real result. Round 9 spent itself on sabotage and
found 5 holes; round 10 pointed a device at a hostile hub, executed the
Linux autostart code for the first time in ten rounds, and requested the
three /auth pages nobody had ever fetched — 30 holes. 777 TestSec
assertions green.
Row 19 is the clearest data point the loop has produced. A round-9 sweep
scored it 12.5% missed and annotated it "no reachable impact", so it
wrote zero tests. Driven end to end it held 11 holes, one critical: pull
skips a listed journal only on an exact string compare, so a hub listing
journal/DEVA.jsonl resolves to the same file as deva.jsonl on APFS and
NTFS and overwrites the device's OWN journal — the invariant the whole
concurrency design rests on. A sweep can only find a hole where a guard
exists; it cannot find a class nobody wrote a guard for, and every
critical since round 7 has been that kind.
Also from the hostile hub: one unusable listed key hid every peer
permanently; one listing minted 200k local journal files; the hub sized
the device's own allocation at two layers, and one boolean in a sign
response made a device publish an op for content it never sent, with the
cursor advanced past it so it never retried. putDirect shipped file
bytes to any host the hub named — round 4 dismissed this because "the hub
already holds the data", but at the moment it names the destination it
does not, which is what the upload is for.
Row 5's device binding is closed after four rounds of deferral. A
read-only member's device could never register, so any member with write
anywhere took its id permanently — and the arm that let them through read
a field the attacker writes. The id is now minted hub-side at login,
bound to the authenticated account, at all three mint points. That was
first framed as requiring the supersession of round 7's test; it did not.
Round 7 asserts a read door creates nothing, and that property is
unchanged and strictly stronger, because the read door now has nothing
left to claim with.
Four of round 9's five fixes had live residuals: re-assertion laundering
returned on any device that cannot push (conflictCopies measures unpushed
against a cursor that only advances on success, and read-only is the
documented steady state); the sizeBound fix still let one peer integer
suppress the victim's own push; earliestMember was inert on every
upgraded hub; and the dev+ino discriminator was inert on every row that
existed. ResolveMount turned out to be a write with a read-shaped name,
so bdrive restore and forget enrolled the device in projects it was never
init'ed into.
Recorded as a measurement gap, not a finding: row 14 was scored clean on
every backend for seven rounds, but this is the first round ever run with
a Postgres DSN, and metaBackends silently omits the arm without one. A
skipped arm and a missing guard are indistinguishable in a green suite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 24 more holes found by adversarial round 11
Two criticals, both first-contact findings on surfaces ten rounds had not
reached.
An `.xml` file got script on the hub's own origin with the reader's session:
`sandboxInline` walled off a LIST of content types where the thing it protects
is a PROPERTY ("the browser parses this as a document"), and the whole XML
family sat outside the list while having the property — an XML document carries
its own `<?xml-stylesheet type="text/xsl"?>` and the XSLT output is HTML in the
origin that served it. `inlineMarkup` is now the property, `inlineType` serves
the XML family inline as text/plain so nothing parses it as a document, and
`nosniff` goes on every stored-bytes door.
A plain member replaced another account's journal by spelling her device id in
a different case: every hub ownership decision was a byte compare while APFS
and NTFS fold, so one login and one PUT broke the one-writer invariant the
whole concurrency design rests on. `canonDeviceID` folds at the trust boundary,
the registry folds on load and at every entry point, and `ownJournal` requires
the canonical journal key.
Also: org ownership was drawn by Go map iteration (`sort.Slice` on an all-zero
`Created` column) and now needs real evidence of age or produces no heir; a
revoked grant was restored by any unrelated write from a second hub process
(grant writes are row-scoped now, on all three backends); `/history` named
whoever the pushing device typed; the admin recovery arm locked the real owner
out of `bdrive login` forever, across the org wall; `bdrive scope` was the
unescaped door `forget` used to be and could wipe the team's synced rules;
`journal.SafePath` let every bidi control and every C1 through.
Two tests were touched, both disclosed in .claude/security-goal.md:
TestSec_DB_NULBytesDoNotTruncateRecords is retired (its assertion is one
Postgres cannot implement), and TestSec_Scope_AddCannotCreateADirectoryOutside-
TheProject is rewritten against the guard it was meant to test — it called
os.MkdirAll itself, so no production code sat between its setup and its
assertion. The rewrite was verified to go red with the guard removed.
918 TestSec assertions green, whole suite green including Postgres and -race,
108/108 Playwright, Linux container clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): the run-mode note only printed when the suite was already red
Round 10 moved the "postgres UNTESTED in this run" note from t.Log to
os.Stderr because t.Log is invisible without -v. That was not enough: `go
test` buffers a package's output and discards it on success without -v,
stderr included. So the note that exists to make a silent coverage gap loud
was itself audible only during a failure — the same shape as the hole it
guards against, and the reason it went unnoticed is that every round that
read it had a red suite in front of it.
secrunNotify now also writes to /dev/tty, which survives that buffering.
Verified under a pty on a fully passing run with no -v.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 24 more holes found by adversarial round 12
The critical one is the fifth instance of "something survives offboarding":
an org invite outlived the membership, the ownership AND the account that
minted it. OrgDB.Redeem/ValidInvite now resolve the minter's ownership at
read time — the rule shareCreatorStillBelongs already applied to a share
link — and retire the invite when it fails, so EvictMember's heir promotion
cannot revive it.
Round 11's row-scoped write landed on ProjectRepo only. OrgRepo, ShareRepo
and DeviceRepo had the same whole-record shape, so a second hub process's
unrelated write resurrected a revoked org membership, a revoked /s/ link,
or erased a device binding. And the authorization READ path never got it at
all: ProjectDB answered from a copy taken at boot, so a revocation took
effect on one process and no other.
Also: a push could credit another account through Op.Author; journalOps
checked the Note and not Author/UserName; a display name skipped trimText;
a password reset left outstanding reset and verification mail grants alive;
nosniff missed two stored-bytes doors; SafeText admitted the zero-width
formats; a project name could break out of the ConnectGuide paste prompt;
inviteTokenFromNext matched "/join/" anywhere in `next`; file content chose
what read-log reported as a read, and the hub recorded reads for paths that
do not exist.
Frontend: decodePath threw URIError on a link in a teammate's document and
unmounted the whole SPA persistently (fixed at the decode, plus a real
ErrorBoundary), and the router kept the Object.prototype lookup bug round 11
fixed in ProjectIcon.
Decision, not a patch: agent HOOK config (.claude/settings.json and
friends) is now reserved in both directions. Skills, commands and CLAUDE.md
deliberately are not — sharing what an agent reads is the product. The trust
boundary that follows from that is now written down in INSTALL_FOR_AGENTS.md,
the docs' Start-here path, and README.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 17 more holes found by adversarial round 13
Round 12 gave ProjectDB a read-path refresh() because a revocation only took
effect on the process that served it. Four sibling registries with the
identical defect went unexamined for a round.
The refresh family (one fix, three service structs + two file repos):
- OrgDB.refresh — the wall IN FRONT of project permissions. A removed org
member kept reading every project in the org; a revoked invite still
redeemed and, on the default invite-only posture, bootstrapped the account.
At the top of the MUTATORS too: the last-owner guard is a cross-process
TOCTOU the write-side re-read cannot close.
- BuiltinAuth.refresh — the CREDENTIAL, not a grant on top of one. A revoked
device token still authenticated; a deleted account signed in again with its
old password.
- ShareDB.refresh — a revoked /s/<token> was still served to anonymous
strangers. fileShareRepo.reload's own r12 comment named this row.
- fileAccountRepo.reload, fileReadRepo.reload — the last two file repos with
no write-side re-read.
It lives in the service structs, not db_file.go: the staleness reproduces on
sqlite and Postgres too, and a file-only fix would have left the
two-replicas-one-database deployment fully broken.
Also:
- offboard now releases the device binding (DeviceRepo.Delete +
DeviceRegistry.Release). A deleted account kept a hub-wide claim on its
device id, which silently and permanently locked out the next hire.
- SafeText and trimText refuse unicode.Cf and the tag block AS A CLASS. The
tag block encodes all printable ASCII with no glyph, so a project name
rendering as "wiki" smuggled a shell command into the agent paste prompt.
- .mcp.json is reserved; the agent-config list is now derived from what each
platform LOADS, not from what BearDrive writes.
- pageDevice and X-Bdrive-Device-Name go through trimText: an unauthenticated
stranger chose the text AND the length of the hub's only consent surface.
- SetPolicy runs the startup validator, so POST /api/admin/policy cannot reach
a posture the binary refuses to boot in.
- Insights uses Object.create(null): a folder named __proto__ erased an agent
device from the Dashboard.
go build / go vet / go test ./... clean with and without BDRIVE_TEST_POSTGRES;
-race clean; Playwright 127/127. Linux container run not completed — see
.claude/security-goal.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close the agent-onboarding holes found by round 13's fourth hacker
Four findings, all against the already-hardened tree. Its fifth (.mcp.json)
was already closed and its test passes — an independent confirmation of that
fix from a second angle.
1. A peer's `!.env` in the shared .bdriveignore uploaded another member's local
.env on their next cycle. Round 4 made .bdriveignore team-wide on purpose
and made `sync --prune` refuse on `!` rules for exactly that reason — but
that reasoning covered DELETION, and nobody asked what a pulled negation
does to SCAN. The runbook's own `bdrive init . --only docs,notes` is what
creates the exposure: the whole repo goes under the mount with only this
synced, teammate-writable file holding the rest back.
Fixed asymmetrically at the upload door (Filter.SkipUp, consulted by
walkFolder): pulled rules that NARROW apply immediately in both directions;
pulled rules that WIDEN apply to materialize but not to scan, until this
device authors the rules itself (init --only, bdrive scope, an editor). A
joining device has authored nothing, so team-wide scope still works on day
one — which a blanket "ignore pulled negations" would have broken.
`bdrive scope --explain` reads the same floor so it cannot drift.
2. A FAILED `init --server <url>` signed the device out of its real hub and
left it defaulting to the new one — after a run that ended in "Error:".
ensureLogin now returns a rollback; initCmd commits the session only once
the hub has answered with a project this device can open.
3. `init --server http://…` minted and stored a device token with no plaintext
warning while `bdrive login` on the same URL warned — and step 2 of the
runbook is titled "Do not run a login command". The warning moved from
loginCmd's RunE into the shared runLogin: one sign-in door, one warning.
4. The hub chose the device-login link and the CLI printed it verbatim under
its own "open this link in any browser". sameOriginLink falls back to the
hub's own /auth/device when scheme+host differ.
Also: safeField gets the same unicode.Cf + tag-block class rule SafeText and
trimText got — third door, same class. Scoping guide documents the widening
rule. Two findings that are not tests (the runbook URL pinned to a mutable
branch; nothing authenticates the hub during device sign-in) are recorded in
known-open.
go build / go vet / go test ./... clean with and without BDRIVE_TEST_POSTGRES;
-race clean on webapp, syncer, store, cmd/bdrive; Playwright 127/127. Linux
container run still not completed — the Docker daemon on this machine will not
create containers at all; see .claude/security-goal.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 22 more holes found by adversarial round 14
The last round of the loop. Four hackers landed; 21 of their tests go green
here, the 22nd names a hole that IS fixed but cannot pass as written (see
below). `.claude/security-goal.md` gains a handover section for a human in
place of the "next round's targets" framing.
Sync / scope (internal/syncer)
- A peer DELETING the shared `.bdriveignore` walked straight past round 13's
upload floor: the `IgnorePulled` bookkeeping sat behind `if want, ok :=
target[IgnoreFile]; ok`, so a delete never updated it while materialize
unlinked the local copy anyway — and the next cycle read the absent file as
locally authored, dropped the floor AND the live rules, and pushed the whole
repo. Recorded as pulled now.
- Both SyncState fields are `omitempty`, so on its first post-upgrade cycle
every existing device adopted whatever was on disk as its own — including a
peer's `!.env` that landed one cycle earlier (scan runs before pull, so it
always does). An upgraded device now seeds its floor with `vouchedFloor`:
keep a `!` line when the path it re-includes is already in this mount's
materialization cache, drop it otherwise. That is what keeps a `bdrive
scope` block (`/*` plus nothing but negations) from silently ending uploads
— pinned by TestUpgradedScopedDeviceKeepsUploading.
Second-process staleness (internal/webapp)
- ProjectDB had refresh() on Get and List only. `put`→`PutMeta` is an
unconditional upsert, so a second process's ordinary rename put a DELETED
project back carrying the org the public-link rule reads; the last-ADMIN
guards counted admins out of the boot-time map; GetOrCreate answered
create-or-join differently per replica. refresh() now runs at the top of all
ten mutators.
- DeviceRegistry had no refresh() at all — round 13 cleared it on the
bind-away direction alone. Offboarding released a device claim on one
process and no other: the next hire is locked out, and a re-created address
inherits the departed account's journal write gate elsewhere. Pinned on
file, sqlite and postgres.
Audit trail
- `/store/object` was a plain object PUT with no relation to what is stored,
so any member could rewind their own journal — or, after inheriting a
reassigned device id, a departed member's — out of History. Journal pushes
now must keep every op Seq the hub already holds.
- seedTemplate journaled the hub's own template files under the account that
ran `bdrive init --template`, byte-identical in shape to a hand upload. They
now carry no account and a "seeded from the <name> template" note.
Text and rendering
- journal.SafeText refused every category-Cf rune and missed U+2028/U+2029
(Zl/Zp), which the webapp's own trimText has deleted by number since round
12. A folder row for `line<U+2028>sep.md` paints to exactly the same glyph
run as `line sep.md`.
- A strong-RTL LETTER needs no format character to reorder a rendered row.
Measured in Chromium, `unicode-bidi: isolate`, `plaintext` and `<bdi>` all
leave it intact; `isolate-override` fixes it, and peer-written-name
selectors now carry it (SPA + the auth pages' device-approval rows).
- `)` closes the paste prompt's clause exactly as `"` did; project names now
drop both parens (org/device/account names are unaffected). PATCH
/api/projects/{id} called trimText where create called trimName, so rename
stored `/` and `\` — one rule for both doors now.
CLI
- `p.Template` was the one hub-chosen field in `bdrive init`'s output that
never reached safeField.
- `--template` reported the hub's own string as proof and never looked at what
arrived; it now always falls through to the idempotent seedLocally.
Docs
- INSTALL_FOR_AGENTS.md no longer raises the hub-seeded AGENTS.md to the
user's authority, and its trust boundary names the hub as an author of
folder content. Documentation defects, not demonstrated exploits: three live
headless runs did not flip behaviour.
Known red, deliberately not worked around:
- TestSec_ProjectName_RenameBypassesTheCreateNameRule — the hole is fixed and
separately verified; the test's own control creates a project with the
normalized name in the same org before renaming into it, so correct
behaviour collides with the unique-name-per-org rule.
- Two e2e/sec14fe specs upload a U+2028 path to demonstrate a rendering
collision that this commit's ingest fix now refuses at the door.
go build / go vet clean. go test ./... green with and without
BDRIVE_TEST_POSTGRES and under -race, except the test named above. Playwright
serial: 131 passed, 2 failed (the two named above).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* test(security): the rename test collided with its own control
TestSec_ProjectName_RenameBypassesTheCreateNameRule created a project
from the same payload it then renamed a second project to. Both
normalize to "notes....etc", names are unique per org, so the rename
400'd on the collision and the test failed at its control check without
ever reaching the assertion it exists to make.
It failed that way against the FIXED code, which is the worst way for a
test to be wrong: it reads as an open hole and is really a broken
instrument. The control now uses a payload that normalizes to a
different name.
Verified load-bearing rather than merely green: reverting Update's
projectLabel call turns it red with "rename stored a path separator in
a project name: notes/../../etc".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* docs(architecture): draw the security hardening's new types and seams
The branch added shared rules and gates that the diagrams did not have
boxes for, so the pictures no longer described the code.
cli-sync: the three drifting path/text checks collapse into one
journal.SafePath/SafeText box, plus store.UnderRoot and
config.ReservedPath as their own single-rule boxes; Filter gains SkipUp
and AcceptRules and the scan/materialize rules stop being symmetric;
SyncState is drawn for the IgnoreAccepted/IgnorePulled floor; the
registry records Dev/Ino and splits ResolveMount (read, self-heal) from
EnrollMount (the only writer); the daemon's signalled pid moved inside
the flock.
webapp-server: DeviceRegistry is keyed (account, id) with
Bind/Release/OwnerOf/MayActAs; new boxes for the /store journal door,
the quota reservation ledger, sandboxInline and offboard; refresh() on
every service; the row-scoped repo interfaces, the storable validation
gate and the schema-version guard in the MetaStore block; HasBlob became
BlobSize.
webapp-frontend: ErrorBoundary, the app's floor. Also fixes escaped
quotes in a note that have been rendering this whole diagram as an error
box on GitHub since it was written.
overview: unchanged — no package appeared or disappeared and no
cross-piece flow moved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* test(security): re-aim two specs their own fix made unbuildable
Round 14 refused U+2028/U+2029 at ingest. Two e2e specs proved a
*rendering* collision by uploading exactly those code points, so the fix
made their fixture impossible and left them red — a broken instrument
reading as an open hole, the same shape as the rename test.
The listing spec now asserts the ingest guard instead, and keeps the
measurement that says why the guard matters: the two names painted to
70.9844 x 16, one line box, byte-different and pixel-identical, measured
in Chromium with Range.getClientRects() over live text nodes. Relax
SafeText and it goes red before the collision returns.
The shares-audit spec is skipped with its numbers preserved. Its
reachable sibling — a strong-RTL letter, which cannot be refused without
refusing Hebrew filenames — is covered by the neighbouring spec and
fixed in style.css.
Playwright: 133 passed, 1 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* test(security): a comment in a spec leaked a CSS rule into the bundle
Tailwind's content scanner reads the e2e specs. A bare `isolate` token in
prose explaining the bidi fix emitted `.isolate{isolation:isolate}` into
the shipped bundle — a rule nothing uses, and a stale-assets failure for
check-dist.
Reworded to avoid the token, with a note saying why, since the next
person to explain a utility class in a comment will hit the same thing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* docs(security): correct three handover entries the landing pass closed
The handover listed one Go test and two browser specs as permanently red.
All three were re-aimed after the loop stopped: the Go test was failing
against the FIXED code because its own control collided with it, and the
two specs were fixture-blocked by round 14's own ingest fix.
Suite is 1052 TestSec assertions green, 0 red; Playwright 133 passed,
1 skipped. The stopping condition itself is still not met and the notice
at the top of the file says so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(sync): a Persian filename permanently wedged a device's sync
The scan door applied config.ReservedPath; the hub's ingest door applied
journal.SafePath AND config.ReservedPath. A file whose name the scan
accepted and the hub refused was blobbed and journaled locally, and
because push PUTs the whole journal object, every later push from that
device 400'd on the same op forever. Renaming did not help — the delete
op names the same path. The only surface was a line in daemon.log, and
recovery meant deleting the volume store.
walk.go's own comment already stated the rule it was breaking: "the
outbound half has to match the inbound one." It just did not name every
predicate the inbound half applies.
Two changes:
SafePath now permits ZWNJ and ZWJ, which the Cf class rule refused. Both
are orthographically required — U+200C is what makes "میروم" the right
word in Persian and is mandatory in several Indic scripts, and U+200D
builds most multi-person emoji. Refusing them did not harden a hub; it
told those users their filenames were illegal. The confusability they
buy is also already reachable without them: a Cyrillic homoglyph
produces the identical "two rows, one reader" tree and is allowed. So
the clause was paying a hard i18n cost for a partial mitigation of a
class that stays open. A note has no orthography, so SafeText still
refuses all four zero-widths; the two rules now share one implementation
with a flag rather than diverging.
walkFolder applies SafePath, so an unsyncable name is simply not carried
— it shows up in `bdrive scope --explain` like any other exclusion and
never enters a journal.
The regression test asserts a PROPERTY over both predicates rather than a
character list, so adding a rule to one door alone reopens it. Its first
draft was vacuous — it skipped every unsafe name instead of creating it,
so nothing hostile reached the scan and deleting the guard changed
nothing. Caught by sabotaging it; it now creates the names unix permits
and goes red when the guard is removed.
Found by a CTO review of PR #112, not by the security rounds, which
checked this asymmetry on the hub and never on the client.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(hub): a proxied hub throttled all its users as one on day one
Upgrading a hub behind nginx / Caddy / Fly / Cloud Run without editing its
config made every user share ONE 10/min login bucket and capped public share
links hub-wide at 120/min: clientIP fell back to r.RemoteAddr, which is the
proxy. Correct passwords started answering "too many attempts", with no log
line saying why.
X-Forwarded-For is now trusted by PEER rather than by configuration: a proxy
that fronts a hub reaches it over loopback or a private address (sidecar,
container network, Fly/Cloud Run internal hop), so that header is the
operator's own infrastructure. A hub on a public IP still ignores it, and now
logs once instead of failing silently. trust_proxy remains the override for
the one shape the peer check cannot see — a proxy on a public address.
Which hop is taken is unchanged (last element of the last field line), and the
round 13/14 tests that pin it stay green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* perf(hub): authorization stopped re-reading the whole registry per request
Every authorized request re-read the entire project, org and account registry
under a hub-wide mutex — 14.1 ms per request at 5k projects on the file
backend, nine unfiltered SELECTs on Postgres — so the hub served roughly 60
req/s regardless of cores while ~200 devices polled /store/list every 10s.
None of this existed before the security rounds; registries loaded once at
boot.
The semantics do not change. Registries still re-read the store before every
authorization decision — that is the correctness floor rounds 12-14 built, and
a TTL would put back exactly the staleness window they closed. What changes is
the cost of asking whether there is anything to re-read:
- new optional Versioned repo capability: one os.Stat (file) or one
primary-key lookup on a per-registry meta_version counter bumped inside
every write transaction (SQL). A repo that cannot answer is treated as
changed, so the fallback is the unconditional re-read that was always there.
- proj() resolved the project and then projectPerm resolved it again;
projectPermOf takes the Project the choke point already has. handleProjectList
and the org share audit did one resolution PER PROJECT in a loop; both now
pass the row they are already holding.
Measured on M1, benchtime=200x, one project resolve + permission check:
file 100 projects 331 us -> 6.0 us
file 1000 projects 2.85 ms -> 3.9 us
file 5000 projects 14.14 ms -> 3.9 us
sqlite 100 projects 295 us -> 22.9 us
sqlite 1000 projects 2.57 ms -> 22.5 us
sqlite 5000 projects 10.86 ms -> 21.5 us
and it is now flat in project count rather than linear.
The file backend does NOT become multi-process-safe from this: every write is
still read-modify-write-rename, and the mtime+size token would miss two
processes writing the same byte count within one filesystem timestamp tick.
refresh narrows the stale-read race; it does not close it. SQL is the fix.
TestVersionGateSeesAnotherProcessWrite pins the property that matters — a
second process's create and grant change are both visible through the gate, on
file, sqlite and Postgres. BenchmarkRegistryRead / BenchmarkAuthorizedRequest
go linear again if this regresses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* perf(hub): every blob read on S3/GCS paid double egress and a full hash first
RemoteSource.verify re-read and re-hashed the whole object before returning a
second read for the actual stream, on any backend that can presign — and
PutSigner survives the Prefixed wrapper, so this was live on every S3 and GCS
hub. Every viewer open, render, download and /s/* share hit paid 2x
object-store egress and a serialized full-object hash before the user's first
byte. On file:// (the OSS default) verify is a no-op, which is why the suite
never felt it.
The check is not weakened. It is cached again, keyed on the one thing that
makes "blobs are immutable" true rather than assumed: BOTH presign doors
refuse to sign a key that already exists, so every presigned URL a blob ever
gets was minted before its first PUT and dies at mint+TTL. Once the stored
object is older than the presign TTL, no live URL for it can exist and none
will ever be minted again — the hub is the only writer left, and the hub hashes
what it relays. Only then is the verification cached. The object's age is read
after the hash, so a replay mid-check reads as seconds old and is not sealed.
Measured, 4 MiB blob, 200 reads (the "unsealed" arm IS the old code path):
before 2.41 ms/op 2.000 storage reads per blob read
after 0.39 ms/op 1.000 storage reads per blob read
On S3/GCS the second read is real egress and real latency, so the win is
larger there than this local stand-in shows.
NOT done: signing the content hash into the presigned URL. GCS cannot bind a
SHA-256 at all — x-goog-hash takes only crc32c and md5, and the md5 would be
declared by the same client that declares the sha, so a chosen-prefix collision
defeats it. On S3 the SDK hoists ChecksumSHA256 into the query string rather
than into SignedHeader; it is inside the signature, but whether S3 enforces a
hoisted checksum (and whether an unsigned request header would override it)
cannot be verified without a real bucket. With the seal in place the checksum
would add no security that verify is not already providing during the only
window it applies to, so it stays out rather than going in untested.
Backends that can sign but cannot bind a content address: GCS certainly, S3
pending a live check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* docs: record the three PR #112 cost fixes and what they do not fix
architecture/webapp-server.md gains Versioned/versionGate, RemoteSource's
PresignTTL + seal, remote.Object.Modified, and the new clientIP peer rule.
Both mermaid blocks parse-checked with mmdc.
.claude/security-goal.md's "known-open, deliberately deferred" list is updated
with what each fix accepts: the private-peer widening on X-Forwarded-For, the
fact that the file backend does NOT become multi-process-safe from the change
token (read-modify-write-rename is unchanged; mtime+size narrows the race and
does not close it), and why the presigned content hash was left out — GCS
cannot bind a SHA-256 at all, and S3's binding lands as a hoisted query
parameter nothing here can verify S3 enforces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(hub): the blob seal compared two clocks that need not agree
The verification cache is sound on one argument: once a stored blob is
older than the presign TTL, no live URL for it can exist, so the bytes
cannot change again and the hash need not be recomputed.
That argument is about time, and the two times came from different
machines — o.Modified is the object store's clock, time.Since is the
hub's. A hub running ahead of storage overstates the object's age and
seals it while a minted URL is still live; a replay through that URL is
then served from cache for the rest of the process's life. NTP makes it
unlikely and a container without it, or a VM resumed from suspend, makes
it reachable.
Seal after the TTL plus an hour instead. Sealing early buys nothing —
the blob is immutable either way — so the margin costs a few extra
hashes on a young blob and removes a dependency on two clocks agreeing
that nothing in the process can verify.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* test(hub): age the blob-seal fixtures instead of shrinking the TTL
The clock-skew fix in
|
||
|
|
c8ab5504f7 |
feat(webapp): history can be narrowed by path, author and date (BEA-67) (#113)
* feat(webapp): history can be narrowed by path, author and date (BEA-67) The project History view was a flat scroll with no controls but per-row restore/open/download. Fine at eleven rows; unreadable after a month of agent writes, and agents write far more than people do. Four reader filters on GET /api/p/<id>/history — q= (case-insensitive substring of the path), user= (exact account), since=/until= (UTC bounds, inclusive at both ends, RFC3339 or a bare YYYY-MM-DD). They compose with each other and with the existing path=/prefix= scoping, and they are applied in the same walk as path/prefix — BEFORE the sort and the cursor skip — so next_cursor keeps meaning "the next matching entry" and paging under a filter needed no new machinery. kinds[] is still computed over every op, so a filtered view classifies edits the same as the full feed. A malformed since/until is a 400, not a silently unfiltered feed. The filter bar drives those params through the URL rather than component state: a narrowed feed is a link you can send, it survives reload, and Back undoes it. The author list accumulates across fetches — rebuilding it from the current feed would strand a reader who filtered by one author with only their own name to pick from. Zero matches gets its own empty state with a Clear. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(architecture): the frontend diagram parses again The escaped quotes inside the NewProjectDialog note made the whole classDiagram fail to parse — it has been rendering as an error box, not a diagram, since that note landed. Same breakage on origin/main; noticed while adding HistoryFilters to the same file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7861b755ac |
fix(webapp): the reads × freshness chart's busiest dot fits inside its own frame (BEA-60) (#111)
Three defects on one panel, all of them stopping it from delivering its one insight — "these docs are hot but stale, go fix them": - The hottest file plotted at exactly y = M.t with a radius up to 7px, so the most important point on the chart straddled the top border. The plotted box is now inset on both axes by the largest radius the size formula can produce, derived from that formula rather than hardcoded, so a future radius change can't quietly reintroduce the clipping. Thresholds, the danger rect and the dots all read X/Y and shift together; the axis lines use M and stay. - No dot said which file it was — identity lived only in the hover tooltip. The six busiest hot+stale files now carry their basename beside their dot, flipped to the dot's other side rather than leaving the frame and stacked when two would print on one baseline. Placement is a pure function in lib/heat.ts so it can be unit-tested; every dot keeps its title and click. - "dot size = agent share of reads" was drawn inside the <svg> directly under the right-anchored "hot + stale" label, 14px apart. It moved to the panel heading row, capped at the chart's own max-width. The e2e seed grew three hot-but-months-old files: every seeded file was hours old, so the danger quadrant — the whole reason this panel exists — was empty in every test that had ever run against it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d7772a22df |
fix(webapp): restore is not offered on the version that is already current (BEA-57) (#110)
The newest row for a path IS the file's current content, so its `restore` button could only ever journal a +0 −0 change — attributed to a real person, on a real device, replicated to every teammate, in the audit trail the whole history story depends on. It was also the single most tempting row to click. One rule, enforced at both ends. handleRestore now 409s when the requested sha is already the path's head (journal.Replay, the way the CLI already answers this question), placed after the "no such version of that path" 404 and before CheckWrite so an unknown sha still 404s and a refused restore records no quota. HistoryView computes each path's head from the loaded window — entries are strictly newest-first, so a path's first occurrence decides — and restoreSha returns undefined for bytes that already are the head, which removes the button, its title and its busy state together. The rule is content equality, not row index: an older row hand-reverted to the current bytes is just as much of a no-op, and matching what the server checks means the UI can never show a button that errors. A newest DELETE leaves the path out of the replay, so a deleted file still restores — that is a real change. Confirm-on-restore stays out, deliberately (HistoryRow.tsx:154): the defect was never "restore should ask", it was "restore should not be offered where it cannot do anything". Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c5f8114e07 |
fix(webapp): the palette always offers a way back (BEA-52) (#102)
⌘K on a path that doesn't resolve degraded to History ×2 and Sign out — the tree-derived entries are gone on a dead route and the switcher lists only OTHER projects, so on a single-project hub the palette, which is the natural escape hatch there, was the one surface with no way out. Four static entries now lead the candidate list whenever hub && project: Go to project root, Dashboard, Installation, Settings — the same four destinations (and icons) the sidebar has. They're independent of the tree and of whether the path resolves, and each navigates to a real VIEW_ROUTES URL, so a reload renders the same view. Panels only close on a location change, so selecting the page you're already on closes them explicitly. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fe872dd1fa |
fix(dashboard): keep reads for a deleted file on the map (BEA-49) (#100)
The Dashboard's file panels built every point by joining the heat map onto the current file tree, so a heat row whose path had left the project was silently dropped — while the agent-coverage panel below, which does no such join, rendered those same reads. One page, one ledger, two answers. The production consequence is the real bug: delete or rename a well-read doc and its whole read history vanishes from the map, which is exactly the signal the Dashboard exists to give. Hot path now ranks orphaned rows alongside tree files, labelled "no longer in the project" and opening that path's History (the file view would land on the not-found page). The two plots stay tree-only — both position by freshness and an orphan has no mtime, so any position would be invented — but each carries a count of what it can't show. "No reads in the window yet" can now only render when the scope genuinely has none. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ac87fbfde0 | fix(webapp): the e2e harness binds the port before it wipes anything (BEA-50) (#98) | ||
|
|
9f13c70b78 |
feat: ask for a GitHub star where it can't annoy anyone (#108)
* feat: ask for a GitHub star where it can't annoy anyone Three surfaces, all passive or once-per-setup: - `bdrive init` prints one line after a successful setup, TTY-only. init runs about once per project per machine, and the guard keeps it out of CI logs and any output a script parses — putting a star plea in repeating output is what got postinstall ads banned from npm. - The hub sidebar gets a dim "Star on GitHub" link above the account row. A link that always sits there reads as social proof; a dismissible banner would need dismissal state and would still have interrupted. - README grows star/pkg.go.dev/docs badges — until now the only user-facing link to the repo was the docs sidebar. Covered by two checks: the CLI e2e asserts init stays silent about the repo when stdout is piped, and a Playwright spec pins the sidebar link's href/target and that nothing modal appears with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FNgeJVcsXQ5sTfWLgCR4cv * fix(webapp): the star link wears the GitHub mark, at 11px A star glyph next to "Star on GitHub" said the same word twice; the mark is what people scan for. lucide dropped its brand icons in v1, so the path is inline rather than a second icon dependency for one glyph — and it is filled, so it sits outside the `.ico` stroke sizing. Text drops 12px → 11px to match the account email below it: this is the dimmest thing in the sidebar on purpose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FNgeJVcsXQ5sTfWLgCR4cv --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
26334ec328 |
fix(webapp): auth submit buttons answer to button[type=submit] (BEA-53) (#103)
Every server-rendered /auth form shipped a bare <button>. Browsers default one inside a form to submit, so humans never noticed — but the conventional automation selector matched nothing, and the e2e suite carried a "form button" workaround at two call sites to compensate. All five buttons (sign in, sign up, approve, send reset link, set password) now carry an explicit type="submit", and both e2e call sites use the standard selector. Every spec's login() routes through helpers.ts, so a regression fails the whole run at the first sign-in. Markup only: authlocal.go styles button by element, not by [type], so the rendered pages are byte-identical before and after. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9c18c83845 |
fix(dashboard): an empty project says it's empty instead of drawing empty charts (BEA-51) (#101)
A brand-new project's Dashboard rendered ~840px of empty bordered SVG
frames with the "hot + stale" / "hot + fresh" / "cold + stale" quadrant
labels floating over nothing: Treemap and Scatter had no empty guard, and
the quadrant labels come from the HOT_READS/STALE_DAYS constants rather
than from data. Only HotPath said anything, and what it said ("No reads in
the window yet") is the wrong claim — the project has no files at all.
The guard goes one level up in Insights, where the three panel headers and
the lens switcher also live, so those go away too instead of sitting over
nothing. Gated on "no files", never on "no reads": files-with-no-reads is
the other zero state and it already behaves correctly (Treemap pads every
file to reads + 1 so unread files keep a sliver).
Two new optional props. `installHref` puts a real <a> in the empty state,
routed through linkProps so it stays copyable and middle-clickable — the
dashboard route passes it, the project home doesn't, because ConnectGuide
directly above it IS the set-up-a-device guide. `loading` keeps the tree's
first frame from claiming a populated project has no files.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
820978cd76 |
fix(history): page the History API and view so old changes are reachable (BEA-46) (#99)
* fix(history): page the API so old changes stop being unreachable (BEA-46) GET /api/p/<id>/history capped at ?n= and said nothing about what it was hiding, so every change older than the cap was unreachable — a project's early history sat in the journals and blob store with no way to display it. The display order used to come from two mechanisms: a stable time sort over a slice built in reverse-journal order, so the tie-break was implicit in the construction and a cursor could not re-derive it. histLess makes it one function — newest wall-clock first, ties in reverse journal.Less — used for both the sort and the skip-past-cursor step, so paging cannot disagree with the feed. The cursor is server-minted and opaque because it has to be: HistoryEntry.time is formatted to whole seconds and carries no lamport/seq, so a client-computed cursor would be lossy across same-second ops. ?n= alone returns exactly the entries it always did (the tie-break IS reverse-Less); it just gains a next_cursor key when more exist. A cursor is a position in an ordering, not a snapshot: an offline device pushing mid-scroll lands ops mid-feed by timestamp and the reader sees them on refresh — pinning would mean server state for the life of a scroll. BenchmarkHistoryPage over 5000 ops: page 1 14.4ms, page 20 15.8ms — every page re-lists and re-parses the journals, so the ceiling is gone but the per-page work is not. No cache needed at this scale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(history): follow the cursor in the History view, with a Load more (BEA-46) The view hard-coded n=200 and rendered whatever came back, so a project past 200 changes showed a list that simply stopped. useInfiniteQuery now follows next_cursor at 100 a page, and the foot of the list says "Load more" while older changes exist — a button, not an IntersectionObserver, so it is keyboard-reachable and states out loud that there is more. Pages accumulate into one array, which is what makes the rest free: groupRuns already groups across the whole window (a run straddling a page boundary becomes one card when its second page lands — verified live: 7 files on page 1, 12 after Load more) and prevBlob already returns undefined past the end, so the oldest loaded row shows no diff base rather than diffing against the wrong predecessor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9a52aed3bb | fix(palette): the ⌘K search box typed black on black (BEA-54) (#106) | ||
|
|
1c703c95f8 |
feat(templates): start a project from a structure, not an empty folder (#97)
* feat(templates): start a project from a structure, not an empty folder A new project was an empty folder with a .bdriveignore in it, so every agent session invented its own layout and the folder rotted into a pile. Both surfaces now offer the same three starting points — from a template, from scratch, from an existing folder (which is just a non-empty folder, and is never restructured). internal/templates holds the shipped set as literal go:embed'ed files: `docs` (docs/, decisions/) and `para` (projects/, areas/, resources/, archives/). cmd/bdrive is one binary for the CLI and the hub, so both read the identical set — no gallery, no drift. The AGENTS.md in each is the deliverable: where a new note goes, when something is archived, what a good filename looks like. Every directory holds a real file, because BearDrive syncs paths and an empty directory would never reach a teammate. The hub seeds at creation through the existing Upload+Commit path, journaled under its own device, and records the choice on the project record — so a user who picked PARA in a browser sees PARA in the browser, and a later init cannot seed a second copy. `bdrive init --template <name>` goes through the same endpoint, with a local-seed fallback for a hub too old to know the field, and seeds in place when re-run in an already-initialized folder (the agent's post-init path). Seeding never overwrites an existing path, which is what makes a double-seed a no-op rather than a divergence. Refusals cost nothing: an unknown name and --template with --only are both rejected before any network call or write. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(cli): joining a project that already has a template is refused by name The one acceptance case with no test behind it: connecting to an existing project with --template must say what the project was actually created from, and must not write the other skeleton on the way out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(templates): name the docs template in plain English, not an acronym "Plain docs + ADRs" was the recommended, first, preselected-adjacent option in a picker that non-engineers see — and it's the label people accept without reading further, so half of it not parsing is the worst place for jargon. The title also disagreed with its own blurb: "ADRs" over "docs/, decisions/", two words for the same folder one line apart. Now "Docs + decision records", which says the same thing to everyone and matches the folder names. The term itself moves into decisions/0001-record-decisions.md, where the reader is already inside the structure and the file can teach it in passing. One line in the registry drives both the web dialog and the CLI menu; the rest is prose echoing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(templates): add the LLM wiki template The third starting point from the issue title, unblocked: the spec parked it because shipping an approximation under someone's name needed a source, and there is now one — Karpathy's LLM Wiki gist. Worth noting the issue's own one-line description of it ("few large, append-heavy topic pages") does not match the source, which is the opposite: many interlinked pages, where a single ingest touches 10-15 of them. The pattern is three layers and three operations, not a folder shape. sources/ is yours and immutable; wiki/ is the agent's and it owns every page; AGENTS.md is the schema layer — which is exactly the file this template system already treats as the deliverable, so the fit is direct. index.md and log.md ship as the two navigation files the pattern turns on. Three of the things the gist tells you to go set up, BearDrive already is: version history and collaboration (per-file history, bdrive log), an Obsidian- style reader for [[wikilinks]] (the hub viewer), and a surface for the lint pass (the dashboard is literally reads x staleness). Two rules in the AGENTS.md are load-bearing and deliberate. A page write that has not updated the index is an incomplete write — a stale index is worse than a missing page, because it is read first and believed. And with no sources yet, build nothing: the structure grows out of the material rather than ahead of it. Shipped second, not first: docs stays the recommendation because a default is the option chosen by people not reading closely, and this pattern degrades badly when half-followed. Promoting it later is one line in the registry. The shipped-template test now checks the "what happens when something stops being true" question through a set of alternatives — PARA archives, a wiki supersedes and revises — since the vocabulary honestly differs by structure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(web): "I already have a folder" as a starting point The third way to start from the spec — adopt what you already have — had no presence in the browser. Templates and "empty" were the only visible answers, so someone with a folder of notes either hesitated or picked a template and got four directories merged into their material. The constraint that shapes it: the browser cannot reach your disk, so this cannot change what is created. It creates the same empty project "Empty project" does; what it changes is the next screen. Create therefore stays enabled — disabling it would leave the dialog a dead end AND produce no project id, which is the one thing the paste prompt actually needs. Landing on the project home with the intent, three things differ: the guide says "in the folder you already have", a note states plainly that connecting never moves, renames or overwrites anything, and the paste prompt tells the agent a folder already exists. That last one is the part that isn't cosmetic — without it an agent reads an empty project and proposes creating shared/, the one recommendation that is wrong here. It still asks which folder: that is the runbook's hard gate and nothing here weakens it. The intent rides in the URL (?connect=existing) rather than onto the project record, the same way ?v= pins a file version. It belongs to whoever is connecting right now — a teammate who connects next week has their own answer and would be told the wrong thing by a persisted flag. Five rows made the dialog tall enough to push Create off a short viewport, so .modal scrolls internally. A hairline divider between the seeding and non-seeding rows was tried and removed: --border is 7% white, which at 1px in a gap renders as literally nothing. The gap is the cue that reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(web): with no projects, open the create dialog and give the page a way in A signed-in account with no projects landed on a page whose only path forward was pasting a prompt into a coding agent. Now the create dialog opens itself — with nothing to browse there is nothing else on that page to do — and the page behind it leads with "Start a project" and a button, so closing the dialog is not a dead end. The dialog moves up to HubApp because three things ask for it now: the sidebar's +, the empty state's button, and the auto-open. ProjectNav keeps only an onNew callback; one owner beats three copies of the create handler. Two guards on the auto-open. It fires once per mount, keyed off a ref rather than the empty state, or closing it would immediately reopen it. And it never fires on a read-only hub, which refuses creation server-side with a 403 — opening a dialog that cannot succeed is worse than the page it covers. The agent paste-prompt stays, demoted to "Or let your agent do it": it is still the right path for someone who wants the folder connected in the same breath, and it is the only path on a hub where this account cannot create. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a05a1f8a52 |
refactor(auth): one CLI sign-in flow for every provider (webapp.CLIAuth) (#104)
* refactor(auth): one CLI sign-in flow for every provider (webapp.CLIAuth)
The `bdrive login` surface — /auth/cli, /auth/device/<token>, the approval
page both show, /api/auth/exchange and /api/auth/device/{start,poll} — moves
out of BuiltinAuth into its own type. A provider supplies the two things
that actually differ: who the browser session is, and how a device token is
minted.
Nothing changes for a self-hosted hub; this is the same code behind the same
paths. It moves because the managed hub's provider carries its own copy, and
the copy drifted: months after the OSS flow moved to a single approval link
naming the device, that hub was still printing a four-byte code to retype
into a text box. Sharing the implementation is the only fix that stays fixed.
BuiltinAuth's own grant map now holds just what it should: verification and
password-reset links.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(architecture): make the webapp-server diagram parse again
Two mermaid syntax errors, so GitHub rendered the first block as an error
box instead of a diagram:
- the CLIAuth class listed its routes as bare lines, and the `{` in
/auth/device/{token} opens a struct inside a class body — the routes are a
note now, where prose belongs;
- `note for` strings escaped quotes as \" (mermaid has no backslash escapes,
so the string ended early). Pre-existing, in the DirectUploader and
Project notes; both use " now, like the </> already in there.
Checked by parsing every block in architecture/*.md with mermaid 11.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
b408e004b3 |
fix(history): stop reporting the device IP on every change (BEA-43) (#81)
* fix(history): stop reporting the device IP on every change (BEA-43) The history API embedded the whole DeviceInfo, so every project member read a teammate's server-observed IP (plus user/last_seen) next to every change on a page whose job is "who changed this file". Project a three-field historyDevice instead — id/name/os — mirroring heatByDevice. The device registry is unchanged: Observe/requestIP and both MetaStore backends keep recording the IP. This is a response projection, not a change in what gets collected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: history shows device name and OS, not the IP (BEA-43) README, SKILL.md and the docs site all promised the History view would show the connecting IP. It no longer does — the registry still records it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
af9ef4d181 |
fix(dashboard): make the knowledge treemap honest on a young project (#93)
* fix(dashboard): make the treemap honest on a young project Every cell was the same green (staleColor spreads 0-300d, so <=3d-old content lands in one stop), half the cells were anonymous with no hover fallback, and read counts existed only inside file <title>s. The map said nothing the file tree didn't. - group rects get a <title> (folder, reads/30d, file count), so a group whose files are all too small to label is still identifiable - read counts appended to file and group labels; the count is part of the string the fit is measured against, so it can never overflow, and when it doesn't fit the label degrades to the bare truncated name as before - a freshness legend under the map: the gradient plus the age span actually observed in this scope+lens, and when that span is under a week it says the colour channel carries no signal instead of implying one - group read totals come from a true sum, not the padded layout value No relative colour scale: normalising to the observed range would paint a 3-day-old file the red that means hot-and-stale everywhere else on the page. Sizing (reads + 1), scatter, hot path and coverage are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(architecture): heat.ts joins the frontend lib Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3d0eee0254 |
fix(history): size an agent run by files touched, not by ops (BEA-39) (#87)
The run card header counted ops, so a path rewritten five times inflated the one number a reader uses to size a run: 14 ops across 10 paths read "14 files". It now counts distinct paths and keeps the word "files"; every op is still a row inside the card. The same count decided whether a run got a card at all, so a run that hit one path five times drew a card claiming "5 files". Counting by file demotes it to bare rows, each still showing its session note. groupRuns and the Run/Item types move to src/lib/runs.ts (pure, no React) so node's test runner can import them — a .tsx with JSX can't be. The grouping key, ordering, time span, who and device are unchanged, and run.idx still addresses the flat feed so diffs and restore shas are unaffected. The key's NUL separator moves across as an explicit "\0" — it was a raw NUL byte in the source, which is also why git saw the old HistoryView.tsx as binary. Deviation from the reviewed plan: flipping the threshold in place would have dropped rows. Only a run's first entry was ever pushed to the output list, so a demoted 5-op run would have rendered one row, not five. groupRuns now builds runs first and emits items in a second pass over the feed, which keeps demoted rows at their own newest-first positions. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
01f33c9fe7 |
fix(dashboard): the hot-path bar paints share reads as share, not as people (BEA-38) (#86)
* fix(dashboard): the hot-path bar paints share reads as share, not as people The bar computed one fraction (agent/total) and painted the whole remainder in the human colour, so a file read only through a share link rendered as if a person had browsed the hub — and the legend named only two readers, while the file header has been breaking out all three all along. Each reader now gets its own segment from its own count. hotPathSplit() does that arithmetic, and it lives with the rest of the heat helpers in the new lib/heat.ts (pure, no React) so one unit test over one fixture can pin the invariant the report doubted: the file header and the Dashboard read the same total from the same helper. useBrowse.ts re-exports them, so no import site moved. No server change — /heat already returns share and stays identity-free. * docs(architecture): lib gains heat.ts, the one read-count arithmetic --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
11dd7ac528 |
fix(webapp): the viewer credits the account you signed in with, like History does (BEA-37) (#84)
* fix(webapp): viewer credits the signed-in account, like History does RemoteSource.Files built each FileInfo from op.Author alone and dropped op.User/op.UserName, so the file viewer header showed a git/OS identity the user never signed in with while History rows for the same op showed the account. Carry both fields through FileInfo -> Node -> /render and render them with the whoChanged() helper History already uses, so there is one attribution rule in the frontend rather than two. The guard on the meta line stays on the raw fields: whoChanged() answers "unknown" rather than "", and plain-folder (DirSource) mode has no identity at all and must keep printing nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(webapp): plain-folder render carries no empty identity fields Locks the other half of BEA-37: DirSource has no account behind a file, and sending empty user fields would make whoChanged() print "unknown" where plain-folder mode has always printed nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4dfe6f44f0 |
feat(history): undo a file an agent run created (BEA-35) (#82)
History could restore an edit or a deletion, but a file a run CREATED was the one thing it couldn't reverse — the ADDED row said so in copy and offered no button. The missing capability was a hub-written delete op: restore.go only ever journaled puts. POST /api/p/<id>/remove journals exactly one journal.KindDelete op under the hub's own device identity, behind restore's gates (gateUpload, PermWrite, cleanUploadPath, quota CheckWrite/RecordUsage) plus a volume- snapshot existence check so the API 404s on what the tree doesn't show. Commit's journal-append tail moves into RemoteSource.appendOp, which both writes now share — one writer per journal, unchanged. The ADDED-in-a-run row gets an "undo — remove file" control that confirms first (it reaches every synced device), and the DELETED row it leaves behind restores the file with its original bytes. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
31472c56e7 |
fix(hub): one Revoke per share link — the modal hands off, the banner undoes (BEA-32) (#77)
Opening Share on a file that already had a live public link showed the URL twice, each copy with its own Revoke: the transient dialog on top of the persistent ShareBanner. Both hit the same link, so nothing was at risk, but a destructive control shown twice is a control nobody wants to click. The dialog loses Revoke and keeps its job — confirm what happened, hand over the URL (Copy link / Open / Done) and set an expiry. The banner keeps Revoke: it is what BEA-16 added so the undo outlives the dialog, and it is still there on every later visit to the file. Deviation from the reviewed plan: it also had this delete the `token` derivation and the `api` import, which existed only for the Revoke handler. BEA-29 (#74) landed first and its expiry PATCH now uses both, so they stay — the plan assumed this PR would go in ahead of it. The e2e that revoked through the modal now revokes through the banner, and asserts `.modal .ai-del` has count 0 right after Share — the assertion that stops the duplication coming back. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
946a552c64 | fix(dashboard): name the fourth quadrant on reads × freshness (BEA-42) (#79) | ||
|
|
b1c0bba415 |
feat(auth): the browser sign-in says whose account the terminal gets (#96)
* feat(auth): ask before signing a terminal in as whoever the browser is `bdrive login` opened /auth/cli and the browser bounced straight back with a code. Whoever the browser happened to be signed in as is who the terminal became — silently. That is frequently not the account the user meant: a personal login left open in the default browser, a teammate's session on a shared machine. The mistake surfaces much later, as a synced folder full of commits authored by the wrong person, which is far more work to undo than one click would have been. The device flow already got this right in #83 — it names the account, offers to switch, and says what approving grants. The browser flow said nothing at all, for the same outcome: a token that acts as you. So /auth/cli now confirms first. GET renders the page (who you would be signing in as, a Switch account link that comes back to this same pending sign-in, what is asking, and where it is waiting); POST is what mints the code and redirects to the loopback listener. A GET therefore grants nothing, so a link someone else got you to open can no longer mint a code on your behalf. whoBlock loses its pendingGrant parameter and renders only the identity half. What is asking differs per flow — a device has a name and an OS, a CLI on this computer has a loopback port — so each page now renders its own rows through a small helper instead of whoBlock pretending to a shape neither quite fits. The CLI's own wording follows: "waiting for you to approve the sign-in in your browser", since being signed in already is no longer the whole story. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * docs: the browser sign-in confirms first, and says whose account it grants README, the CLI reference, and the self-hosting auth page all described the old behaviour — sign in and the page bounces a code straight to the terminal. They also read as though only `--device` had an approval step. Both flows now confirm; say so, and say why it matters (the browser session is often not the account the user meant). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * feat(auth): one web step for a first sign-in, not two The confirmation page fixed the wrong-account problem and created a smaller one: a user with no browser session now saw two pages on their first `bdrive init` — sign in, then approve — where the sign-in had already settled the only question the second page asks. So authenticating *for* a pending CLI sign-in now counts as approving it. The login and signup pages carry a line saying a terminal is waiting and that the account used here is the one it will act as, which is where that consent is made informed; reaching the callback then needs no second click. The marker is server-side, bound to the exact pending sign-in, single use, and two minutes long, so it can only ever skip the page it was granted for and only once. It cannot be forged: setting it requires authenticating as that account, and anyone who could do that could click Approve anyway. An existing session still gets the page — that is the case where the browser may be signed in as someone the user did not intend, which is the whole reason it exists. Net effect: exactly one web interaction either way. The device flow keeps its explicit approval. Its page names a machine that isn't this one, along with the OS and address it came from — information no login form can convey, about a grant to somewhere else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * fix(auth): keep `bdrive login` on one line in the approval hint It wrapped mid-phrase into two separate code boxes, which reads as two commands rather than one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * refactor(auth): one approval page for both sign-in flows The two flows ask the same question — shall this thing act as you? — and had two copies of the page asking it, differing in three strings. They had already started drifting: a wrapping fix went into the CLI copy only, leaving the device page able to break `bdrive login --device` across two code boxes. A page whose whole purpose is consistent disclosure is a bad place to keep two of everything. So pageAuth owns the shape (session check, redirect to login, whoBlock, rows, the Approve form, the note) and each flow supplies an authRequest describing what differs: how the request is identified, what is asking, and what approving does. Two asymmetries are now explicit rather than accidental. freshAuthSkips is true only for the local flow — signing in and approving are the same act when the terminal is on this machine, and are not when the token goes to another one. live() reports whether the request still exists, because the device flow's link expires while the CLI flow carries its whole request in the URL and has nothing to expire. detail is a function, not a slice: the device rows come off the pending grant, which only exists after live() has found it. No test changed. The pages render byte-identically — same sha256 for all three CLI screenshots before and after — and the device flow was driven end to end against a real hub, approving a real `bdrive login --device`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * feat(auth): both sign-in flows always ask you to approve Consistency between the two flows is worth more than the click it saves. Letting a sign-in count as its own approval made the local flow one step and the device flow two, so the same product asked for consent in two different shapes depending on which machine you were on — and the shape that skipped it was the one where the page had something to tell you. So the fresh-auth marker is gone: sign in, then approve, on both flows. That drops a map, two methods, a descriptor field, and a branch in pageAuth — the unified handler now has exactly one path through it. A first `bdrive init` on a fresh machine is two web pages again. That is the deliberate trade: the approval page is where a user sees which account a machine is about to act as, and nothing shortcuts it. The sign-in page keeps the line saying a terminal is waiting. It no longer carries the consent — the next page does — so it is there to explain why a password prompt appeared at all. TestBothFlowsAlwaysAskToApprove replaces the one-step test and runs the same assertions over both flows as subtests: no session sends you to sign in carrying the request, signing in returns to the request without granting, the approval page is there every time, and only the POST grants. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
951c6b9de2 |
feat(viewer): preview text by its bytes, not its extension — and render PDFs (#95)
The viewer decided what to preview from a filename regex, so every extensionless file an agent writes — Dockerfile, LICENSE, .bdriveignore — and every unlisted extension (main.tf, schema.graphql) hit a dead "No preview for this file type." card. That bites hardest in the core use case: an agent writes an artifact, a teammate opens the hub to read it, and the hub refuses. The unmatched path now fetches once and decides on the bytes. The logic already existed for the history diff — 1 MB cap, Content-Length cheap-out, 8 KB NUL scan, fatal UTF-8 decode — so the pure half moves to lib/sniff.ts (importable by node --test, no React Query) and both DiffView and FileView call it. Exactly one sniffer, per the spec. .pdf gets the browser's own viewer in an iframe, in the wide page column (768px is unreadable for a PDF page). No sandbox attribute, deliberately: the PDF viewer is not this page's JS realm, so it cannot reach the hub API or its cookies, and sandboxing without allow-same-origin breaks Firefox's pdf.js. Files that already previewed (md/html/img/.txt) are untouched and issue no extra fetch. BEA-44 |
||
|
|
b927e56fab |
fix(daemon): let the daemon own its pidfile, so stop can stop it (#91)
* fix(daemon): let the daemon own its pidfile, so stop can stop it `bdrive stop` could fail with "no such process" and leave sync running. Liveness became the flock in #88, and Run already announces the child's own pid only after it holds that lock. But Start still wrote daemon.pid from the parent, right after fork, with the pid of a child that had not earned anything yet. Two starts inside that window — `bdrive init` followed by the login agent's `bdrive resume`, or two resumes close together — race: Running still reads false, a second child spawns, it loses hold(), and it exits without ever being the daemon. Its pid is already in the file. Everything downstream trusts that file. Stop signals the loser and gets ESRCH, so it reports failure while the winner keeps syncing — the one command whose job is "stop sending my files" silently does not. status prints the phantom pid, or "pid 0" when the loser's cleanup removed the file the winner wrote. So the parent no longer writes it: the pidfile belongs to whoever holds the lock. Start now waits for the lock to be taken instead of assuming the spawn worked, which also means a caller that gets a pid back can trust a daemon owns it — `bdrive resume` used to print "started (pid N)" for a child that had already died. The regression test needs the real binary (Start execs os.Executable), so it lives with the CLI e2e rather than in internal/daemon, whose tests synthesize locks. It is deterministic on Linux and roughly one run in five on macOS, where the window is tighter; `sandbox/run.sh daemon-linux` is the reliable reproducer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * docs(architecture): the daemon owns its pidfile, Start only waits for the lock Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fd4f5c7964 |
feat(onboarding): folder name follows the project name, and project ids are UUIDs (#89)
The paste prompt now carries the project's name so an agent recommends a folder of that name; with no project at all the recommendation is `shared/` (and `bdrive init shared` names the new project after the folder), replacing the old `wiki/` default. New project ids are UUIDs instead of `p-` + 8 hex chars. The route validator still accepts the legacy shape — ids are permanent — and the client-side URL parsers (remote/http.go, bdrive share) now only check the shape of a URL segment, leaving the hub as the single authority on which ids are valid. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fb6ce347c4 |
feat(daemon): survive a reboot — login autostart on macOS/Linux/Windows, and a lock instead of a pidfile (#88)
* feat(daemon): bring sync back after a reboot, and stop trusting the pidfile A reboot killed every daemon and nothing restarted them. Agent hooks still synced per turn, which is what made it easy to miss: a folder looked fine while an agent worked in it and went stale the moment one didn't. `bdrive init` now registers a login item (macOS: a user LaunchAgent) that runs the new `bdrive resume` — one registration per machine, which starts a daemon for every enrolled, unpaused mount, so adding a project later needs no re-registration and `bdrive stop` still means stay stopped. `--no-autostart` opts out, `bdrive autostart install|uninstall` manages it. Writing the plist is the whole job: no `launchctl` shell-out. launchd loads agents at login anyway, the caller has just started the daemon for this session, and shelling out would let a test or a packaging script register a real login item as a side effect. The recovery path was also broken, which is why this is one change. Liveness came from `kill(pid, 0)` on daemon.pid — but that file lives in $BDRIVE_HOME and survives the reboot that killed its process, so any same-user process recycling the pid read as a live daemon. `bdrive status` said "running", and worse `daemon.Start` returned early, so the one documented recovery (`bdrive init`) reported success and started nothing. Liveness is now an flock held for the daemon's lifetime: the kernel drops it at death or reboot, and it makes two daemons on one mount impossible. The pid stays for display and for signalling. internal/autostart is darwin-only today; autostart_other.go returns ErrUnsupported and every caller already treats that as "nothing to do", so Linux (systemd user unit) and Windows are one file each. Tests: internal/daemon gets its first ones — a recycled pid must not read as running (the exact regression), the lock decides liveness, a second holder is refused. internal/autostart covers write/idempotency/stale-path-rewrite/ uninstall with HOME redirected, and lints the plist with plutil so launchd can actually parse it. The CLI e2e asserts init registers the agent, that it runs `resume`, that resume finds the live daemon instead of starting a second, and that --no-autostart is silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ * feat(autostart): Linux support — a systemd user unit alongside the launchd agent Same three functions, same discipline. Linux writes $XDG_CONFIG_HOME/systemd/user/beardrive.service (Type=oneshot, no Restart= — `bdrive resume` exits by design) plus the default.target.wants symlink that `systemctl --user enable` would create, because systemd ignores a unit nothing wants. No `systemctl` shell-out, for the same reasons as launchctl: the file is the registration, it only matters at the next login, and a container or ssh session has no session bus to talk to. Install declines with ErrUnsupported unless systemd is actually the init system (/run/systemd/system, i.e. sd_booted) — on Alpine, WSL1 or a slim container a unit file is inert decoration, and reporting "registered" would be a lie. Installed() likewise requires the enable symlink, not just the unit: a unit nothing wants never starts. os.UserConfigDir honors XDG_CONFIG_HOME, so relocated config dirs work. Windows is now the only gap; autostart_other.go is !darwin && !linux, and the shared writeIfDifferent/selfPath moved into the tag-free autostart.go (darwin now uses them too). Tests run on Linux, not just compiled for it: cross-compiled test binaries executed in a container, both with /run/systemd/system present (unit written, enabled, idempotent, stale ExecStart rewritten, broken symlink repaired, XDG honored, uninstall removes both) and without it (Install declines and writes nothing). The daemon flock tests were run there too, since flock semantics are per-OS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ * feat(autostart): Windows support — a per-user Run entry Third platform, same three functions. Windows has no user service manager in the launchd/systemd sense, so the registration is a HKCU\...\Run value via golang.org/x/sys/windows/registry (already in the module graph; go mod tidy just promotes it to direct). Chosen over the alternatives for the same reason the other two write files: no admin rights, no COM (a Startup-folder .lnk needs it), no schtasks shell-out. It is also honestly discoverable — the entry appears in Task Manager's Startup tab, where someone can disable it without knowing bdrive exists. The executable is quoted because Explorer parses the value as a command line and Program Files has a space in it. Two things a reader should not have to discover for themselves: - The tests here have NEVER RUN. They are written and compile-checked (GOOS=windows go test -c) from macOS; there is no Windows host or usable container on an arm64 mac. They execute the first time the suite runs on Windows. They also cannot use a temp HOME the way the macOS and Linux tests do — HKCU is real — so each one snapshots and restores the previous value. - `GOOS=windows go build ./...` still does not pass, and this package is not why: internal/store's Lock uses syscall.Flock and internal/daemon uses syscall.Kill and Setsid, all unix-only (true before this branch too). A Windows port means LockFileEx plus a stop story for a platform with no SIGTERM — a separate change, against the sync invariants, and untestable from here. So this code is correct and currently unreachable. autostart_other.go is now !darwin && !linux && !windows (the BSDs). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
31f705e287 |
chore: drop the Claude plugin and the bundled skill — hooks are the integration (#85)
Two front doors for the same setup, and one of them was a second copy of everything. The plugin shipped the skill the CLI already installs (to four platforms, not one), hooks that ran the identical commands `bdrive hooks install` writes machine-level, and an install flow duplicating INSTALL_FOR_AGENTS.md. Nothing deduped, so a machine with both pulled twice per turn — two blocking syncs — and showed two identical `beardrive` skills in the picker. What remains is `internal/agenthooks` plus the runbook: init registers a blocking pull (which also injects the gated-link convention as additionalContext), an async push on Write/Edit, and read-log for the heatmap, in each platform's user config, once per machine. That is the whole integration, and it is the part that was never optional. Removed: plugin/, .claude-plugin/marketplace.json, internal/agentskills, `bdrive skill`, `bdrive hook-approve` (its PreToolUse auto-approve only ever helped when a plugin pre-installed it; the substitute is a `Bash(bdrive:*)` permission entry, which is user-owned config and needs no code). The e2e now asserts the absence: no SKILL.md in any platform's skills dir after init, and no `skill` subcommand. login_test keeps the "no revoke surface" wording check on logoutNote alone. Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dfb9da3260 |
feat(auth): one approval link for device sign-in, and a page that says what it grants (#83)
The headless flow printed a short code to retype into a bare "Approve" form. Now `bdrive login --device` prints a single link — the token lives in the path (/auth/device/<token>), so there is nothing to read off one screen and type into another. The page it opens is a consent page rather than a text field: it names the account the device would act as, offers Switch account (logout now honors ?next, so you land back here), and shows the device name, OS, and the address the server observed. That matters because this flow's weakness is a stranger sending you their pending link; an anonymous "Approve" gives you nothing to notice with. Approval is still a POST from the page, so a link alone cannot grant, and SameSite=Lax keeps a cross-site form out. Also aligns the /auth/* pages with the app's tokens, which had drifted: card #0c0e10 vs --color-card #15171b, 8px controls vs --radius-ctl 7px, hand-picked #ff9b91/#6fd699 vs --color-del/--color-add. The style block now declares the tw.css tokens by name and every rule uses them. Older CLIs still print /auth/device?code=…, so that shape 303s to the path form; a pre-0.13 hub returning no verify_url still gets the old type-the-code instruction from the CLI. Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2f68bbe92e |
feat(hub): optional analytics seam so a managed deployment can measure the app (#80)
Adds Server.Analytics (webapp.AnalyticsConfig) and emits it as /api/config `analytics` when a key is set. The frontend loads posthog-js from the CDN at runtime rather than as a dependency, so an unconfigured hub ships no tracker and makes no third-party request — the OSS bundle grows 1.1KB (the loader), not 230KB. Product events come from one table in api/http.ts keyed on method+path. Every mutating call in the app already goes through api()/postJSON(), so a new write is measured or it isn't, instead of depending on someone remembering a capture() call. Share creation is the one raw fetch and fires its own. Session replay masks every text node: in this product nearly all of it is customer file names and document bodies. Replays are layout, clicks and navigation only. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4ff92c56ab |
feat(history): group agent runs and restore any version (BEA-6) (#69)
* feat(history): group agent runs and restore any version (BEA-6) BearDrive recorded everything and could restore nothing. Now every version of a file has a Restore button — in the hub's History view and as `bdrive restore` — and the changes one agent run made read as one card instead of N loose rows. Restore is a NEW put op pointing at the old blob: journals are never rewritten, so one-writer-per-journal holds and peers converge on the restore like any other edit. The hub reuses RemoteSource.Commit (the upload commit minus the upload); the CLI writes the bytes into the working folder and lets the ordinary cycle journal them, so the sync engine gains no new write path. Grouping is a pure frontend group-by on (note, device) over the existing /history response — no journal or API change. Known gap, stated in the UI and the docs: nothing in the hub writes a delete op yet, so a file a run *created* cannot be un-created. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(history): don't repeat a run's note on every row in its card UI pass on the real hub: inside a run card the note is the card's header, so printing it again on each row said the same thing N times. The header now carries the note (linkified, so an agent's session link still opens) and the collapse control is its own button rather than the whole header — the link could not live inside a button. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ecc8328512 |
fix(shares): say when "the latest version" was (BEA-31) (#76)
A /s/<token> page promises the reader it always shows the latest version, then never says when latest was — so a stranger can't tell whether a living wiki page is from today or last March. Print FileInfo.Time, already in hand at the point handleShared renders, as a muted line above the content: human date, precise RFC3339 in the title attribute. Zero time prints nothing rather than a 1970 date. Markdown shells only. The .html/.htm branch stays a raw io.Copy and binaries stay untouched — the new test asserts byte equality for both. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |