Keep computers' browser sessions across restarts and recreates

Each computer's Chrome profile now lives on the data volume
(DATA_DIR/computer-browsers/<id>, mounted at ~/.cobble-browser) and
Chrome is pointed at it with --user-data-dir. The sandbox image resets
its own profile's preferences to session-only cookies on every boot,
which signed every site out whenever Chrome exited. New profiles are
seeded with the image's preferences minus that rule, stale Singleton
links are removed before each provision, and existing computers are
recreated once (skipped while a job runs on them).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 15:26:18 +01:00
co-authored by Claude Opus 5.5
parent 380d3ebb2f
commit b9c5311fde
12 changed files with 276 additions and 26 deletions
+2 -1
View File
@@ -128,7 +128,8 @@ Server push only. `pushBotNotice` (`server:bot-message`, `server:bot-updated`),
### Computers
- An AIO sandbox per docker container on `AppData.DockerNetworkName`, managed over the docker socket (`src/functions/backend/docker/`). `containers` records every container except the app's own. `createComputer` + background `provisionComputer` (status `creating` until the health check passes). Resource changes recreate the container.
- Only `/home/gem/workspace` is mounted, from `DATA_DIR/computers/<id>` (with `bots/`, `workspaces/`, marker `AppData.ComputerWorkspaceMarker`); the rest of home lives in the container. Deleting a computer keeps its directory. Outside docker (dev) containers are reached by IP (`pickReachableAddress`).
- Only `/home/gem/workspace` is mounted, from `DATA_DIR/computers/<id>` (with `bots/`, `workspaces/`, marker `AppData.ComputerWorkspaceMarker`), plus the browser profile; the rest of home lives in the container.
- **Browser profile:** `DATA_DIR/computer-browsers/<id>` mounted at `~/.cobble-browser`, which Chrome is pointed at (`BROWSER_EXTRA_ARGS=--user-data-dir=... --password-store=basic`), so logins, extensions and site permissions survive restarts and recreates. Never use the image's `~/.config/browser`: every boot overwrites its `Default/Preferences` with session-only cookies. Seed prefs (`computer-browser-preferences.ts`) are written only when missing; stale `Singleton*` links are removed before each provision. Bumping `AppData.ComputerWorkspaceLayout` recreates every computer once (`migrateComputerLayout`, skipped while a job runs on it). Deleting a computer keeps its directory. Outside docker (dev) containers are reached by IP (`pickReachableAddress`).
- One main computer (`ensureMainComputer`), whose coordinator grant is locked (`isLockedComputerAccess`). With `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. Only super admins create/delete/retry computers.
- A bot's effective computers (`grab-bot-computers`): the workspace's in a workspace; otherwise direct grants plus its workspaces' (coordinator: all); `bots.computer_id` first. Only a `running` (healthy) computer is reached (`grabComputerMcpUrl` `ready`, `computerNotReadyMessage`).
- **Folders (enforced in tools, not a Linux user):** a run works only in `bots/<bot>/users/<user>` (cwd, `HOME`), `bots/<bot>` and `workspaces/<workspace>` (`computerRunDirs`, `withComputerDirs`, `resolveComputerPath`; `sudo` refused).
+17 -5
View File
@@ -98,9 +98,10 @@ export const AppData = {
/**
* Only a computer's workspace (`ComputerWorkspaceDir` in its
* container) is kept, at `DATA_DIR/<ComputerDataDir>/<id>`, owned by
* the sandbox's user. It stays when the computer is deleted. The rest
* of its home (`ComputerHomeDir`: dotfiles, browser profile, the env
* files) lives in the container and goes with it. Every bot works in
* the sandbox's user, plus its browser profile
* (`ComputerBrowserDataDir`). Both stay when the computer is deleted.
* The rest of its home (`ComputerHomeDir`: dotfiles, the env files)
* lives in the container and goes with it. Every bot works in
* `bots/<bot id>` there, a user's work in `bots/<bot id>/users/<user
* id>`, and a workspace's bots share `workspaces/<workspace id>`
* (`computerRunDirs`).
@@ -108,6 +109,16 @@ export const AppData = {
ComputerDataDir: "computers",
ComputerHomeDir: "/home/gem",
ComputerWorkspaceDir: "/home/gem/workspace",
/**
* A computer's browser profile (cookies, logins, site permissions) is
* kept at `DATA_DIR/<ComputerBrowserDataDir>/<id>`, outside its
* workspace, and mounted at `ComputerBrowserProfileDir`, which Chrome
* is pointed at with `--user-data-dir`. The image's own profile
* (`~/.config/browser`) can't be kept: every boot resets its
* preferences to session-only cookies.
*/
ComputerBrowserDataDir: "computer-browsers",
ComputerBrowserProfileDir: "/home/gem/.cobble-browser",
/**
* Written at the root of a computer's workspace once it uses that
* layout, so an older computer's whole home is told apart and moved
@@ -121,9 +132,10 @@ export const AppData = {
ComputerOldHomeSuffix: "-home-",
/**
* Recorded in a container's `metadata.layout` once it mounts only the
* workspace.
* workspace and the browser profile. A container with another value
* is recreated (`migrateComputerLayout`).
*/
ComputerWorkspaceLayout: "workspace",
ComputerWorkspaceLayout: "workspace-browser",
ComputerUserUid: 1000,
ComputerUserGid: 1000,
/**
+51
View File
@@ -0,0 +1,51 @@
/**
* Chrome preferences a computer's browser profile starts with, written
* only when the profile has none (`prepareComputerBrowserDir`) so whatever
* Chrome saves later (site permissions, settings) is kept. The sandbox
* image's own preferences (`/opt/gem/preferences.json`), minus its
* session-only cookie rule (`cookies: 4`), which signs every site out
* whenever Chrome exits.
*/
export const ComputerBrowserPreferences = {
credentials_enable_service: false,
credentials_enable_autosignin: false,
autofill: {
credit_card_enabled: false,
profile_enabled: false,
},
password_manager_enabled: false,
browser: {
custom_chrome_frame: false,
show_home_button: true,
window_placement: { maximized: true },
},
bookmark_bar: { show_on_all_tabs: false },
sync_promo: { show_on_first_run_allowed: false },
profile: {
avatar_index: 19,
default_content_setting_values: {
clipboard: 2,
geolocation: 2,
media_stream_camera: 2,
media_stream_mic: 2,
midi_sysex: 2,
payment_handler: 2,
usb_guard: 2,
},
name: "gem",
using_default_avatar: false,
using_default_name: false,
using_gaia_avatar: false,
},
signin: { allowed: false },
intl: {
selected_languages: "en-US,en",
accept_languages: "en-US,en",
},
download: {
default_directory: "/home/gem/Downloads",
prompt_for_download: false,
directory_upgrade: true,
},
savefile: { default_directory: "/home/gem/Downloads" },
};
@@ -7,6 +7,7 @@ import computerNotReadyMessage from "@/src/utils/computer-not-ready-message";
import { decrypt } from "@/src/utils/crypt";
import notifyWatchEnsureCommand from "@/src/utils/notify-watch-ensure-command";
import grabComputerMcpUrl from "../computers/grab-computer-mcp-url";
import isComputerBusy from "../computers/is-computer-busy";
import callComputerApi from "./call-computer-api";
type Params = {
@@ -95,14 +96,7 @@ export default async function syncComputerNotifyWatch({ computer_id }: Params):
body: { file: `${dir}/notify-hooks`, content: `${urls.join("\n")}\n` },
});
const busy = Boolean(
(
await BunSQLite.sql<{ id: number }>({
sql: `SELECT id FROM bots_queue WHERE computer_id = ? AND status = 'processing' LIMIT 1`,
values: [computer_id],
})
).payload?.[0],
);
const busy = await isComputerBusy({ computer_id });
const res = await callComputerApi({
base_url,
@@ -0,0 +1,17 @@
import path from "path";
import { AppData } from "@/src/data/app-data";
import grabDirNames from "@/src/utils/grab-dir-names";
type Params = {
computer_id: number | string;
};
/**
* Where a computer's browser profile is kept, as this app sees it:
* `DATA_DIR/<AppData.ComputerBrowserDataDir>/<id>`.
*/
export default function grabComputerBrowserDir({ computer_id }: Params) {
const { DATA_DIR } = grabDirNames();
return path.join(DATA_DIR, AppData.ComputerBrowserDataDir, String(computer_id));
}
@@ -0,0 +1,20 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
computer_id: number | string;
};
/**
* Whether a job is working on the computer right now, so its browser or
* container must not be pulled from under it.
*/
export default async function isComputerBusy({ computer_id }: Params) {
return Boolean(
(
await BunSQLite.sql<{ id: number }>({
sql: `SELECT id FROM bots_queue WHERE computer_id = ? AND status = 'processing' LIMIT 1`,
values: [computer_id],
})
).payload?.[0],
);
}
@@ -1,6 +1,7 @@
import parseJobJson from "@/src/bots-queue/parse-job-json";
import { AppData } from "@/src/data/app-data";
import logEvent from "@/src/functions/backend/logger";
import isComputerBusy from "./is-computer-busy";
import provisionComputer from "./provision-computer";
type Params = {
@@ -12,23 +13,25 @@ type Params = {
};
/**
* Recreates a computer whose container still mounts its whole home (set up
* before only `AppData.ComputerWorkspaceDir` was mounted), so its bots work
* in the workspace alone. `provisionComputer` moves the old home aside
* (`prepareComputerDataDir`) and records the layout, so this runs once per
* computer. Returns whether it did.
* Recreates a computer whose container was set up with an older layout
* (its whole home mounted, or its browser profile left in the container),
* so it mounts only its workspace and keeps its browser profile.
* `provisionComputer` moves an old home aside (`prepareComputerDataDir`)
* and records the layout, so this runs once per computer. Waits for a
* later sync while a job works on it. Returns whether it did.
*/
export default async function migrateComputerLayout({ computer_id, metadata }: Params) {
const layout = parseJobJson({ text: metadata || "" })?.layout;
if (layout == AppData.ComputerWorkspaceLayout) return false;
if (await isComputerBusy({ computer_id })) return false;
await provisionComputer({ computer_id });
await logEvent({
title: `Computer #${computer_id} now mounts only its workspace`,
title: `Computer #${computer_id} now keeps its browser profile`,
category: "system",
plain_text: `Recreated computer #${computer_id} so only ${AppData.ComputerWorkspaceDir} is kept; its old home was moved aside and kept on disk.`,
plain_text: `Recreated computer #${computer_id} so only ${AppData.ComputerWorkspaceDir} and its browser profile (${AppData.ComputerBrowserProfileDir}) are kept; an old home, if any, was moved aside and kept on disk.`,
metadata: { computer_id },
});
@@ -0,0 +1,67 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import fs from "fs/promises";
import os from "os";
import path from "path";
import { AppData } from "@/src/data/app-data";
import prepareComputerBrowserDir from "./prepare-computer-browser-dir";
let data_dir = "";
const previous_data_dir = process.env.DATA_DIR;
beforeAll(async () => {
data_dir = await fs.mkdtemp(path.join(os.tmpdir(), "browser-dir-"));
process.env.DATA_DIR = data_dir;
});
afterAll(async () => {
process.env.DATA_DIR = previous_data_dir;
if (previous_data_dir === undefined) delete process.env.DATA_DIR;
await fs.rm(data_dir, { recursive: true, force: true });
});
const readPreferences = async ({ dir }: { dir: string }) =>
JSON.parse(await fs.readFile(path.join(dir, "Default", "Preferences"), "utf8"));
describe("prepareComputerBrowserDir", () => {
test("seeds preferences that keep cookies", async () => {
const dir = await prepareComputerBrowserDir({ computer_id: 1 });
expect(dir).toBe(path.join(data_dir, AppData.ComputerBrowserDataDir, "1"));
const preferences = await readPreferences({ dir });
expect(preferences.profile.default_content_setting_values.cookies).toBeUndefined();
expect(preferences.password_manager_enabled).toBe(false);
});
test("keeps what Chrome saved and drops the old process's links", async () => {
const dir = await prepareComputerBrowserDir({ computer_id: 2 });
const saved = { profile: { content_settings: { exceptions: { notifications: { a: 1 } } } } };
await fs.writeFile(path.join(dir, "Default", "Preferences"), JSON.stringify(saved));
await fs.writeFile(path.join(dir, "Default", "Cookies"), "cookies");
await fs.symlink("cobble-computer-2-4409", path.join(dir, "SingletonLock"));
await fs.symlink("/tmp/gone/SingletonSocket", path.join(dir, "SingletonSocket"));
await prepareComputerBrowserDir({ computer_id: 2 });
expect(await readPreferences({ dir })).toEqual(saved);
expect(await fs.readFile(path.join(dir, "Default", "Cookies"), "utf8")).toBe("cookies");
expect(await fs.lstat(path.join(dir, "SingletonLock")).catch(() => null)).toBeNull();
expect(await fs.lstat(path.join(dir, "SingletonSocket")).catch(() => null)).toBeNull();
});
test("moves an old profile aside for a fresh computer", async () => {
const dir = await prepareComputerBrowserDir({ computer_id: 3 });
await fs.writeFile(path.join(dir, "Default", "Cookies"), "old");
await prepareComputerBrowserDir({ computer_id: 3, fresh: true });
expect(await fs.stat(path.join(dir, "Default", "Cookies")).catch(() => null)).toBeNull();
const kept = (await fs.readdir(path.dirname(dir))).filter((name) => name.startsWith("3-"));
expect(kept.length).toBe(1);
expect(await fs.readFile(path.join(path.dirname(dir), kept[0]!, "Default", "Cookies"), "utf8")).toBe("old");
});
});
@@ -0,0 +1,62 @@
import fs from "fs/promises";
import path from "path";
import { AppData } from "@/src/data/app-data";
import { ComputerBrowserPreferences } from "@/src/data/computer-browser-preferences";
import grabComputerBrowserDir from "./grab-computer-browser-dir";
type Params = {
computer_id: number | string;
/**
* A new computer: a profile already at its path (left by a deleted
* computer with the same id) is moved aside to `<id>-<timestamp>` and
* kept, so it starts signed out.
*/
fresh?: boolean;
};
/**
* Chrome's links saying which process holds a profile. One left by the
* container being replaced would point at a process that's gone.
*/
const SingletonFiles = ["SingletonLock", "SingletonSocket", "SingletonCookie"];
/**
* Creates a computer's browser profile directory (mounted at
* `AppData.ComputerBrowserProfileDir`), owned by the sandbox's user, and
* returns its path. A profile without preferences gets
* `ComputerBrowserPreferences`; an existing one is left as Chrome saved it,
* only rid of the links of the Chrome that ran before.
*/
export default async function prepareComputerBrowserDir({ computer_id, fresh }: Params) {
const dir = grabComputerBrowserDir({ computer_id });
const default_dir = path.join(dir, "Default");
const preferences = path.join(default_dir, "Preferences");
if (fresh && (await fs.readdir(dir).catch(() => null))?.length) {
await fs.rename(dir, `${dir}-${Date.now()}`);
}
for (const folder of [dir, default_dir]) {
await fs.mkdir(folder, { recursive: true });
await chownToSandbox({ target: folder });
}
for (const name of SingletonFiles) {
await fs.rm(path.join(dir, name), { force: true });
}
if (!(await fs.stat(preferences).catch(() => null))) {
await fs.writeFile(preferences, JSON.stringify(ComputerBrowserPreferences));
await chownToSandbox({ target: preferences });
}
return dir;
}
/**
* Only root can hand a file to another user; a dev server running as that
* user already owns it.
*/
async function chownToSandbox({ target }: { target: string }) {
await fs.chown(target, AppData.ComputerUserUid, AppData.ComputerUserGid).catch(() => {});
}
@@ -4,6 +4,7 @@ import parseJobJson from "@/src/bots-queue/parse-job-json";
import { AppData } from "@/src/data/app-data";
import createComputerContainer from "@/src/functions/backend/docker/create-computer-container";
import { logError } from "@/src/functions/backend/logger";
import prepareComputerBrowserDir from "./prepare-computer-browser-dir";
import prepareComputerDataDir from "./prepare-computer-data-dir";
import pushComputerUpdate from "./push-computer-update";
@@ -16,13 +17,13 @@ type Params = {
};
/**
* Sets up a computer's container: prepares its workspace directory, pulls the
* Sets up a computer's container: prepares its workspace and browser profile directories, pulls the
* sandbox image and starts the container with the computer's resources
* (`cpus`, `memory_mb`, else the `AppData` defaults), then records it (`containers`:
* docker id, state, and its IP address on the network). A failure is
* recorded as `failed` with its reason, which the computer lists show. The
* owner's tabs hear about it either way. The metadata's `layout` says the
* container mounts only the workspace (`syncComputers` recreates one that
* container mounts only the workspace and the browser profile (`syncComputers` recreates one that
* doesn't). Never throws.
*/
export default async function provisionComputer({ computer_id, fresh }: Params) {
@@ -46,10 +47,12 @@ export default async function provisionComputer({ computer_id, fresh }: Params)
});
const data_dir = await prepareComputerDataDir({ computer_id, fresh });
const browser_dir = await prepareComputerBrowserDir({ computer_id, fresh });
const created = await createComputerContainer({
computer_id,
name: row.name,
data_dir,
browser_dir,
cpus: Number(row.cpus) || AppData.ComputerDefaultCpus,
memory_mb: Number(row.memory_mb) || AppData.ComputerDefaultMemoryMb,
});
@@ -71,6 +74,8 @@ export default async function provisionComputer({ computer_id, fresh }: Params)
...kept,
data_dir,
host_dir: created.host_dir,
browser_dir,
browser_host_dir: created.browser_host_dir,
layout: AppData.ComputerWorkspaceLayout,
}),
Date.now(),
@@ -22,6 +22,10 @@ type Params = {
* The computer's workspace directory as this app sees it.
*/
data_dir: string;
/**
* The computer's browser profile directory as this app sees it.
*/
browser_dir: string;
/**
* CPU cores the container may use.
*/
@@ -36,7 +40,10 @@ type Params = {
* Creates and starts (the API's `docker run`) an AIO sandbox container
* for a computer (`AppData.ComputerImage`), attached to the computers'
* network (created first when missing), with its home directory
* bind-mounted from `data_dir` (only its workspace, `AppData.ComputerWorkspaceDir`: the rest of its home lives and dies with the container), limited to `cpus` and `memory_mb`, with a
* bind-mounted from `data_dir` (only its workspace, `AppData.ComputerWorkspaceDir`: the rest of its home lives and dies with the container), its browser profile from
* `browser_dir` (at `AppData.ComputerBrowserProfileDir`, which Chrome is
* pointed at, so logins outlive restarts and recreates; the basic password
* store keeps the cookies' key the same in every container), limited to `cpus` and `memory_mb`, with a
* health check (`AppData.ComputerHealthcheck`), and restarted with
* docker. A container left by an earlier try is stopped and replaced.
* Resolves once the health check passes, with its IP address on the network, which a dev
@@ -47,11 +54,13 @@ export default async function createComputerContainer({
computer_id,
name,
data_dir,
browser_dir,
cpus,
memory_mb,
}: Params) {
const network = await ensureDockerNetwork();
const host_dir = await resolveHostPath({ local_path: data_dir });
const browser_host_dir = await resolveHostPath({ local_path: browser_dir });
await pullDockerImage({ image: AppData.ComputerImage });
await removeDockerContainer({ container: name });
@@ -66,6 +75,9 @@ export default async function createComputerContainer({
Labels: {
[`${AppData.DockerNetworkName}.computer_id`]: String(computer_id),
},
Env: [
`BROWSER_EXTRA_ARGS=--user-data-dir=${AppData.ComputerBrowserProfileDir} --password-store=basic`,
],
ExposedPorts: { [`${AppData.ComputerPort}/tcp`]: {} },
Healthcheck: {
Test: [
@@ -78,7 +90,10 @@ export default async function createComputerContainer({
Retries: AppData.ComputerHealthcheck.Retries,
},
HostConfig: {
Binds: [`${host_dir}:${AppData.ComputerWorkspaceDir}`],
Binds: [
`${host_dir}:${AppData.ComputerWorkspaceDir}`,
`${browser_host_dir}:${AppData.ComputerBrowserProfileDir}`,
],
NetworkMode: network,
RestartPolicy: { Name: "unless-stopped" },
SecurityOpt: ["seccomp=unconfined"],
@@ -112,6 +127,7 @@ export default async function createComputerContainer({
network,
status: state.status,
host_dir,
browser_host_dir,
ip_address: state.ip_address,
};
}
+3 -1
View File
@@ -57,7 +57,7 @@ The way around it is to sign in on your own browser, where Google trusts you, an
### On the bot's computer
4. Open the computer: in the bot's chat, click the computer icon in the header and open the computer, or click **Take over** if the bot asked you to. Switch to **Desktop**. This is the only view that shows the browser's toolbar, where extensions live.
5. The first time only: in the computer's Chrome, open the [Cookie-Editor page](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm), click **Add to Chrome**, then **Add extension**. Pin it from the puzzle piece so it's always one click away. It stays installed: the computer keeps its browser between restarts.
5. The first time only: in the computer's Chrome, open the [Cookie-Editor page](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm), click **Add to Chrome**, then **Add extension**. Pin it from the puzzle piece so it's always one click away. It stays installed: the computer keeps its browser profile, even when it restarts or is rebuilt.
6. In the computer's Chrome, go to the same site (for Google, [myaccount.google.com](https://myaccount.google.com)). It's fine that it shows the sign-in page.
7. Put the cookies on the computer's clipboard. Desktop has a small handle on the left edge: open it, click the **Clipboard** icon, and paste into the box (Ctrl+V, or Cmd+V on a Mac). What's in that box is the computer's clipboard.
8. Click **Cookie-Editor** in the computer's toolbar, then **Import**. Click into the box, paste (Ctrl+V), and click **Import**. Reload the page (F5): you're signed in.
@@ -65,6 +65,8 @@ The way around it is to sign in on your own browser, where Google trusts you, an
Then tell the bot you're signed in, or click **Hand back** if it asked you to take over, and it carries on.
The computer keeps the session, like everything else in its browser (extensions, site permissions): restarting the computer or changing its resources doesn't sign it out. Only the site itself ending the session does.
### If it doesn't work
- **The paste in step 7 or 8 is empty.** Use the computer's code editor instead: switch to **Code**, create a file such as \`workspace/cookies.json\`, and paste into it (that paste always works). In the computer's Chrome, open \`file:///home/gem/workspace/cookies.json\`, press Ctrl+A then Ctrl+C, and import as in step 8. **Delete the file afterwards.**