diff --git a/AGENTS.md b/AGENTS.md index 222da8c..01b9d46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,7 +129,7 @@ Server push only. `pushBotNotice` (`server:bot-message`, `server:bot-updated`), - 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`), 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`). +- **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). Default extensions (`ComputerBrowserExtensions`, `normal_installed`: can be turned off, not removed) come from a managed policy written as root into the running container (`syncComputerBrowserPolicy`, on provision and every `sync-computers` run), not a mount, so existing computers get new ones without a recreate; Chrome installs them live. 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/cron/sync-computers/index.ts b/src/cron/sync-computers/index.ts index 4804d87..c5cebd5 100644 --- a/src/cron/sync-computers/index.ts +++ b/src/cron/sync-computers/index.ts @@ -3,6 +3,7 @@ import BunSQLite from "@moduletrace/bun-sqlite"; import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db"; import migrateComputerLayout from "@/src/functions/backend/db/computers/migrate-computer-layout"; import refreshComputerContainer from "@/src/functions/backend/db/computers/refresh-computer-container"; +import syncComputerBrowserPolicy from "@/src/functions/backend/db/computers/sync-computer-browser-policy"; type Row = Pick< BUN_SQLITE_COBBLE_CONTAINERS, @@ -14,7 +15,9 @@ type Row = Pick< * (`refreshComputerContainer`), telling owners about changes. Containers * still being set up, or that failed, are left alone. A container that * still mounts its computer's whole home is recreated with only the - * workspace (`migrateComputerLayout`, once per computer). + * workspace (`migrateComputerLayout`, once per computer). A running one + * gets the browser policy it lacks (`syncComputerBrowserPolicy`), so + * computers made before an extension was added get it too. */ export default async function syncComputers() { const rows = @@ -37,5 +40,9 @@ export default async function syncComputers() { }); if (refreshed?.changed) changed++; + + if (refreshed?.status == "running" && container.docker_id) { + await syncComputerBrowserPolicy({ computer_id, docker_id: container.docker_id }); + } } } diff --git a/src/data/app-data.ts b/src/data/app-data.ts index 3f2c6c4..f2097c4 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -119,6 +119,12 @@ export const AppData = { */ ComputerBrowserDataDir: "computer-browsers", ComputerBrowserProfileDir: "/home/gem/.cobble-browser", + /** + * The managed policy file a computer's Chrome reads (the image's + * Chrome looks in `/etc/chromium/policies/managed`), written into the + * container by `syncComputerBrowserPolicy`. + */ + ComputerBrowserPolicyFile: "/etc/chromium/policies/managed/cobble.json", /** * 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 diff --git a/src/data/computer-browser-extensions.ts b/src/data/computer-browser-extensions.ts new file mode 100644 index 0000000..5bb4a15 --- /dev/null +++ b/src/data/computer-browser-extensions.ts @@ -0,0 +1,9 @@ +/** + * Chrome Web Store extensions every computer's browser gets, installed by + * a managed policy (`computerBrowserPolicy`, written by + * `syncComputerBrowserPolicy`). They're installed as `normal_installed`: + * a user can turn one off in that browser, but not remove it. + */ +export const ComputerBrowserExtensions = [ + { id: "hlkenndednhfkekhgcdicdfddnkalmdm", name: "Cookie-Editor" }, +] as const; diff --git a/src/functions/backend/db/computers/provision-computer.ts b/src/functions/backend/db/computers/provision-computer.ts index b3b59b7..232465f 100644 --- a/src/functions/backend/db/computers/provision-computer.ts +++ b/src/functions/backend/db/computers/provision-computer.ts @@ -7,6 +7,7 @@ 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"; +import syncComputerBrowserPolicy from "./sync-computer-browser-policy"; type Params = { computer_id: number; @@ -22,7 +23,8 @@ type Params = { * (`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 + * owner's tabs hear about it either way. Its browser gets its policy + * (`syncComputerBrowserPolicy`). The metadata's `layout` says the * container mounts only the workspace and the browser profile (`syncComputers` recreates one that * doesn't). Never throws. */ @@ -83,6 +85,8 @@ export default async function provisionComputer({ computer_id, fresh }: Params) row.id, ], }); + + await syncComputerBrowserPolicy({ computer_id, docker_id: created.docker_id }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/functions/backend/db/computers/sync-computer-browser-policy.ts b/src/functions/backend/db/computers/sync-computer-browser-policy.ts new file mode 100644 index 0000000..41d98ea --- /dev/null +++ b/src/functions/backend/db/computers/sync-computer-browser-policy.ts @@ -0,0 +1,45 @@ +import { AppData } from "@/src/data/app-data"; +import execDockerContainer from "@/src/functions/backend/docker/exec-docker-container"; +import { logError } from "@/src/functions/backend/logger"; +import computerBrowserPolicy from "@/src/utils/computer-browser-policy"; + +type Params = { + computer_id: number | string; + /** + * The computer's running container. + */ + docker_id: string; +}; + +/** + * Writes the browser policy (`computerBrowserPolicy`: the extensions every + * computer gets) into a running computer's container at + * `AppData.ComputerBrowserPolicyFile`, as root, only when it differs. + * Chrome watches that folder, so a running browser installs them without + * a restart. The file lives in the container, not on a mount, so a running + * computer gets it without being recreated; `provisionComputer` and the + * `sync-computers` cron write it again. Never throws. + */ +export default async function syncComputerBrowserPolicy({ computer_id, docker_id }: Params) { + const file = AppData.ComputerBrowserPolicyFile; + + try { + const { exit_code } = await execDockerContainer({ + container: docker_id, + cmd: [ + "sh", + "-c", + `[ "$(cat "$POLICY_FILE" 2>/dev/null)" = "$(printf '%s' "$POLICY")" ] && exit 0 + mkdir -p "$(dirname "$POLICY_FILE")" && printf '%s' "$POLICY" > "$POLICY_FILE.tmp" && chmod 644 "$POLICY_FILE.tmp" && mv "$POLICY_FILE.tmp" "$POLICY_FILE"`, + ], + env: [`POLICY_FILE=${file}`, `POLICY=${computerBrowserPolicy()}`], + }); + + if (exit_code !== 0) throw new Error(`Writing ${file} exited with ${exit_code}.`); + + return true; + } catch (error) { + await logError({ title: `Computer #${computer_id} couldn't get its browser policy`, error }).catch(() => {}); + return false; + } +} diff --git a/src/functions/backend/docker/exec-docker-container.ts b/src/functions/backend/docker/exec-docker-container.ts new file mode 100644 index 0000000..ebe4b15 --- /dev/null +++ b/src/functions/backend/docker/exec-docker-container.ts @@ -0,0 +1,55 @@ +import dockerRequest from "./docker-request"; + +type Params = { + /** + * The container's id or name. + */ + container: string; + cmd: string[]; + /** + * Who runs it, `root` by default. + */ + user?: string; + env?: string[]; + timeout_ms?: number; +}; + +/** + * Runs a command in a running container (the API's `docker exec`) and + * waits for it. Resolves with its exit code, or throws when docker + * couldn't run it. + */ +export default async function execDockerContainer({ + container, + cmd, + user = "root", + env, + timeout_ms = 30 * 1000, +}: Params) { + const created = await dockerRequest<{ Id: string }>({ + method: "POST", + path: `/containers/${container}/exec`, + body: { Cmd: cmd, User: user, Env: env, AttachStdout: true, AttachStderr: true }, + }); + + if (!created.ok || !created.data?.Id) { + throw new Error(`Couldn't run a command in the container: ${created.message}`); + } + + const started = await dockerRequest({ + method: "POST", + path: `/exec/${created.data.Id}/start`, + body: { Detach: false, Tty: false }, + timeout_ms, + }); + + if (!started.ok) { + throw new Error(`Couldn't run a command in the container: ${started.message}`); + } + + const inspected = await dockerRequest<{ ExitCode: number | null }>({ + path: `/exec/${created.data.Id}/json`, + }); + + return { exit_code: inspected.data?.ExitCode ?? null }; +} diff --git a/src/utils/computer-browser-policy.test.ts b/src/utils/computer-browser-policy.test.ts new file mode 100644 index 0000000..5b1b00a --- /dev/null +++ b/src/utils/computer-browser-policy.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { ComputerBrowserExtensions } from "@/src/data/computer-browser-extensions"; +import computerBrowserPolicy from "./computer-browser-policy"; + +describe("computerBrowserPolicy", () => { + test("installs every default extension from the Web Store, turned off but not removed", () => { + const policy = JSON.parse(computerBrowserPolicy()); + + for (const { id } of ComputerBrowserExtensions) { + expect(policy.ExtensionSettings[id]).toEqual({ + installation_mode: "normal_installed", + update_url: "https://clients2.google.com/service/update2/crx", + }); + } + }); + + test("is the same text for the same list", () => { + expect(computerBrowserPolicy()).toBe(computerBrowserPolicy()); + }); + + test("an empty list installs nothing", () => { + expect(JSON.parse(computerBrowserPolicy({ extensions: [] }))).toEqual({ ExtensionSettings: {} }); + }); +}); diff --git a/src/utils/computer-browser-policy.ts b/src/utils/computer-browser-policy.ts new file mode 100644 index 0000000..c6a4189 --- /dev/null +++ b/src/utils/computer-browser-policy.ts @@ -0,0 +1,28 @@ +import { ComputerBrowserExtensions } from "@/src/data/computer-browser-extensions"; + +/** + * Where Chrome fetches Web Store extensions from. + */ +const WebStoreUpdateUrl = "https://clients2.google.com/service/update2/crx"; + +type Params = { + extensions?: ReadonlyArray<{ id: string }>; +}; + +/** + * The managed policy file (`AppData.ComputerBrowserPolicyFile`) a + * computer's Chrome reads: each extension (`ComputerBrowserExtensions` by + * default) installed from the Web Store, which the user may turn off but + * not remove. Always the same text for the same list, so a computer that + * already has it is left alone. + */ +export default function computerBrowserPolicy({ extensions = ComputerBrowserExtensions }: Params = {}) { + const settings = Object.fromEntries( + extensions.map(({ id }) => [ + id, + { installation_mode: "normal_installed", update_url: WebStoreUpdateUrl }, + ]), + ); + + return `${JSON.stringify({ ExtensionSettings: settings }, null, 4)}\n`; +}