Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions agent-computer/PROFILE_USAGE.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 9 additions & 1 deletion agent-computer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -677,6 +679,12 @@ serve<StreamData>({
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);
Expand Down
102 changes: 102 additions & 0 deletions agent-computer/src/profile-usage.ts
Original file line number Diff line number Diff line change
@@ -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<number | null> {
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<void> {
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;
}
}
}
}
23 changes: 22 additions & 1 deletion agent-computer/tests/control-http.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
Expand Down
75 changes: 75 additions & 0 deletions agent-computer/tests/profile-usage.test.ts
Original file line number Diff line number Diff line change
@@ -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();
},
);