diff --git a/INSTALL_FOR_AGENTS.md b/INSTALL_FOR_AGENTS.md index 242abda..d281b2b 100644 --- a/INSTALL_FOR_AGENTS.md +++ b/INSTALL_FOR_AGENTS.md @@ -45,8 +45,8 @@ works, stop and tell the user. ## 2. Do not run a login command `bdrive init` (step 3) signs the device in when there is no session, and -without a TTY it uses the device-code flow automatically: it prints a URL and -a short code for the user to approve in any browser. Pass the hub with +without a TTY it uses the device-code flow automatically: it prints one link +for the user to open in any browser and approve — no code to type. Pass the hub with `--server ` and init signs in *there* — so a hub this device has never seen still needs no separate command. diff --git a/README.md b/README.md index 7998da9..3a7f9be 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ hub's own storage, never something a syncing client points at directly: | Command | Description | |---|---| -| `bdrive login [server-url]` | Sign this device in (browser flow; `--device` forces the code flow, and shells without a TTY fall back to it automatically; default server beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host). Switch hubs with `bdrive login ` | +| `bdrive login [server-url]` | Sign this device in (browser flow; `--device` forces the approval-link flow, and shells without a TTY fall back to it automatically; default server beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host). Switch hubs with `bdrive login ` | | `bdrive logout` | Sign this device out — clear the saved token/account (`--forget` also drops the remembered server) | | `bdrive init [folder]` | Create/connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY, flags (`--name/--project/--server/--only/--yes`) for scripts; installs the agent skill, registers agent sync hooks in each platform's user config (`--no-hooks` skips the hooks), prints the project link; re-run to resume | | `bdrive stop [folder]` | Stop syncing, including agent sync hooks (files stay; `bdrive init` resumes) | @@ -501,8 +501,8 @@ to set up BearDrive project on . Ask me which folder to sy ``` The agent fetches [INSTALL_FOR_AGENTS.md](INSTALL_FOR_AGENTS.md) and follows -it: install the CLI, then one `bdrive init` — which signs in (device code -when there is no browser), installs the skill, registers the sync hooks and +it: install the CLI, then one `bdrive init` — which signs in (an approval +link when there is no local browser), installs the skill, registers the sync hooks and prints the project link. The instructions live at that URL rather than inside the prompt so they never go stale in someone's copy — and the agent handles every deviation (already installed, no Homebrew, sign-in, wrong diff --git a/cmd/bdrive/login.go b/cmd/bdrive/login.go index ddb4a05..2472678 100644 --- a/cmd/bdrive/login.go +++ b/cmd/bdrive/login.go @@ -330,10 +330,13 @@ func exchangeCode(server, code string) (string, serverUser, error) { return out.Token, out.User, nil } -// deviceCodeLogin runs the headless flow: show a code, poll until the user -// approves it from any browser. +// deviceCodeLogin runs the headless flow: print one approval link, poll until +// the user opens it in a signed-in browser. The link carries the secret, so +// there is no code to read off this screen and type into another — and the +// page it opens names this device, so the approver can see what they're +// approving. func deviceCodeLogin(server string) (string, serverUser, error) { - body, _ := json.Marshal(map[string]string{"device": deviceName()}) + body, _ := json.Marshal(map[string]string{"device": deviceName(), "os": runtime.GOOS}) resp, err := initClient.Post(server+"/api/auth/device/start", "application/json", bytes.NewReader(body)) if err != nil { return "", serverUser{}, err @@ -351,7 +354,13 @@ func deviceCodeLogin(server string) (string, serverUser, error) { if start.Interval <= 0 { start.Interval = 2 } - fmt.Printf("on any signed-in browser, open:\n %s\nand approve code: %s\n", start.VerifyURL, start.Code) + // Older hubs (pre-0.13) hand back a short code and expect it typed into + // /auth/device; keep that instruction for them. + if start.VerifyURL == "" { + fmt.Printf("on any signed-in browser, open:\n %s/auth/device\nand approve code: %s\n", server, start.Code) + } else { + fmt.Printf("to finish signing in, open this link in any browser:\n %s\n", start.VerifyURL) + } deadline := time.Now().Add(10 * time.Minute) for time.Now().Before(deadline) { diff --git a/internal/webapp/auth_test.go b/internal/webapp/auth_test.go index 0fddbeb..38025e6 100644 --- a/internal/webapp/auth_test.go +++ b/internal/webapp/auth_test.go @@ -271,16 +271,26 @@ func TestDeviceCodeFlow(t *testing.T) { h := srv.Handler() cookie := signupAndSession(t, h, "dev@x.io", "Dev", "password1") - rec := do(t, h, "POST", "/api/auth/device/start", map[string]string{"device": "server-1"}) + rec := do(t, h, "POST", "/api/auth/device/start", map[string]string{"device": "server-1", "os": "linux"}) if rec.Code != 200 { t.Fatalf("start: %d %s", rec.Code, rec.Body) } var start struct { - Code string `json:"code"` + Code string `json:"code"` + VerifyURL string `json:"verify_url"` } if err := json.Unmarshal(rec.Body.Bytes(), &start); err != nil || start.Code == "" { t.Fatalf("start = %s (%v)", rec.Body, err) } + // The link is the secret, so it must be a real token, and it must carry + // the code in the path — nobody types this in. + if len(start.Code) < 32 { + t.Fatalf("device code %q is too short to be a URL secret", start.Code) + } + if !strings.HasSuffix(start.VerifyURL, "/auth/device/"+start.Code) { + t.Fatalf("verify_url = %q, want .../auth/device/", start.VerifyURL) + } + approve := "/auth/device/" + start.Code // pending until approved rec = do(t, h, "POST", "/api/auth/device/poll", map[string]string{"code": start.Code}) @@ -288,10 +298,23 @@ func TestDeviceCodeFlow(t *testing.T) { t.Fatalf("poll before approve: %d %s", rec.Code, rec.Body) } + // The approval page names the account being granted, offers a way off it, + // and says what is asking — approving is handing that box a token. + req := httptest.NewRequest("GET", approve, nil) + req.AddCookie(cookie) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + for _, want := range []string{"dev@x.io", "server-1", "linux", "Switch account", url.QueryEscape(approve)} { + if !strings.Contains(rec.Body.String(), want) { + t.Fatalf("approval page missing %q:\n%s", want, rec.Body) + } + } + if strings.Contains(rec.Body.String(), `name="code"`) { + t.Fatal("approval page still asks for a typed code") + } + // approve from a signed-in browser - form := url.Values{"code": {start.Code}} - req := httptest.NewRequest("POST", "/auth/device", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req = httptest.NewRequest("POST", approve, nil) req.AddCookie(cookie) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) diff --git a/internal/webapp/authlocal.go b/internal/webapp/authlocal.go index c66e1d0..57ec387 100644 --- a/internal/webapp/authlocal.go +++ b/internal/webapp/authlocal.go @@ -82,6 +82,8 @@ type pendingGrant struct { kind string // "code" (CLI callback), "device" (poll flow), "reset" user string // set once granted device string // device flow: requested device name + os string // device flow: requested device's OS + ip string // device flow: where the request came from, as the server saw it granted bool expires time.Time } @@ -516,8 +518,9 @@ func (a *BuiltinAuth) Register(mux *http.ServeMux) { mux.HandleFunc("POST /auth/signup", a.pageSignup) mux.HandleFunc("GET /auth/logout", a.pageLogout) mux.HandleFunc("GET /auth/cli", a.pageCLI) - mux.HandleFunc("GET /auth/device", a.pageDevice) - mux.HandleFunc("POST /auth/device", a.pageDevice) + mux.HandleFunc("GET /auth/device/{token}", a.pageDevice) + mux.HandleFunc("POST /auth/device/{token}", a.pageDevice) + mux.HandleFunc("GET /auth/device", a.pageDeviceLegacy) mux.HandleFunc("GET /auth/verify", a.pageVerify) mux.HandleFunc("GET /auth/reset", a.pageReset) mux.HandleFunc("POST /auth/reset", a.pageReset) @@ -607,31 +610,51 @@ func authPage(w http.ResponseWriter, title, body string) { fmt.Fprintf(w, ` %s — BearDrive

%s

%s
`, @@ -699,7 +722,7 @@ func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) { if a.AllowSignup || invited { note := "" if a.AllowSignup && len(a.AllowedDomains) > 0 { - note = ` (` + html.EscapeString(a.domainList()) + ` only)` + note = ` (` + html.EscapeString(a.domainList()) + ` only)` } label := "No account?" if invited && !a.AllowSignup { @@ -709,7 +732,7 @@ func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) { } brand := "" if a.Brand != "" { - brand = `

` + html.EscapeString(a.Brand) + `

` + brand = `

` + html.EscapeString(a.Brand) + `

` } authPage(w, "Sign in", brand+inviteBanner(next)+fmt.Sprintf(`
%s%s%s
%s

Forgot password?

`, @@ -775,7 +798,7 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) { } brand := "" if a.Brand != "" { - brand = `

` + html.EscapeString(a.Brand) + `

` + brand = `

` + html.EscapeString(a.Brand) + `

` } authPage(w, "Create account", brand+inviteBanner(next)+fmt.Sprintf(`
%s%s%s%s%s

Have an account? Sign in

`, @@ -787,12 +810,19 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) { errMsg, url.QueryEscape(next))) } +// pageLogout ends the browser session. It honors ?next= so "switch account" +// on a page that needed one (device approval, an invite) lands back there as +// the new account instead of dumping the visitor at the hub root. func (a *BuiltinAuth) pageLogout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(sessionCookie); err == nil { a.revokeToken(c.Value) } http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1}) - http.Redirect(w, r, "/auth/login", http.StatusSeeOther) + dest := "/auth/login" + if next := safeNext(r.FormValue("next")); next != "/" { + dest += "?next=" + url.QueryEscape(next) + } + http.Redirect(w, r, dest, http.StatusSeeOther) } // pageCLI completes `bdrive login`: once the browser has a session, mint a @@ -819,27 +849,74 @@ func (a *BuiltinAuth) pageCLI(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, u.String(), http.StatusSeeOther) } -// pageDevice is the headless-login approval page: the user types the code -// `bdrive login` printed. +// pageDevice is the headless-login approval page, reached by opening the link +// `bdrive login` printed: the token lives in the path, so there is no code to +// read off one screen and type into another. It names the account the device +// will act as (with a way to switch), and what is asking, because approving +// hands that machine a token that acts as you. func (a *BuiltinAuth) pageDevice(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") user, ok := a.sessionUser(r) if !ok { http.Redirect(w, r, "/auth/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) return } - var msg string + g, ok := a.peekGrant("device", token) + if !ok { + authPage(w, "Link expired", `

This sign-in link is invalid, already used, or older than 10 minutes.

+

Run bdrive login --device again for a fresh one.

`) + return + } if r.Method == http.MethodPost { - code := strings.ToLower(strings.TrimSpace(r.FormValue("code"))) - if a.grantDevice(code, user.ID) { - authPage(w, "Device connected", `

Done — you can close this tab. The terminal will finish logging in.

`) + if !a.grantDevice(token, user.ID) { + authPage(w, "Link expired", `

This sign-in link expired while the page was open.

+

Run bdrive login --device again for a fresh one.

`) return } - msg = `

Unknown or expired code.

` + authPage(w, "Device connected", fmt.Sprintf(`

%s can now sync as %s.

+

You can close this tab — the terminal finishes on its own.

`, + html.EscapeString(orDash(g.device)), html.EscapeString(user.Email))) + return } - code := r.URL.Query().Get("code") - authPage(w, "Connect a device", fmt.Sprintf(`

Enter the code shown by bdrive login:

-
%s%s
`, - field("Code", "code", "text", code), msg)) + authPage(w, "Connect a device", fmt.Sprintf(`

A device is asking to sign in to BearDrive.

+%s +
+

Approve this only if you just started a sign-in on that machine.

`, + whoBlock(user, g, r.URL.RequestURI()))) +} + +// whoBlock renders the two things the approver needs: who they'd be granting +// as (with an escape hatch back to this same page), and what is asking. +func whoBlock(user User, g pendingGrant, back string) string { + name := user.Name + if name == "" { + name = user.Email + } + return fmt.Sprintf(`
+
Signing in as%s%s
+Switch account +
+
Device
%s
System
%s
Address
%s
`, + html.EscapeString(name), html.EscapeString(user.Email), url.QueryEscape(safeNext(back)), + html.EscapeString(orDash(g.device)), html.EscapeString(orDash(g.os)), html.EscapeString(orDash(g.ip))) +} + +func orDash(s string) string { + if strings.TrimSpace(s) == "" { + return "—" + } + return s +} + +// pageDeviceLegacy forwards the pre-0.13 link shape (/auth/device?code=…), +// which older CLIs still print, to the path form. +func (a *BuiltinAuth) pageDeviceLegacy(w http.ResponseWriter, r *http.Request) { + code := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("code"))) + if code == "" { + authPage(w, "Connect a device", `

Run bdrive login --device on the machine you want to connect; it prints a link to open here.

`) + return + } + http.Redirect(w, r, "/auth/device/"+url.PathEscape(code), http.StatusSeeOther) } // pageVerify activates an account from an email link, then either starts a @@ -963,23 +1040,37 @@ func (a *BuiltinAuth) apiExchange(w http.ResponseWriter, r *http.Request) { a.finishLogin(w, g.user, req.Device) } -// apiDeviceStart begins the headless flow: the CLI shows the code, the user -// approves it at /auth/device, the CLI polls. +// apiDeviceStart begins the headless flow: the CLI prints the approval link, +// the user opens it in any signed-in browser, the CLI polls. The link itself +// is the secret (RFC 8628 calls this verification_uri_complete), so it is a +// full-length token, not something short enough to retype — nobody has to +// read a code off one screen and type it into another. +// +// The requesting device's name, OS, and address are recorded here so the +// approval page can show WHAT is being approved: this flow's weakness is that +// a stranger can send you their own pending link, and a page that just says +// "Approve" gives you nothing to notice with. func (a *BuiltinAuth) apiDeviceStart(w http.ResponseWriter, r *http.Request) { var req struct { Device string `json:"device"` + OS string `json:"os"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) return } - code := randHex(4) // short enough to type + code := randHex(16) a.mu.Lock() - a.pending[code] = pendingGrant{kind: "device", device: req.Device, expires: time.Now().Add(10 * time.Minute)} + a.pending[code] = pendingGrant{ + kind: "device", device: req.Device, os: req.OS, ip: requestIP(r), + expires: time.Now().Add(10 * time.Minute), + } a.mu.Unlock() writeJSON(w, map[string]any{ + // "code" keeps its wire name: it is what the CLI polls with, and + // older clients still print it. "code": code, - "verify_url": requestBaseURL(r) + "/auth/device?code=" + code, + "verify_url": requestBaseURL(r) + "/auth/device/" + code, "interval": 2, }) } diff --git a/internal/webapp/cli_e2e_test.go b/internal/webapp/cli_e2e_test.go index 07c9bc8..5300bcb 100644 --- a/internal/webapp/cli_e2e_test.go +++ b/internal/webapp/cli_e2e_test.go @@ -77,9 +77,9 @@ func newCLIEnv(t *testing.T) cliEnv { t.Fatal(err) } t.Cleanup(func() { login.Process.Kill() }) - code := waitForCode(t, logFile) + approve := waitForApprovalLink(t, logFile) browser := signedInBrowser(t, hub.URL) - if _, err := browser.PostForm(hub.URL+"/auth/device", url.Values{"code": {code}}); err != nil { + if _, err := browser.PostForm(approve, nil); err != nil { t.Fatal(err) } if err := login.Wait(); err != nil { @@ -500,21 +500,21 @@ func hubProjects(t *testing.T, browser *http.Client, hubURL string) string { return string(data) } -var codeRe = regexp.MustCompile(`approve code: ([a-z0-9]+)`) +var approveRe = regexp.MustCompile(`(https?://\S+/auth/device/[a-f0-9]+)`) // waitForCode polls the login command's output for the device code it prints. -func waitForCode(t *testing.T, logFile string) string { +func waitForApprovalLink(t *testing.T, logFile string) string { t.Helper() deadline := time.Now().Add(15 * time.Second) for time.Now().Before(deadline) { data, _ := os.ReadFile(logFile) - if m := codeRe.FindSubmatch(data); m != nil { + if m := approveRe.FindSubmatch(data); m != nil { return string(m[1]) } time.Sleep(100 * time.Millisecond) } data, _ := os.ReadFile(logFile) - t.Fatalf("login --device never printed a device code:\n%s", data) + t.Fatalf("login --device never printed an approval link:\n%s", data) return "" } diff --git a/plugin/skills/beardrive/SKILL.md b/plugin/skills/beardrive/SKILL.md index b86387f..72907f2 100644 --- a/plugin/skills/beardrive/SKILL.md +++ b/plugin/skills/beardrive/SKILL.md @@ -29,7 +29,7 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing | Undo a change to a file | `bdrive restore []` — writes an earlier version back as a NEW change (never rewrites history); no version = the previous one, `--list` shows them. Also in the hub's History view. Cannot un-create a file a run created (see below) | | Move a project to a different hub (cloud ↔ self-hosted) | `bdrive export []` writes a portable `.tar.gz` of the whole project — every device's journal and every blob, so full history and authorship travel. Then `bdrive login ` and `bdrive import ` recreates it there as a new project (`--name` overrides; target must be empty); connect folders with `bdrive init --project `. Sync first so the export is complete. | | This device's identity | `bdrive whoami` | -| Sign this device in (once per device) | `bdrive login [url]` — bare form targets BearDrive Cloud (beardrive.ai): signing up there auto-creates a free personal workspace, no questions asked. Self-hosting teams pass their hub URL instead. Opens the sign-in page in a browser (sign-up available there); the terminal completes on its own and stores a per-device token. `--device` prints a code to approve from any browser (SSH/headless), and login falls back to that code flow automatically when there is no TTY (agent shells, CI) or no browser opens; `--status` shows server + account. Password reset: "Forgot password?" on the sign-in page (emailed via the server's SMTP config, or the link appears in the server log). **Switch hubs** with `bdrive login `, then re-run `bdrive init` in each folder. | +| Sign this device in (once per device) | `bdrive login [url]` — bare form targets BearDrive Cloud (beardrive.ai): signing up there auto-creates a free personal workspace, no questions asked. Self-hosting teams pass their hub URL instead. Opens the sign-in page in a browser (sign-up available there); the terminal completes on its own and stores a per-device token. `--device` prints one approval link to open in any signed-in browser (SSH/headless) — nothing to retype, and the page names the device asking — and login falls back to that flow automatically when there is no TTY (agent shells, CI) or no browser opens; `--status` shows server + account. Password reset: "Forgot password?" on the sign-in page (emailed via the server's SMTP config, or the link appears in the server log). **Switch hubs** with `bdrive login `, then re-run `bdrive init` in each folder. | | Sign this device out | `bdrive logout` — clears the saved token + account (folders untouched); `--forget` also drops the remembered server. The token is only cleared locally: the hub still accepts it and there is no revoke yet. | | Link a synced file for teammates | `bdrive url ` — prints the file's hub viewer URL (sign-in + project membership required; always the latest content). Computed locally, no network; `--sync` pushes first so a just-created file's link resolves immediately; no arg = the project home page. **After creating a shareable artifact (.md/.html/.csv/report/plan) in the shared folder, include this link in your reply** so teammates can open it. | | Share a synced file publicly by URL | `bdrive share ` — prints a link anyone can open (HTML renders as a page, markdown rendered, PDFs inline; sandboxed; always the latest content; no account needed). `--expires 24h` for self-destructing links; `--list` / `--revoke ` to manage. The hub's Share dialog can also set an expiry (24h / 7d / 30d) on an already-minted link — same token, same URL. Put generated reports in the shared folder, sync, then share. | @@ -208,7 +208,7 @@ to set up BearDrive project on . Ask me which folder to sy The fetched instructions cover install and one `bdrive init --project --server`, which signs in, installs the skill and registers the hooks. Sign-in uses `login --device` because an agent is driving: a browser-callback sign-in is -invisible to it mid-turn, while the device flow yields a code and URL it can +invisible to it mid-turn, while the device flow yields one approval link it can hand back in chat. The hub's project home page renders this prompt with the URL and id filled in. diff --git a/web/docs/src/content/docs/manual/setup-by-hand.md b/web/docs/src/content/docs/manual/setup-by-hand.md index a82dfca..e8a1940 100644 --- a/web/docs/src/content/docs/manual/setup-by-hand.md +++ b/web/docs/src/content/docs/manual/setup-by-hand.md @@ -30,8 +30,8 @@ bdrive login https://your-hub This opens your browser, and the terminal finishes on its own. On a headless or SSH machine login falls back to the device-code flow automatically (no TTY, or -no browser can open): it prints a short code you approve from any signed-in -browser. `bdrive login --device` forces that flow. +no browser can open): it prints one link you open in any signed-in browser and +approve — nothing to retype. `bdrive login --device` forces that flow. `bdrive login --status` shows the current server and account. diff --git a/web/docs/src/content/docs/reference/cli.md b/web/docs/src/content/docs/reference/cli.md index 7432358..52cae16 100644 --- a/web/docs/src/content/docs/reference/cli.md +++ b/web/docs/src/content/docs/reference/cli.md @@ -9,7 +9,7 @@ One binary, `bdrive` — the CLI, the sync daemon, and the web server. | Command | Description | |---|---| -| `bdrive login [server-url]` | Sign this device in. Browser flow; `--device` forces the code flow, and shells without a TTY (agents, CI, SSH) fall back to it automatically. Default server is beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host. Switch hubs with `bdrive login `. `--status` shows the current server and account | +| `bdrive login [server-url]` | Sign this device in. Browser flow; `--device` forces the approval-link flow, and shells without a TTY (agents, CI, SSH) fall back to it automatically. Default server is beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host. Switch hubs with `bdrive login `. `--status` shows the current server and account | | `bdrive logout` | Sign this device out — clear the saved token and account. `--forget` also drops the remembered server | | `bdrive init [folder]` | Create or connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY; flags (`--name`, `--project`, `--server`, `--only`, `--yes`) for scripts. Also installs the agent skill, registers agent sync hooks for detected platforms (`--no-hooks` skips the hooks only), and prints the project's hub link. Re-run to resume | | `bdrive stop [folder]` | Stop syncing — daemon and agent sync hooks both pause. Files stay on disk; `bdrive init` resumes | diff --git a/web/docs/src/content/docs/self-hosting/authentication.md b/web/docs/src/content/docs/self-hosting/authentication.md index 5ad11ce..b4c8ac7 100644 --- a/web/docs/src/content/docs/self-hosting/authentication.md +++ b/web/docs/src/content/docs/self-hosting/authentication.md @@ -64,8 +64,12 @@ to the CLI's loopback listener and the terminal finishes on its own, storing a long-lived per-device token that is revocable server-side. On headless or SSH machines login falls back to the device-code flow -automatically (no TTY, or no browser can open): it prints a short code to -approve from any signed-in browser. `bdrive login --device` forces that flow. +automatically (no TTY, or no browser can open): it prints one approval link to +open in any signed-in browser. The link carries the request, so there is no code +to retype; the page it opens names the account you would be granting, the device +asking, its OS, and the address it came from, so an unexpected approval request +is visible rather than anonymous. Approving is a POST from that page — a link +alone can't grant. `bdrive login --device` forces that flow. Every sync and every `bdrive init` then authenticates with that token. The hub's device registry records per-device name, OS, account, and the IP the server diff --git a/web/docs/src/content/docs/start/setup.md b/web/docs/src/content/docs/start/setup.md index 95d8ae2..5d820c5 100644 --- a/web/docs/src/content/docs/start/setup.md +++ b/web/docs/src/content/docs/start/setup.md @@ -54,7 +54,8 @@ to set up BearDrive project on . Ask me which folder to sy ``` The agent fetches that page and works through it — install the CLI, sign in -with a device code, connect the project, register the sync hooks. You copy one +(it prints a link you approve in the browser), connect the project, register +the sync hooks. You copy one thing; the agent handles every deviation — already installed, no Homebrew, browser sign-in, wrong folder. On BearDrive Cloud, drop `on ` — sign-in defaults to beardrive.ai.