feat(browser): persist login sessions across restarts#45371
Conversation
…okie snapshots The managed browser loses all login sessions on gateway restart because Chrome's in-memory cookie store isn't flushed before SIGKILL. This adds: - Periodic cookie save (every 60s) via CDP Network.getAllCookies() - Cookie restore on browser launch via CDP Network.setCookies() - Best-effort save before shutdown (before SIGTERM) - Atomic writes (tmp + rename) to prevent corruption - Config type: browser.sessionPersistence.enabled (default: true) State file: <userDataDir>/openclaw-saved-state.json Fixes login persistence for Google, GitHub, and all web sessions.
Greptile SummaryThis PR adds periodic cookie snapshot persistence to the managed browser profile, solving the problem of login sessions being lost on gateway restart due to Chrome's in-memory cookie store never being flushed to disk (process is SIGKILL'd). The approach—CDP Key findings:
Confidence Score: 3/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/browser/server-context.availability.ts
Line: 83-93
Comment:
**`sessionPersistence` config is never read**
The `browser.sessionPersistence.enabled` and `browser.sessionPersistence.intervalMs` config fields are declared in `types.browser.ts` but are never consumed here (or anywhere else in the codebase). This means:
- Setting `browser.sessionPersistence.enabled: false` will have **no effect** — persistence is always started unconditionally.
- Setting `browser.sessionPersistence.intervalMs` will also be silently ignored — `startPeriodicSave` will always use the 60-second default.
The `profile` object passed into this closure comes from resolved config. The raw config (and its `sessionPersistence` key) needs to be threaded through to `startSessionPersistence`, and both the `enabled` guard and `intervalMs` should be respected before calling `startPeriodicSave`.
```
// Example fix sketch
const persistenceCfg = opts.getState().resolved.sessionPersistence;
if (persistenceCfg?.enabled === false) return;
const stopSave = startPeriodicSave(
...,
persistenceCfg?.intervalMs, // pass through configured interval
);
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/browser/session-persistence.ts
Line: 127-142
Comment:
**Concurrent saves possible with `setInterval` + async callback**
`setInterval` fires every `intervalMs` regardless of whether the previous async callback has finished. If a CDP call is slow (e.g. the browser is under load, or the network is congested), two invocations of the callback can run concurrently and both attempt to write to the same `.tmp` file simultaneously — defeating the atomicity guarantee.
For example, if save #1 is still awaiting `getAllCookiesViaCdp` when the interval fires again, save #2 will call `fs.writeFileSync(tmpPath, ...)` over the partial write of save #1, then `fs.renameSync` before save #1 finishes its own rename — resulting in a corrupted or wrong final file.
Consider tracking an in-flight save and skipping the tick if one is already running:
```typescript
let saving = false;
const timer = setInterval(async () => {
if (saving) return;
saving = true;
try {
const wsUrl = await getCdpWsUrl();
if (!wsUrl) return;
const result = await saveBrowserState(wsUrl, userDataDir);
if (result.cookieCount > 0) {
log.info(`🍪 saved ${result.cookieCount} cookies`);
}
} catch (err) {
log.warn(`cookie periodic save failed: ${String(err)}`);
} finally {
saving = false;
}
}, intervalMs);
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/browser/session-persistence.ts
Line: 97-105
Comment:
**Misleading return value when all cookies are expired**
When a state file exists with cookies but all of them have expired, `validCookies.length` will be `0`. The function skips `setCookiesViaCdp` (correct), but still returns `{ cookieCount: 0, savedAt: state.savedAt }` (non-null).
The caller in `chrome.ts` does `if (restored) { log.info(...restored ${restored.cookieCount} cookies...) }` — so it will log `"🍪 restored 0 cookies (saved ...)"` which is confusing and implies a restore happened when it didn't.
Consider returning `null` in this case, or adjusting the log in `chrome.ts` to only log when `cookieCount > 0`:
```typescript
if (validCookies.length === 0) {
return null;
}
```
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: 6d46c86 |
| const stopSave = startPeriodicSave( | ||
| async () => { | ||
| try { | ||
| return await getChromeWebSocketUrl(profile.cdpUrl); | ||
| } catch { | ||
| return null; | ||
| } | ||
| }, | ||
| resolveOpenClawUserDataDir(profile.name), | ||
| persistenceLog, | ||
| ); |
There was a problem hiding this comment.
sessionPersistence config is never read
The browser.sessionPersistence.enabled and browser.sessionPersistence.intervalMs config fields are declared in types.browser.ts but are never consumed here (or anywhere else in the codebase). This means:
- Setting
browser.sessionPersistence.enabled: falsewill have no effect — persistence is always started unconditionally. - Setting
browser.sessionPersistence.intervalMswill also be silently ignored —startPeriodicSavewill always use the 60-second default.
The profile object passed into this closure comes from resolved config. The raw config (and its sessionPersistence key) needs to be threaded through to startSessionPersistence, and both the enabled guard and intervalMs should be respected before calling startPeriodicSave.
// Example fix sketch
const persistenceCfg = opts.getState().resolved.sessionPersistence;
if (persistenceCfg?.enabled === false) return;
const stopSave = startPeriodicSave(
...,
persistenceCfg?.intervalMs, // pass through configured interval
);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/browser/server-context.availability.ts
Line: 83-93
Comment:
**`sessionPersistence` config is never read**
The `browser.sessionPersistence.enabled` and `browser.sessionPersistence.intervalMs` config fields are declared in `types.browser.ts` but are never consumed here (or anywhere else in the codebase). This means:
- Setting `browser.sessionPersistence.enabled: false` will have **no effect** — persistence is always started unconditionally.
- Setting `browser.sessionPersistence.intervalMs` will also be silently ignored — `startPeriodicSave` will always use the 60-second default.
The `profile` object passed into this closure comes from resolved config. The raw config (and its `sessionPersistence` key) needs to be threaded through to `startSessionPersistence`, and both the `enabled` guard and `intervalMs` should be respected before calling `startPeriodicSave`.
```
// Example fix sketch
const persistenceCfg = opts.getState().resolved.sessionPersistence;
if (persistenceCfg?.enabled === false) return;
const stopSave = startPeriodicSave(
...,
persistenceCfg?.intervalMs, // pass through configured interval
);
```
How can I resolve this? If you propose a fix, please make it concise.| const timer = setInterval(async () => { | ||
| try { | ||
| const wsUrl = await getCdpWsUrl(); | ||
| if (!wsUrl) { | ||
| return; | ||
| } // browser not running | ||
| const result = await saveBrowserState(wsUrl, userDataDir); | ||
| if (result.cookieCount > 0) { | ||
| log.info(`🍪 saved ${result.cookieCount} cookies`); | ||
| } | ||
| } catch (err) { | ||
| log.warn(`cookie periodic save failed: ${String(err)}`); | ||
| } | ||
| }, intervalMs); | ||
|
|
||
| return () => clearInterval(timer); |
There was a problem hiding this comment.
Concurrent saves possible with setInterval + async callback
setInterval fires every intervalMs regardless of whether the previous async callback has finished. If a CDP call is slow (e.g. the browser is under load, or the network is congested), two invocations of the callback can run concurrently and both attempt to write to the same .tmp file simultaneously — defeating the atomicity guarantee.
For example, if save #1 is still awaiting getAllCookiesViaCdp when the interval fires again, save #2 will call fs.writeFileSync(tmpPath, ...) over the partial write of save #1, then fs.renameSync before save #1 finishes its own rename — resulting in a corrupted or wrong final file.
Consider tracking an in-flight save and skipping the tick if one is already running:
let saving = false;
const timer = setInterval(async () => {
if (saving) return;
saving = true;
try {
const wsUrl = await getCdpWsUrl();
if (!wsUrl) return;
const result = await saveBrowserState(wsUrl, userDataDir);
if (result.cookieCount > 0) {
log.info(`🍪 saved ${result.cookieCount} cookies`);
}
} catch (err) {
log.warn(`cookie periodic save failed: ${String(err)}`);
} finally {
saving = false;
}
}, intervalMs);Prompt To Fix With AI
This is a comment left during a code review.
Path: src/browser/session-persistence.ts
Line: 127-142
Comment:
**Concurrent saves possible with `setInterval` + async callback**
`setInterval` fires every `intervalMs` regardless of whether the previous async callback has finished. If a CDP call is slow (e.g. the browser is under load, or the network is congested), two invocations of the callback can run concurrently and both attempt to write to the same `.tmp` file simultaneously — defeating the atomicity guarantee.
For example, if save #1 is still awaiting `getAllCookiesViaCdp` when the interval fires again, save #2 will call `fs.writeFileSync(tmpPath, ...)` over the partial write of save #1, then `fs.renameSync` before save #1 finishes its own rename — resulting in a corrupted or wrong final file.
Consider tracking an in-flight save and skipping the tick if one is already running:
```typescript
let saving = false;
const timer = setInterval(async () => {
if (saving) return;
saving = true;
try {
const wsUrl = await getCdpWsUrl();
if (!wsUrl) return;
const result = await saveBrowserState(wsUrl, userDataDir);
if (result.cookieCount > 0) {
log.info(`🍪 saved ${result.cookieCount} cookies`);
}
} catch (err) {
log.warn(`cookie periodic save failed: ${String(err)}`);
} finally {
saving = false;
}
}, intervalMs);
```
How can I resolve this? If you propose a fix, please make it concise.| const validCookies = state.cookies.filter( | ||
| (c) => c.session || c.expires === -1 || c.expires === 0 || c.expires > now, | ||
| ); | ||
|
|
||
| if (validCookies.length > 0) { | ||
| await setCookiesViaCdp(cdpWsUrl, validCookies); | ||
| } | ||
|
|
||
| return { cookieCount: validCookies.length, savedAt: state.savedAt }; |
There was a problem hiding this comment.
Misleading return value when all cookies are expired
When a state file exists with cookies but all of them have expired, validCookies.length will be 0. The function skips setCookiesViaCdp (correct), but still returns { cookieCount: 0, savedAt: state.savedAt } (non-null).
The caller in chrome.ts does if (restored) { log.info(...restored ${restored.cookieCount} cookies...) } — so it will log "🍪 restored 0 cookies (saved ...)" which is confusing and implies a restore happened when it didn't.
Consider returning null in this case, or adjusting the log in chrome.ts to only log when cookieCount > 0:
if (validCookies.length === 0) {
return null;
}Prompt To Fix With AI
This is a comment left during a code review.
Path: src/browser/session-persistence.ts
Line: 97-105
Comment:
**Misleading return value when all cookies are expired**
When a state file exists with cookies but all of them have expired, `validCookies.length` will be `0`. The function skips `setCookiesViaCdp` (correct), but still returns `{ cookieCount: 0, savedAt: state.savedAt }` (non-null).
The caller in `chrome.ts` does `if (restored) { log.info(...restored ${restored.cookieCount} cookies...) }` — so it will log `"🍪 restored 0 cookies (saved ...)"` which is confusing and implies a restore happened when it didn't.
Consider returning `null` in this case, or adjusting the log in `chrome.ts` to only log when `cookieCount > 0`:
```typescript
if (validCookies.length === 0) {
return null;
}
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6d46c867d6
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| try { | ||
| const wsUrl = await getChromeWebSocketUrl(cdpUrlForPort(running.cdpPort), 500); | ||
| if (wsUrl) { | ||
| await saveBrowserState(wsUrl, running.userDataDir); |
There was a problem hiding this comment.
Bound pre-shutdown cookie save with a timeout
The shutdown path now awaits saveBrowserState before sending SIGTERM, but this call can wait indefinitely if CDP accepts the socket and never replies to Network.getAllCookies (there is no per-command timeout in the CDP helper). In that state, stopOpenClawChrome can hang and never reach the existing CHROME_STOP_TIMEOUT_MS kill logic, so restarts/shutdowns can stall exactly when the browser is unhealthy.
Useful? React with 👍 / 👎.
| const attachRunning = (running: NonNullable<ProfileRuntimeState["running"]>) => { | ||
| setProfileRunning(running); | ||
| startSessionPersistence(); |
There was a problem hiding this comment.
Wire sessionPersistence config into runtime behavior
The new browser.sessionPersistence settings are declared but not honored in the runtime path: browser attach always starts persistence, and the periodic saver is started with default parameters. I checked usage with repo search (sessionPersistence only appears in the type declaration), so enabled: false and custom intervalMs values currently have no effect despite being exposed as config knobs.
Useful? React with 👍 / 👎.
- Read sessionPersistence config (enabled/intervalMs) instead of hardcoding - Add concurrent-save guard to prevent overlapping periodic saves - Return null from restoreBrowserState when all cookies are expired
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 040aa5f64a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| running.proc.on("exit", () => { | ||
| stopSessionPersistence(); | ||
| // Guard against server teardown (e.g., SIGUSR1 restart) |
There was a problem hiding this comment.
Scope timer cleanup to the exiting browser process
The new exit handler clears session persistence before verifying which process exited. In restart paths we can launch a replacement browser and start a new periodic-save timer before the old process emits exit; when that delayed old exit arrives, this unconditional cleanup stops the new timer, so periodic snapshots silently stop for the active browser. Tie cleanup to the PID check (or otherwise associate timer ownership) so an old process cannot clear state for a new one.
Useful? React with 👍 / 👎.
| const sessionPersistenceEnabled = cfg?.sessionPersistence?.enabled !== false; | ||
| const sessionPersistenceIntervalMs = cfg?.sessionPersistence?.intervalMs ?? 60_000; |
There was a problem hiding this comment.
Validate session persistence interval before using it
The config value is passed through without bounds checks, then fed into setInterval. In Node, out-of-range delays (for example 3000000000) overflow and are coerced to 1ms, which turns cookie persistence into a near-tight loop and can create heavy CDP/disk load. Clamp/sanitize sessionPersistence.intervalMs to a safe positive range when resolving config.
Useful? React with 👍 / 👎.
| try { | ||
| const wsUrl = await getChromeWebSocketUrl(profile.cdpUrl); | ||
| if (wsUrl) { | ||
| const restored = await restoreBrowserState(wsUrl, userDataDir); | ||
| if (restored) { |
There was a problem hiding this comment.
Honor disabled session persistence in startup restore path
Cookie restore now runs unconditionally at browser launch, so browser.sessionPersistence.enabled: false still loads persisted login state if the state file exists. That makes the opt-out ineffective for users who disable persistence (for privacy/debugging) and can produce unexpected re-auth behavior. Gate this launch restore behind the resolved session-persistence flag, consistent with the periodic saver toggle.
Useful? React with 👍 / 👎.
|
Codex triage: still potentially useful, but this needs a rebase/narrow fix before review. The feature maps directly to the Google Meet/login-session problem: managed Please keep the PR focused on managed-profile persistence and address the existing review blockers before it moves forward:
This should complement #62288 rather than replace it: #45371 is managed profile persistence; #62288 is existing-session attach/fallback diagnostics. |
steipete
left a comment
There was a problem hiding this comment.
Codex deep review: still not mergeable in this shape.
The underlying problem is real and useful for Google Meet/login handoff: managed openclaw profile login state should survive gateway restarts. But this PR needs to be rebased and redesigned before it can land.
Blocking issues:
-
The diff targets the old core browser layout (
src/browser/*,src/config/types.browser.ts). Current main owns this in the bundled browser plugin (extensions/browser/src/browser/*plus generated config/schema docs). A merge/rebase needs to move the implementation to the plugin-owned surface and keep generated config metadata/docs aligned. -
The persistence format writes all browser cookies into a new plain JSON file inside the user data dir. That duplicates sensitive session cookies outside Chrome's normal cookie store protections. If we do this, the security model needs to be explicit: local-only managed profile, owner-readable file permissions, docs/security note, and no accidental exposure through profile reset/export/debug artifacts. Better still, first try graceful Chrome shutdown / profile flush or a narrower state strategy before creating an extra cookie vault.
-
Network.getAllCookies/Network.setCookiesis not obviously complete for modern Chrome state. Partitioned cookies/CHIPS, SameSite edge cases, source metadata, storage state beyond cookies, and Google account refresh state need live proof. The PR currently has mostly file-shape tests and one manual happy-path claim. Please add a focused integration/live smoke or at least a browser-control seam test that proves restore is actually called with representative CDP cookie fields. -
Runtime lifecycle has to be owned by the browser plugin lifecycle. Periodic timers must stop on profile reconcile, gateway reload, browser runtime shutdown, and failed launch paths. Current code was added to the old availability module and does not cover current plugin lifecycle.
Suggested path: rebase onto current main, keep this as managed-profile persistence only, add docs/security notes, and prove with a small live/manual checklist for Google/GitHub login persistence across gateway restart. This remains complementary to #62288; it does not solve existing-session attach diagnostics.
|
Codex deep review: closing this PR rather than keeping it in the merge queue. The underlying problem is understandable: managed browser profiles can lose login state if Chrome is repeatedly terminated before it flushes profile data. But this patch is no longer a good implementation for current
Best path if we revisit this: write a fresh current-path patch in Thanks for the investigation here; the root symptom is useful, but this implementation should not land. |
Problem
The managed browser (
openclawprofile) loses all login sessions on gateway restart. Chrome's in-memory cookie store isn't flushed to disk before the process gets SIGKILL'd after the 2.5s timeout.Evidence: The Cookies SQLite DB has only tracking cookies — zero Google/GitHub/actual session cookies.
exit_type: "Crashed"in Preferences confirms Chrome never shuts down cleanly.Solution
Periodic cookie snapshots via CDP, sidestepping Chrome's shutdown behavior entirely.
How it works
Network.getAllCookies()→ save to<userDataDir>/openclaw-saved-state.jsonNetwork.setCookies()Key design decisions
Files changed
src/browser/session-persistence.tssrc/browser/session-persistence.test.tssrc/browser/chrome.tssrc/browser/server-context.availability.tssrc/browser/server-context.types.tsstopPeriodicSaveto ProfileRuntimeStatesrc/config/types.browser.tsbrowser.sessionPersistenceconfig typeConfig
{ "browser": { "sessionPersistence": { "enabled": true, "intervalMs": 60000 } } }Both default to the values shown. Most users won't need to touch this.
What this covers
Testing