diff --git a/AGENTS.md b/AGENTS.md index 0f7ed74..dca29f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/` (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/` (with `bots/`, `workspaces/`, marker `AppData.ComputerWorkspaceMarker`), plus the browser profile; the rest of home lives in the container. +- **Browser profile:** `DATA_DIR/computer-browsers/` 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//users/` (cwd, `HOME`), `bots/` and `workspaces/` (`computerRunDirs`, `withComputerDirs`, `resolveComputerPath`; `sudo` refused). diff --git a/src/data/app-data.ts b/src/data/app-data.ts index ef754ea..b30bfba 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -98,9 +98,10 @@ export const AppData = { /** * Only a computer's workspace (`ComputerWorkspaceDir` in its * container) is kept, at `DATA_DIR//`, 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/` there, a user's work in `bots//users/`, and a workspace's bots share `workspaces/` * (`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//`, 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, /** diff --git a/src/data/computer-browser-preferences.ts b/src/data/computer-browser-preferences.ts new file mode 100644 index 0000000..9ed3811 --- /dev/null +++ b/src/data/computer-browser-preferences.ts @@ -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" }, +}; diff --git a/src/functions/backend/db/bot-hooks/sync-computer-notify-watch.ts b/src/functions/backend/db/bot-hooks/sync-computer-notify-watch.ts index ffd07da..491e45f 100644 --- a/src/functions/backend/db/bot-hooks/sync-computer-notify-watch.ts +++ b/src/functions/backend/db/bot-hooks/sync-computer-notify-watch.ts @@ -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, diff --git a/src/functions/backend/db/computers/grab-computer-browser-dir.ts b/src/functions/backend/db/computers/grab-computer-browser-dir.ts new file mode 100644 index 0000000..078a4b0 --- /dev/null +++ b/src/functions/backend/db/computers/grab-computer-browser-dir.ts @@ -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//`. + */ +export default function grabComputerBrowserDir({ computer_id }: Params) { + const { DATA_DIR } = grabDirNames(); + + return path.join(DATA_DIR, AppData.ComputerBrowserDataDir, String(computer_id)); +} diff --git a/src/functions/backend/db/computers/is-computer-busy.ts b/src/functions/backend/db/computers/is-computer-busy.ts new file mode 100644 index 0000000..7d6f142 --- /dev/null +++ b/src/functions/backend/db/computers/is-computer-busy.ts @@ -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], + ); +} diff --git a/src/functions/backend/db/computers/migrate-computer-layout.ts b/src/functions/backend/db/computers/migrate-computer-layout.ts index e886dd9..b52d66a 100644 --- a/src/functions/backend/db/computers/migrate-computer-layout.ts +++ b/src/functions/backend/db/computers/migrate-computer-layout.ts @@ -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 }, }); diff --git a/src/functions/backend/db/computers/prepare-computer-browser-dir.test.ts b/src/functions/backend/db/computers/prepare-computer-browser-dir.test.ts new file mode 100644 index 0000000..5d2e324 --- /dev/null +++ b/src/functions/backend/db/computers/prepare-computer-browser-dir.test.ts @@ -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"); + }); +}); diff --git a/src/functions/backend/db/computers/prepare-computer-browser-dir.ts b/src/functions/backend/db/computers/prepare-computer-browser-dir.ts new file mode 100644 index 0000000..06cd490 --- /dev/null +++ b/src/functions/backend/db/computers/prepare-computer-browser-dir.ts @@ -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 `-` 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(() => {}); +} diff --git a/src/functions/backend/db/computers/provision-computer.ts b/src/functions/backend/db/computers/provision-computer.ts index f8febbe..b3b59b7 100644 --- a/src/functions/backend/db/computers/provision-computer.ts +++ b/src/functions/backend/db/computers/provision-computer.ts @@ -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(), diff --git a/src/functions/backend/docker/create-computer-container.ts b/src/functions/backend/docker/create-computer-container.ts index 472e8c8..db0bbd8 100644 --- a/src/functions/backend/docker/create-computer-container.ts +++ b/src/functions/backend/docker/create-computer-container.ts @@ -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, }; } diff --git a/src/pages/admin/help/(data)/help-topics.ts b/src/pages/admin/help/(data)/help-topics.ts index 5e6423b..2a94726 100644 --- a/src/pages/admin/help/(data)/help-topics.ts +++ b/src/pages/admin/help/(data)/help-topics.ts @@ -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.**