From 46eb7af817027c5de4202846c73c43bbb2fa67b7 Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 3 Oct 2026 15:27:17 +0800 Subject: [PATCH] feat(agent-computer): report bounded per-Bot profile bytes --- agent-computer/PROFILE_USAGE.md | 24 +++++ agent-computer/src/index.ts | 10 +- agent-computer/src/profile-usage.ts | 102 +++++++++++++++++++++ agent-computer/tests/control-http.test.ts | 23 ++++- agent-computer/tests/profile-usage.test.ts | 75 +++++++++++++++ 5 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 agent-computer/PROFILE_USAGE.md create mode 100644 agent-computer/src/profile-usage.ts create mode 100644 agent-computer/tests/profile-usage.test.ts diff --git a/agent-computer/PROFILE_USAGE.md b/agent-computer/PROFILE_USAGE.md new file mode 100644 index 000000000..3a2f9011c --- /dev/null +++ b/agent-computer/PROFILE_USAGE.md @@ -0,0 +1,24 @@ +# Per-Bot browser profile usage + +`GET /computers/profile-usage` uses the existing computer-token authentication and +`x-openbot-bot-id` identity validation. It accepts no path, query parameters, or mutation command. +It returns only `{ "profileBytes": 1234 }` or `{ "profileBytes": null }`. It does not start a +browser, create a profile/session, stop a browser, or read file contents. + +The number is observed logical bytes of regular files in that Bot's profile, not disk allocation, +a quota, or a deletion grant. A missing profile under an accessible profile root returns zero. +Symbolic links (including Chromium's singleton links) are skipped without following their targets. +Hardlinks, special files, cross-device directories, inaccessible/changing directories and exceeded +bounds return null, never partial bytes or a path-bearing error. File contents can change while +Chromium is running; this is an on-demand measurement, not an atomic filesystem snapshot. + +Traversal admits one measurement per process, at most 10,000 entries and 16 directory levels, +with a 2-second cooperative deadline checked around filesystem operations. Directory enumeration +is incremental with 32-entry buffers. The kernel can delay an individual filesystem call; the +deadline does not forcibly interrupt a blocked filesystem. The implementation uses Linux +`/proc/self/fd` anchors and `O_NOFOLLOW` for every directory opened. Platforms without that +descriptor-relative traversal return null. No user profile link is followed, including a directory +replaced with a link between inspection and opening. + +Run the unit cases on Linux with `bun test agent-computer/tests/profile-usage.test.ts`; the +unsupported-platform case also runs on macOS. No browser, user account, or model is required. diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts index 8863fc753..70db13df3 100644 --- a/agent-computer/src/index.ts +++ b/agent-computer/src/index.ts @@ -49,6 +49,7 @@ import { sensitiveRefs, } from "./secret-masking"; import { type BotSession, createSessions } from "./sessions"; +import { profileBytes } from "./profile-usage"; import { createShell } from "./shell"; import { fillSignIn, parseSignInFill } from "./sign-in"; import { startVirtualDisplay } from "./virtual-display"; @@ -262,8 +263,9 @@ const workspace = createWorkspace( // Only the running computer points its shell at it: a test that imports this module shares its // process with every later test. await startEgressFilter({ forShell: import.meta.main }); +const profilesRoot = process.env.PROFILES_DIR?.trim() || "/profiles"; const profiles = createProfiles( - process.env.PROFILES_DIR?.trim() || "/profiles", + profilesRoot, async (botId) => { if (sessions.get(botId)) sessions.renewRun(botId); await sessions.get(botId)?.viewer.releaseAll(COMPUTER_STOPPED); @@ -677,6 +679,12 @@ serve({ if (!isOpenPath(url.pathname) && !isPlainBotId(botId)) { return json({ error: "That is not a usable bot id." }, 400); } + // This read requires the existing computer token and Bot validation, but no browser/session. + if (url.pathname === "/computers/profile-usage") { + if (request.method !== "GET") return json({ error: "Method not allowed." }, 405); + if (url.search) return json({ error: "Profile usage takes no query parameters." }, 400); + return json({ profileBytes: await profileBytes(profilesRoot, botId) }); + } // The admin network policy, pushed by the server on every change; applied without a restart. if (url.pathname === "/egress-policy" && request.method === "PUT") return handleEgressPolicyRequest(botId, request); diff --git a/agent-computer/src/profile-usage.ts b/agent-computer/src/profile-usage.ts new file mode 100644 index 000000000..483682422 --- /dev/null +++ b/agent-computer/src/profile-usage.ts @@ -0,0 +1,102 @@ +import { constants } from "node:fs"; +import { type FileHandle, lstat, open, opendir } from "node:fs/promises"; +import { isPlainBotId, profileDirectoryFor } from "./bot-id"; + +const LIMITS = { entries: 10_000, depth: 16, milliseconds: 2_000 }; +let measuring = false; + +/** + * Logical regular-file bytes for one Bot; never starts Chromium or reads file contents. + * Linux descriptor paths anchor every lookup to an already-open directory. O_NOFOLLOW on each + * child directory also refuses a symlink swapped in after lstat. Other platforms return unknown + * until they have an equally bounded, descriptor-relative implementation. + */ +export async function profileBytes( + root: string, + botId: string, + limits = LIMITS, +): Promise { + if (process.platform !== "linux" || !isPlainBotId(botId) || measuring) return null; + measuring = true; + let parent: FileHandle | undefined; + let profile: FileHandle | undefined; + const deadline = performance.now() + limits.milliseconds; + let entries = 0; + let bytes = 0; + const flags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW; + const anchored = (handle: FileHandle) => `/proc/self/fd/${handle.fd}`; + const check = () => { + if (performance.now() >= deadline) throw new Error("Profile measurement deadline."); + }; + try { + check(); + parent = await open(root, flags); + try { + profile = await open(profileDirectoryFor(anchored(parent), botId), flags); + } catch (error) { + // A missing Bot profile is a measured zero; an inaccessible root is not. + if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0; + throw error; + } + const device = (await profile.stat()).dev; + async function walk(handle: FileHandle, depth: number): Promise { + check(); + if (depth > limits.depth) throw new Error("Profile depth limit."); + const before = await handle.stat(); + if (before.dev !== device) throw new Error("Profile mount refused."); + const directory = await opendir(anchored(handle), { bufferSize: 32 }); + try { + for (;;) { + check(); + const entry = await directory.read(); + if (!entry) break; + if (++entries > limits.entries) throw new Error("Profile entry limit."); + const path = `${anchored(handle)}/${entry.name}`; + const info = await lstat(path); + check(); + // Chromium's SingletonLock/SingletonSocket/SingletonCookie links do not contribute + // target bytes. Never open them, even if their targets point into another Bot's profile. + if (info.isSymbolicLink()) continue; + if (info.dev !== device) throw new Error("Profile mount refused."); + if (info.isDirectory()) { + const child = await open(path, flags); + try { + const actual = await child.stat(); + if (actual.ino !== info.ino || actual.dev !== info.dev) + throw new Error("Profile directory changed."); + await walk(child, depth + 1); + } finally { + await child.close(); + } + } else if (info.isFile() && info.nlink === 1) { + bytes += info.size; + if (!Number.isSafeInteger(bytes) || bytes < 0) throw new Error("Profile size limit."); + } else { + throw new Error("Profile entry refused."); + } + } + } finally { + await directory.close(); + } + const after = await handle.stat(); + if (before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) + throw new Error("Profile directory changed."); + check(); + } + await walk(profile, 0); + return bytes; + } catch { + // Unknown is never a partial/estimated size, and errors must not disclose profile paths. + return null; + } finally { + try { + await profile?.close(); + } finally { + try { + await parent?.close(); + } finally { + measuring = false; + } + } + } +} diff --git a/agent-computer/tests/control-http.test.ts b/agent-computer/tests/control-http.test.ts index 3b092d0b9..f96c017a2 100644 --- a/agent-computer/tests/control-http.test.ts +++ b/agent-computer/tests/control-http.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ComputerControlState } from "../../shared/computer-control"; @@ -90,6 +90,27 @@ afterAll(async () => { describe.skipIf(!asked)( "request handoff through the actual computer HTTP dispatcher", () => { + test("profile usage is token-protected, single-Bot and never starts a browser", async () => { + await mkdir(join(root, "profiles", "bot-http"), { recursive: true }); + await writeFile(join(root, "profiles", "bot-http", "History"), "12345"); + await mkdir(join(root, "profiles", "other-bot"), { recursive: true }); + await writeFile(join(root, "profiles", "other-bot", "Cookies"), "PRIVATE"); + const headers = { "x-openbot-computer-token": token, "x-openbot-bot-id": "bot-http" }; + const url = `${base}/computers/profile-usage`; + expect((await fetch(url)).status).toBe(401); + expect( + (await fetch(url, { headers: { ...headers, "x-openbot-bot-id": "../other-bot" } })).status, + ).toBe(400); + expect((await fetch(url + "?path=/private", { headers })).status).toBe(400); + expect((await fetch(url, { headers, method: "POST" })).status).toBe(405); + const response = await fetch(url, { headers }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + profileBytes: process.platform === "linux" ? 5 : null, + }); + const health = await (await fetch(`${base}/health`, { headers })).json(); + expect(health.browser).toBe(false); + }); test("take cannot overtake admitted work; later mutations are refused, then freshness is mandatory", async () => { const running = post("/exec", { command: "printf admitted > admitted; sleep 0.3", diff --git a/agent-computer/tests/profile-usage.test.ts b/agent-computer/tests/profile-usage.test.ts new file mode 100644 index 000000000..30b46be23 --- /dev/null +++ b/agent-computer/tests/profile-usage.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { link, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { profileBytes } from "../src/profile-usage"; + +let root = ""; +afterEach(async () => { + if (root) await rm(root, { recursive: true, force: true }); +}); +async function fixture() { + root = await mkdtemp(join(tmpdir(), "profile-usage-")); + await mkdir(join(root, "bot", "Default"), { recursive: true }); + await writeFile(join(root, "bot", "Default", "History"), "12345"); + await writeFile(join(root, "bot", "Preferences"), "123"); + return root; +} + +describe.skipIf(process.platform !== "linux")("descriptor-anchored profile usage", () => { + test("counts only the requested Bot's regular files without creating a missing profile", async () => { + await fixture(); + await mkdir(join(root, "other")); + await writeFile(join(root, "other", "Cookies"), "PRIVATE".repeat(100)); + expect(await profileBytes(root, "bot")).toBe(8); + expect(await profileBytes(root, "new-bot")).toBe(0); + expect(await profileBytes(join(root, "missing-root"), "bot")).toBeNull(); + }); + test("never follows file, directory, dangling or cyclic symbolic links", async () => { + await fixture(); + await mkdir(join(root, "other")); + await writeFile(join(root, "other", "Cookies"), "PRIVATE"); + await symlink(join(root, "other"), join(root, "bot", "outside")); + await symlink(join(root, "other", "Cookies"), join(root, "bot", "SingletonCookie")); + await symlink("/nonexistent-profile-lock", join(root, "bot", "SingletonLock")); + await symlink(join(root, "bot"), join(root, "bot", "cycle")); + expect(await profileBytes(root, "bot")).toBe(8); + await symlink(join(root, "other"), join(root, "linked-bot")); + expect(await profileBytes(root, "linked-bot")).toBeNull(); + await symlink(root, join(root, "linked-root")); + expect(await profileBytes(join(root, "linked-root"), "bot")).toBeNull(); + }); + test("refuses hardlinks, invalid identities and exhausted bounds without partial bytes", async () => { + await fixture(); + expect(await profileBytes(root, "../bot")).toBeNull(); + expect( + await profileBytes(root, "bot", { entries: 1, depth: 16, milliseconds: 2000 }), + ).toBeNull(); + expect( + await profileBytes(root, "bot", { entries: 100, depth: 0, milliseconds: 2000 }), + ).toBeNull(); + expect( + await profileBytes(root, "bot", { entries: 100, depth: 16, milliseconds: 0 }), + ).toBeNull(); + await link(join(root, "bot", "Preferences"), join(root, "bot", "copy")); + expect(await profileBytes(root, "bot")).toBeNull(); + }); + test("admits one traversal at a time and releases it after refusal", async () => { + await fixture(); + const pending = profileBytes(root, "bot"); + expect(await profileBytes(root, "bot")).toBeNull(); + expect(await pending).toBe(8); + expect( + await profileBytes(root, "bot", { entries: 0, depth: 16, milliseconds: 2000 }), + ).toBeNull(); + expect(await profileBytes(root, "bot")).toBe(8); + }); +}); + +test.skipIf(process.platform === "linux")( + "unsupported traversal platforms report unknown", + async () => { + await fixture(); + expect(await profileBytes(root, "bot")).toBeNull(); + }, +);