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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user