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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 44 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,27 +339,50 @@ notte profiles delete --profile-id <id> # Delete a profile
notte profiles sync # Copy your local browser login into a profile
```

`profiles sync` reads the cookies from a local Firefox, Chrome, Brave, Edge,
Chromium, Arc or Helium profile and uploads them into a Notte profile, so remote
sessions start already logged in. The cookies are read from a copy of the
browser's own files on your machine (and, for the Chromium browsers, decrypted
with the key from your OS keychain); nothing is sent until you confirm, and
`--domain` limits the sync to the sites you name.

```bash
notte profiles sync # pick a browser profile, create a Notte profile
notte profiles sync --domain github.com --domain mail.google.com # only these sites (and subdomains)
notte profiles sync --profile-id <id> # refresh an existing profile after a re-login
notte profiles sync --browser brave --local-profile Work # choose the source without the prompt
```

Then start a session with it: `notte sessions start --profile-id <id>`. Re-run
`sync` whenever the local login changes. Supported on macOS and Linux (Arc and
Helium on macOS only); for the
Chromium browsers the first run asks permission to read the browser key from your
OS keychain (Firefox stores cookies in the clear and needs no such permission).
Scoped syncs into an existing profile may want `--mode append` so other sites'
cookies are kept.
`profiles sync` copies cookies and localStorage from a local Firefox, Chrome,
Brave, Edge, Chromium, Arc or Helium profile into a Notte profile. This includes
logins stored only in localStorage, even when the site has no cookies. Browser
files are read through private temporary copies; encrypted cookies are decrypted
locally. Nothing is uploaded until you confirm (or pass `--yes`). `--domain`
restricts cookies and storage to the named hosts and their subdomains.

```bash
notte profiles sync # choose a local browser profile
notte profiles sync --domain bsky.app # include storage-only logins
notte profiles sync --browser brave --local-profile Work # choose the source explicitly
notte profiles sync --profile-id <id> --domain bsky.app # refresh an existing profile
notte profiles sync --cdp-url http://127.0.0.1:9222 --domain bsky.app
```

Start a remote session with `notte sessions start --profile-id <id>`. Re-run sync
when a local login changes. Disk reading is supported on macOS and Linux (Arc
and Helium on macOS only). Chromium cookie decryption may ask for OS keychain
access. Close the source browser and retry if its files change during a copy.

For live **sessionStorage**, use `--cdp-url` with a Chromium browser already
running with remote debugging enabled in a dedicated profile. Sign into the
site there and keep its tab open. Accepts a debugger HTTP origin or a **page**
WebSocket URL; it reads matching top-level tabs without navigating them. Tabs
with conflicting state for the same origin are rejected rather than combining
different accounts. Disk sync cannot capture in-memory sessionStorage and leaves
previously stored sessionStorage unchanged.

Storage imports require an API deployment supporting `POST /profiles/{id}/state`.
Older deployments return an explicit unsupported-server error; the CLI never
silently drops localStorage to fall back to cookies. Scoped cookie-only syncs
also use the state API to preserve other sites. Unscoped cookie-only disk syncs
remain compatible with the existing cookies API. `--mode replace` replaces
captured state classes within `--domain` (or all sites without a scope).
Live sync replaces web storage only for captured origins, preserving saved
state for closed tabs. `--mode append` merges cookie identities and storage keys.
Avoid importing into a profile while a remote browser is saving
its state into that same profile.

The snapshot limit is 8 MiB. Values are never printed in summaries or JSON
output. Unreadable or undecryptable selected state fails before upload. IndexedDB,
partitioned/container storage, browser extensions, passkeys and device-bound
credentials are not transferred. Some sites also bind sessions to an IP or
require a new sign-in; copying browser state cannot guarantee every site's login.

### Files

Expand Down
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ go 1.25.5

require (
github.com/99designs/keyring v1.2.2
github.com/golang/snappy v0.0.4
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/muesli/termenv v0.16.0
github.com/oapi-codegen/runtime v1.1.2
github.com/spf13/cobra v1.8.0
github.com/spf13/pflag v1.0.5
github.com/syndtr/goleveldb v1.0.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.3.0
modernc.org/sqlite v1.59.0
Expand All @@ -21,7 +25,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/dvsekhvalnov/jose2go v1.7.0 // indirect
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
Expand Down
30 changes: 30 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,25 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dvsekhvalnov/jose2go v1.7.0 h1:bnQc8+GMnidJZA8zc6lLEAb4xNrIqHwO+9TzqvtQZPo=
github.com/dvsekhvalnov/jose2go v1.7.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
Expand All @@ -48,6 +57,11 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI=
github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
Expand All @@ -67,20 +81,36 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Expand Down
73 changes: 73 additions & 0 deletions internal/api/profile_state.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package api

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)

// BrowserStateUploadRequest uses the cloud profile's existing browser-state
// format. Domain-scoped replace leaves unrelated cloud logins unchanged.
type BrowserStateUploadRequest struct {
Cookies []Cookie `json:"cookies"`
LocalStorage map[string]map[string]string `json:"local_storage"`
SessionStorage *map[string]map[string]string `json:"session_storage,omitempty"`
Domains []string `json:"domains,omitempty"`
Mode string `json:"mode"`
StorageScope string `json:"storage_scope,omitempty"`
}

type BrowserStateUploadResponse struct {
Success bool `json:"success"`
CookiesCount int `json:"cookies_count"`
LocalStorageOrigins int `json:"local_storage_origins"`
SessionStorageOrigins int `json:"session_storage_origins"`
}

// Validate checks the final wire representation before a destination is created.
func (state BrowserStateUploadRequest) Validate() error {
body, err := json.Marshal(state)
if err != nil {
return fmt.Errorf("cannot encode browser state")
}
if len(body) > 8*1024*1024 {
return fmt.Errorf("browser state exceeds the 8 MiB import limit")
}
return nil
}

func (c *NotteClient) ProfileStateSetWithResponse(ctx context.Context, id string, state BrowserStateUploadRequest) (*BrowserStateUploadResponse, int, error) {
if err := state.Validate(); err != nil {
return nil, 0, err
}
body, _ := json.Marshal(state)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL(), "/")+"/profiles/"+url.PathEscape(id)+"/state", bytes.NewReader(body))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/json")
// Redirects must never forward a body containing browser credentials.
httpClient := *c.HTTPClient()
httpClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
resp, err := httpClient.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("profile state import could not be confirmed; inspect the profile before retrying")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed {
return nil, resp.StatusCode, fmt.Errorf("this API does not support browser-state import yet; update the server before syncing storage-based logins")
}
if resp.StatusCode != http.StatusOK {
return nil, resp.StatusCode, fmt.Errorf("profile state import failed (HTTP %d); browser values were not printed", resp.StatusCode)
}
var result BrowserStateUploadResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 65536)).Decode(&result); err != nil || !result.Success {
return nil, resp.StatusCode, fmt.Errorf("profile state import returned an invalid confirmation")
}
return &result, resp.StatusCode, nil
}
72 changes: 72 additions & 0 deletions internal/api/profile_state_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package api

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestImportProfileStorageOnly(t *testing.T) {
for _, live := range []bool{false, true} {
t.Run(fmt.Sprint(live), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer synthetic-key" {
t.Error("missing workspace authentication")
}
if r.URL.Path != "/profiles/notte-profile-0123456789abcdef/state" {
t.Error("wrong import route")
}
var body map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if _, ok := body["session_storage"]; ok != live {
t.Error("uncaptured session storage must be omitted; captured empty state must be explicit")
}
_, _ = w.Write([]byte(`{"success":true,"cookies_count":0,"local_storage_origins":1,"session_storage_origins":0}`))
}))
defer server.Close()
client, err := NewClientWithURL("synthetic-key", server.URL, "")
if err != nil {
t.Fatal(err)
}
state := BrowserStateUploadRequest{Cookies: []Cookie{}, LocalStorage: map[string]map[string]string{"https://bsky.app": {"BSKY_STORAGE": "synthetic-private-token"}}, Mode: "replace"}
if live {
sessions := map[string]map[string]string{}
state.SessionStorage = &sessions
}
response, status, err := client.ProfileStateSetWithResponse(context.Background(), "notte-profile-0123456789abcdef", state)
if err != nil || status != 200 || response.LocalStorageOrigins != 1 {
t.Fatalf("import failed: %v", err)
}
})
}
}

func TestImportProfileStateNeverLeaksBodyOrFollowsRedirects(t *testing.T) {
for _, code := range []int{307, 308, 400, 401, 403, 404, 405, 422, 500} {
t.Run(fmt.Sprint(code), func(t *testing.T) {
forwarded := false
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { forwarded = true; w.WriteHeader(200) }))
defer target.Close()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Location", target.URL)
w.WriteHeader(code)
_, _ = w.Write([]byte("synthetic-private-token"))
}))
defer server.Close()
client, err := NewClientWithURL("synthetic-key", server.URL, "")
if err != nil {
t.Fatal(err)
}
_, _, err = client.ProfileStateSetWithResponse(context.Background(), "profile", BrowserStateUploadRequest{LocalStorage: map[string]map[string]string{"https://bsky.app": {"token": "synthetic-private-token"}}})
if err == nil || strings.Contains(err.Error(), "synthetic-private-token") || forwarded {
t.Fatal("import failure leaked or forwarded browser credentials")
}
})
}
}
Loading
Loading