akashyadav758 Claude Opus 4.8 (1M context) commited on
Commit
69c2cb9
Β·
1 Parent(s): c6e6dac

Persist Chrome profile across restarts via Postgres snapshot

Browse files

Hugging Face Spaces have an ephemeral filesystem, so the Chrome
user-data dir (cookies / logins) is wiped on every restart and rebuild.
Snapshot it into Postgres (Neon) instead:

- profilesync: a small Go tool that tar+gzips /home/chrome/data into a
Postgres bytea blob (backup) and extracts it back (restore). Cache/junk
dirs are excluded so the blob stays small. Connection comes from the
DATABASE_URL env var β€” the credential is never hardcoded.
- Dockerfile: build the profilesync binary and ship it in /opt/profilesync.
- start_hf.sh: restore the profile on boot, back it up on shutdown (after
Chrome closes its files) and every 5 min as a safety net. All guarded by
DATABASE_URL, so the Space still runs fine without the secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (5) hide show
  1. Dockerfile +12 -3
  2. profilesync/go.mod +12 -0
  3. profilesync/go.sum +28 -0
  4. profilesync/main.go +220 -0
  5. start_hf.sh +20 -0
Dockerfile CHANGED
@@ -1,9 +1,15 @@
1
- # Build the 3-tab monitor server (GPT / Gemini / Flow live view) from source.
2
  FROM golang:1.22-bookworm AS monitor-build
3
  WORKDIR /src
4
  COPY monitor/ ./
5
  RUN go mod tidy && CGO_ENABLED=0 go build -ldflags="-s -w" -o /monitor .
6
 
 
 
 
 
 
 
7
  FROM akashyadav758/chrome:latest
8
 
9
  USER root
@@ -16,6 +22,9 @@ COPY flow-agent/Flow-extension /opt/flow-extension
16
  # 3-tab monitor binary (replaces the single-view base monitor)
17
  COPY --from=monitor-build /monitor /opt/monitor3/monitor
18
 
 
 
 
19
  # Install Chrome for Testing (unbranded). Branded google-chrome-stable (>=128)
20
  # silently ignores --load-extension, so unpacked extensions never load. CfT is the
21
  # unbranded build of the same Chrome version where --load-extension still works.
@@ -29,8 +38,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends unzip && \
29
 
30
  # Set correct permissions and ownership
31
  RUN rm -rf /opt/flow-extension/_metadata && \
32
- chown -R 1000:1000 /opt/gpt-extension /opt/gemini-extension /opt/flow-extension /opt/chrome-linux64 /opt/monitor3 && \
33
- chmod -R 755 /opt/gpt-extension /opt/gemini-extension /opt/flow-extension /opt/chrome-linux64 /opt/monitor3
34
 
35
  # Replace the start script with the non-root version
36
  COPY --chmod=755 start_hf.sh /start.sh
 
1
+ # Build the navigator monitor server (GPT / Gemini / Flow live view) from source.
2
  FROM golang:1.22-bookworm AS monitor-build
3
  WORKDIR /src
4
  COPY monitor/ ./
5
  RUN go mod tidy && CGO_ENABLED=0 go build -ldflags="-s -w" -o /monitor .
6
 
7
+ # Build the profile-sync tool that snapshots the Chrome profile to Postgres (Neon).
8
+ FROM golang:1.22-bookworm AS profilesync-build
9
+ WORKDIR /src
10
+ COPY profilesync/ ./
11
+ RUN go mod tidy && CGO_ENABLED=0 go build -ldflags="-s -w" -o /profilesync .
12
+
13
  FROM akashyadav758/chrome:latest
14
 
15
  USER root
 
22
  # 3-tab monitor binary (replaces the single-view base monitor)
23
  COPY --from=monitor-build /monitor /opt/monitor3/monitor
24
 
25
+ # Profile-sync binary (Chrome profile <-> Postgres snapshot)
26
+ COPY --from=profilesync-build /profilesync /opt/profilesync/profilesync
27
+
28
  # Install Chrome for Testing (unbranded). Branded google-chrome-stable (>=128)
29
  # silently ignores --load-extension, so unpacked extensions never load. CfT is the
30
  # unbranded build of the same Chrome version where --load-extension still works.
 
38
 
39
  # Set correct permissions and ownership
40
  RUN rm -rf /opt/flow-extension/_metadata && \
41
+ chown -R 1000:1000 /opt/gpt-extension /opt/gemini-extension /opt/flow-extension /opt/chrome-linux64 /opt/monitor3 /opt/profilesync && \
42
+ chmod -R 755 /opt/gpt-extension /opt/gemini-extension /opt/flow-extension /opt/chrome-linux64 /opt/monitor3 /opt/profilesync
43
 
44
  # Replace the start script with the non-root version
45
  COPY --chmod=755 start_hf.sh /start.sh
profilesync/go.mod ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module profilesync
2
+
3
+ go 1.22
4
+
5
+ require github.com/jackc/pgx/v5 v5.6.0
6
+
7
+ require (
8
+ github.com/jackc/pgpassfile v1.0.0 // indirect
9
+ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
10
+ golang.org/x/crypto v0.17.0 // indirect
11
+ golang.org/x/text v0.14.0 // indirect
12
+ )
profilesync/go.sum ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2
+ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3
+ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4
+ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
5
+ github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
6
+ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
7
+ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
8
+ github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
9
+ github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
10
+ github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
11
+ github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
12
+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
13
+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
14
+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
15
+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
16
+ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
17
+ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
18
+ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
19
+ golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
20
+ golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
21
+ golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
22
+ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
23
+ golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
24
+ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
25
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
26
+ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
27
+ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
28
+ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
profilesync/main.go ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // profilesync persists the Chrome user-data profile across Hugging Face Space
2
+ // restarts/rebuilds by snapshotting it into a Postgres (Neon) bytea blob.
3
+ //
4
+ // profilesync restore β€” pull the latest snapshot and extract it (run at boot)
5
+ // profilesync backup β€” tar+gzip the profile and upsert it (run on shutdown / periodically)
6
+ //
7
+ // The connection string comes from the DATABASE_URL env var (a Space secret) β€” it
8
+ // is never hardcoded. Cache/junk directories are excluded so the blob stays small
9
+ // enough for Neon's free tier; only login state (cookies, Local Storage, IndexedDB,
10
+ // Login Data, …) is kept.
11
+ package main
12
+
13
+ import (
14
+ "archive/tar"
15
+ "bytes"
16
+ "compress/gzip"
17
+ "context"
18
+ "errors"
19
+ "io"
20
+ "log"
21
+ "os"
22
+ "path/filepath"
23
+ "strings"
24
+ "time"
25
+
26
+ "github.com/jackc/pgx/v5"
27
+ )
28
+
29
+ const (
30
+ profileDir = "/home/chrome/data" // Chrome --user-data-dir
31
+ baseName = "data" // archive top-level dir
32
+ )
33
+
34
+ func main() {
35
+ log.SetFlags(0)
36
+ log.SetPrefix("[profilesync] ")
37
+
38
+ if len(os.Args) < 2 {
39
+ log.Fatal("usage: profilesync [backup|restore]")
40
+ }
41
+ mode := os.Args[1]
42
+
43
+ dsn := os.Getenv("DATABASE_URL")
44
+ if dsn == "" {
45
+ log.Println("DATABASE_URL not set β€” skipping")
46
+ return
47
+ }
48
+
49
+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
50
+ defer cancel()
51
+
52
+ conn, err := pgx.Connect(ctx, dsn)
53
+ if err != nil {
54
+ log.Fatalf("connect: %v", err)
55
+ }
56
+ defer conn.Close(ctx)
57
+
58
+ if _, err := conn.Exec(ctx, `CREATE TABLE IF NOT EXISTS chrome_profile (
59
+ id int PRIMARY KEY DEFAULT 1,
60
+ updated_at timestamptz DEFAULT now(),
61
+ data bytea
62
+ )`); err != nil {
63
+ log.Fatalf("create table: %v", err)
64
+ }
65
+
66
+ switch mode {
67
+ case "backup":
68
+ if err := backup(ctx, conn); err != nil {
69
+ log.Fatalf("backup: %v", err)
70
+ }
71
+ case "restore":
72
+ if err := restore(ctx, conn); err != nil {
73
+ log.Fatalf("restore: %v", err)
74
+ }
75
+ default:
76
+ log.Fatalf("unknown mode %q (want backup|restore)", mode)
77
+ }
78
+ }
79
+
80
+ // skipSegment reports whether a path segment is cache/junk we never persist.
81
+ func skipSegment(name string) bool {
82
+ if strings.Contains(name, "Cache") || strings.HasPrefix(name, "Singleton") {
83
+ return true
84
+ }
85
+ switch name {
86
+ case "Crashpad", "component_crx_cache", "optimization_guide_model_store",
87
+ "GraphiteDawnCache", "BrowserMetrics", "Safe Browsing", "LOCK", "LOG.old":
88
+ return true
89
+ }
90
+ return false
91
+ }
92
+
93
+ func backup(ctx context.Context, conn *pgx.Conn) error {
94
+ if _, err := os.Stat(profileDir); err != nil {
95
+ log.Printf("profile dir %s missing β€” nothing to back up", profileDir)
96
+ return nil
97
+ }
98
+
99
+ var buf bytes.Buffer
100
+ gz := gzip.NewWriter(&buf)
101
+ tw := tar.NewWriter(gz)
102
+
103
+ walkErr := filepath.Walk(profileDir, func(path string, info os.FileInfo, err error) error {
104
+ if err != nil {
105
+ return nil // skip unreadable entries
106
+ }
107
+ rel, err := filepath.Rel(profileDir, path)
108
+ if err != nil || rel == "." {
109
+ return nil
110
+ }
111
+ for _, seg := range strings.Split(rel, string(os.PathSeparator)) {
112
+ if skipSegment(seg) {
113
+ if info.IsDir() {
114
+ return filepath.SkipDir
115
+ }
116
+ return nil
117
+ }
118
+ }
119
+ if !info.IsDir() && !info.Mode().IsRegular() {
120
+ return nil // skip sockets/symlinks/etc.
121
+ }
122
+ hdr, err := tar.FileInfoHeader(info, "")
123
+ if err != nil {
124
+ return nil
125
+ }
126
+ hdr.Name = filepath.ToSlash(filepath.Join(baseName, rel))
127
+ if info.IsDir() {
128
+ hdr.Name += "/"
129
+ }
130
+ if err := tw.WriteHeader(hdr); err != nil {
131
+ return err
132
+ }
133
+ if info.IsDir() {
134
+ return nil
135
+ }
136
+ f, err := os.Open(path)
137
+ if err != nil {
138
+ return nil // file vanished or locked β€” skip it
139
+ }
140
+ defer f.Close()
141
+ io.Copy(tw, f)
142
+ return nil
143
+ })
144
+ if walkErr != nil {
145
+ return walkErr
146
+ }
147
+ if err := tw.Close(); err != nil {
148
+ return err
149
+ }
150
+ if err := gz.Close(); err != nil {
151
+ return err
152
+ }
153
+
154
+ if _, err := conn.Exec(ctx,
155
+ `INSERT INTO chrome_profile (id, data, updated_at) VALUES (1, $1, now())
156
+ ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data, updated_at = now()`,
157
+ buf.Bytes()); err != nil {
158
+ return err
159
+ }
160
+ log.Printf("backed up %d KB (compressed) to Postgres", buf.Len()/1024)
161
+ return nil
162
+ }
163
+
164
+ func restore(ctx context.Context, conn *pgx.Conn) error {
165
+ var data []byte
166
+ err := conn.QueryRow(ctx, `SELECT data FROM chrome_profile WHERE id = 1`).Scan(&data)
167
+ if errors.Is(err, pgx.ErrNoRows) {
168
+ log.Println("no snapshot yet β€” fresh start")
169
+ return nil
170
+ }
171
+ if err != nil {
172
+ return err
173
+ }
174
+ if len(data) == 0 {
175
+ log.Println("empty snapshot β€” fresh start")
176
+ return nil
177
+ }
178
+
179
+ gz, err := gzip.NewReader(bytes.NewReader(data))
180
+ if err != nil {
181
+ return err
182
+ }
183
+ defer gz.Close()
184
+ tr := tar.NewReader(gz)
185
+
186
+ root := filepath.Dir(profileDir) // /home/chrome ; "data/..." lands at /home/chrome/data/...
187
+ cleanRoot := filepath.Clean(root)
188
+ var n int
189
+ for {
190
+ hdr, err := tr.Next()
191
+ if errors.Is(err, io.EOF) {
192
+ break
193
+ }
194
+ if err != nil {
195
+ return err
196
+ }
197
+ target := filepath.Join(root, filepath.Clean(hdr.Name))
198
+ if target != cleanRoot && !strings.HasPrefix(target, cleanRoot+string(os.PathSeparator)) {
199
+ continue // guard against path traversal
200
+ }
201
+ switch hdr.Typeflag {
202
+ case tar.TypeDir:
203
+ os.MkdirAll(target, 0o755)
204
+ case tar.TypeReg:
205
+ os.MkdirAll(filepath.Dir(target), 0o755)
206
+ f, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode)&0o777)
207
+ if err != nil {
208
+ return err
209
+ }
210
+ if _, err := io.Copy(f, tr); err != nil {
211
+ f.Close()
212
+ return err
213
+ }
214
+ f.Close()
215
+ n++
216
+ }
217
+ }
218
+ log.Printf("restored %d files from Postgres snapshot", n)
219
+ return nil
220
+ }
start_hf.sh CHANGED
@@ -5,12 +5,25 @@ cleanup() {
5
  echo "πŸ›‘ Shutting down..."
6
  pkill -TERM -f chrome-linux64 2>/dev/null || true
7
  sleep 5
 
 
 
 
 
 
8
  exit 0
9
  }
10
  trap cleanup SIGTERM SIGINT
11
 
12
  echo "πŸš€ Starting Chrome Headless Boot Sequence on Hugging Face Spaces..."
13
 
 
 
 
 
 
 
 
14
  # 1. Structure Detection & Fix
15
  if [ -d "/home/chrome/data/data/Default" ]; then
16
  echo "⚠️ Nested structure detected. Fixing..."
@@ -93,6 +106,13 @@ touch /home/chrome/chrome.log
93
  echo "πŸ”Œ Starting CDP Proxy..."
94
  socat TCP-LISTEN:9223,fork,bind=0.0.0.0 TCP:127.0.0.1:9222 &
95
 
 
 
 
 
 
 
 
96
  # 7. Launch Chrome Headless
97
  echo "✨ Launching Chrome Headless..."
98
  /opt/chrome-linux64/chrome \
 
5
  echo "πŸ›‘ Shutting down..."
6
  pkill -TERM -f chrome-linux64 2>/dev/null || true
7
  sleep 5
8
+ # Snapshot the profile (cookies/logins) to Postgres after Chrome has closed
9
+ # its files, so the next boot restores a consistent login state.
10
+ if [ -n "$DATABASE_URL" ]; then
11
+ echo "πŸ’Ύ Backing up Chrome profile to Postgres..."
12
+ /opt/profilesync/profilesync backup || echo "⚠️ Backup failed"
13
+ fi
14
  exit 0
15
  }
16
  trap cleanup SIGTERM SIGINT
17
 
18
  echo "πŸš€ Starting Chrome Headless Boot Sequence on Hugging Face Spaces..."
19
 
20
+ # 0. Restore Chrome profile from Postgres snapshot (survives HF restart/rebuild).
21
+ # DATABASE_URL is a Space secret β€” never hardcode the credential here.
22
+ if [ -n "$DATABASE_URL" ]; then
23
+ echo "πŸ“₯ Restoring Chrome profile from Postgres..."
24
+ /opt/profilesync/profilesync restore || echo "⚠️ Restore skipped/failed (first run?)"
25
+ fi
26
+
27
  # 1. Structure Detection & Fix
28
  if [ -d "/home/chrome/data/data/Default" ]; then
29
  echo "⚠️ Nested structure detected. Fixing..."
 
106
  echo "πŸ”Œ Starting CDP Proxy..."
107
  socat TCP-LISTEN:9223,fork,bind=0.0.0.0 TCP:127.0.0.1:9222 &
108
 
109
+ # 6b. Periodic profile backup (safety net if HF kills the container without a clean
110
+ # SIGTERM). Best-effort every 5 minutes; the shutdown trap does the final one.
111
+ if [ -n "$DATABASE_URL" ]; then
112
+ echo "πŸ—„οΈ Profile auto-backup every 5 min enabled."
113
+ ( while sleep 300; do /opt/profilesync/profilesync backup >/dev/null 2>&1 || true; done ) &
114
+ fi
115
+
116
  # 7. Launch Chrome Headless
117
  echo "✨ Launching Chrome Headless..."
118
  /opt/chrome-linux64/chrome \