2 Commits
Author SHA1 Message Date
tbenandClaude Opus 5.5 211599e0fc Release v0.5.2
Release image / image (release) Waiting to run
CI / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:18:36 +01:00
tbenandClaude Opus 5.5 ac1048c8f3 Install Cookie-Editor in every computer's browser
A managed Chrome policy, written as root into each running computer's
container on provision and every sync-computers run, installs the
default extensions (normal_installed: can be turned off, not removed).
Existing computers get it without being recreated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:18:36 +01:00
10 changed files with 182 additions and 4 deletions
+1 -1
View File
@@ -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. - 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`), plus the browser profile; the rest of home lives in the container. - 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`). - **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). 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. - 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`). - 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). - **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).
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cobble", "name": "cobble",
"version": "0.5.1", "version": "0.5.2",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
+8 -1
View File
@@ -3,6 +3,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import migrateComputerLayout from "@/src/functions/backend/db/computers/migrate-computer-layout"; import migrateComputerLayout from "@/src/functions/backend/db/computers/migrate-computer-layout";
import refreshComputerContainer from "@/src/functions/backend/db/computers/refresh-computer-container"; 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< type Row = Pick<
BUN_SQLITE_COBBLE_CONTAINERS, BUN_SQLITE_COBBLE_CONTAINERS,
@@ -14,7 +15,9 @@ type Row = Pick<
* (`refreshComputerContainer`), telling owners about changes. Containers * (`refreshComputerContainer`), telling owners about changes. Containers
* still being set up, or that failed, are left alone. A container that * 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 * 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() { export default async function syncComputers() {
const rows = const rows =
@@ -37,5 +40,9 @@ export default async function syncComputers() {
}); });
if (refreshed?.changed) changed++; if (refreshed?.changed) changed++;
if (refreshed?.status == "running" && container.docker_id) {
await syncComputerBrowserPolicy({ computer_id, docker_id: container.docker_id });
}
} }
} }
+6
View File
@@ -119,6 +119,12 @@ export const AppData = {
*/ */
ComputerBrowserDataDir: "computer-browsers", ComputerBrowserDataDir: "computer-browsers",
ComputerBrowserProfileDir: "/home/gem/.cobble-browser", 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 * 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 * layout, so an older computer's whole home is told apart and moved
+9
View File
@@ -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;
@@ -7,6 +7,7 @@ import { logError } from "@/src/functions/backend/logger";
import prepareComputerBrowserDir from "./prepare-computer-browser-dir"; import prepareComputerBrowserDir from "./prepare-computer-browser-dir";
import prepareComputerDataDir from "./prepare-computer-data-dir"; import prepareComputerDataDir from "./prepare-computer-data-dir";
import pushComputerUpdate from "./push-computer-update"; import pushComputerUpdate from "./push-computer-update";
import syncComputerBrowserPolicy from "./sync-computer-browser-policy";
type Params = { type Params = {
computer_id: number; computer_id: number;
@@ -22,7 +23,8 @@ type Params = {
* (`cpus`, `memory_mb`, else the `AppData` defaults), then records it (`containers`: * (`cpus`, `memory_mb`, else the `AppData` defaults), then records it (`containers`:
* docker id, state, and its IP address on the network). A failure is * 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 * 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 * container mounts only the workspace and the browser profile (`syncComputers` recreates one that
* doesn't). Never throws. * doesn't). Never throws.
*/ */
@@ -83,6 +85,8 @@ export default async function provisionComputer({ computer_id, fresh }: Params)
row.id, row.id,
], ],
}); });
await syncComputerBrowserPolicy({ computer_id, docker_id: created.docker_id });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
@@ -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;
}
}
@@ -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 };
}
+24
View File
@@ -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: {} });
});
});
+28
View File
@@ -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`;
}