Skip to content
Merged
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
15 changes: 14 additions & 1 deletion src/lib/process-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,20 @@ const PS_BINARY = "/bin/ps";
// that endpoint answer questions about other people's sessions. Scoping the scan
// itself is the narrow fix: a session TokenTracker could not have recorded
// anyway is one this user is not running.
const PS_ARGS = ["-x", "-o", "pid=,command="];
//
// Verified on both supported platforms rather than assumed from documented
// semantics, because Linux `ps` is procps and parses dash-prefixed options as
// UNIX-style, where `-x` is not an option at all. It does accept this as the BSD
// `x`: on Debian 12 / procps-ng 4.0.2, `ps -x -o pid=,command=` exits 0 and
// lists one user, while `-ax` on the same box lists seven. macOS/BSD `ps` is the
// native case. Had procps rejected it, every Linux host would have fallen into
// `process_list_failed` — a permanent non-advisory warn, which would pin
// `degraded` for a whole platform.
//
// Frozen because two modules now share this array. Importing one constant stops
// the two scans from drifting apart editorially; freezing is what stops a caller
// pushing `-a` onto it at runtime.
const PS_ARGS = Object.freeze(["-x", "-o", "pid=,command="]);
const PS_TIMEOUT_MS = 4000;
const PS_MAX_BUFFER = 10 * 1024 * 1024;

Expand Down
22 changes: 20 additions & 2 deletions src/lib/usage-limits.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const {
extractCursorSessionToken,
fetchCursorUsageSummary,
} = require("./cursor-config");
const { parseProcessLine } = require("./process-list");
const { PS_ARGS, PS_BINARY, parseProcessLine } = require("./process-list");

// 2-minute in-memory cache
let cache = { data: null, fetchedAt: 0 };
Expand Down Expand Up @@ -1350,8 +1350,26 @@ function extractCommandFlag(command, flag) {
return match?.[1] || null;
}

// Scans for the local Antigravity language server and reads its `--csrf_token`
// out of the command line, so that the quota request below can authenticate to
// it.
//
// The scan is scoped to the current user, and must stay that way. Under the
// previous `-ax` this walked every account on the box and attached to whichever
// Antigravity matched first. The token itself never reached an HTTP response —
// `processInfo` is read field by field and never spread into a return value —
// but what the token *fetches* does: `normalizeAntigravityResponse` returns
// `account_email` and `account_plan`, `finalize` spreads them into the result,
// and `getUsageLimits` serves that at `/functions/tokentracker-usage-limits`.
// On a shared host this meant showing another person's email, plan and quota as
// the local user's, and `writeAntigravityLimitsCache` persisted it to disk,
// where the `!configured` branch would keep serving it after their process
// exited.
//
// Sharing PS_ARGS with process-list.js is deliberate: two scans that must both
// stay own-user should not be able to drift apart.
function detectAntigravityProcess({ commandRunner } = {}) {
const result = runCommand(commandRunner, "/bin/ps", ["-ax", "-o", "pid=,command="], {
const result = runCommand(commandRunner, PS_BINARY, PS_ARGS, {
timeout: 4000,
});
const lines = String(result?.stdout || "").split("\n");
Expand Down
12 changes: 12 additions & 0 deletions test/process-list.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ test("the process scan is scoped to the current user", () => {
);
});

// Both `ps` call sites import this one array, which stops them drifting apart
// when someone edits one of them. It does not stop a caller mutating the shared
// object at runtime, and "cannot drift apart" is only true of a frozen one.
test("the shared argv cannot be widened at runtime", () => {
assert.equal(Object.isFrozen(PS_ARGS), true);
assert.throws(() => {
"use strict";
PS_ARGS.push("-a");
}, TypeError);
assert.deepEqual(PS_ARGS, ["-x", "-o", "pid=,command="]);
});

test("parseProcessLine splits a pid from a command and rejects junk", () => {
assert.deepEqual(parseProcessLine(" 4211 /usr/local/bin/claude --model x"), {
pid: 4211,
Expand Down
27 changes: 27 additions & 0 deletions test/usage-limits.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1271,6 +1271,33 @@ lang 123 me 23u IPv4 0x124 0t0 TCP 127.0.0.1:51235 (LIS
assert.equal(result.extensionPort, 42427);
});

// The scan must stay scoped to the user running TokenTracker. Under `-ax` this
// walked every account on the box and attached to whichever Antigravity
// matched first — authenticating with that person's CSRF token and then
// serving their `account_email`, plan and quota at
// `/functions/tokentracker-usage-limits` as if they were the local user's,
// and caching it to disk.
//
// Asserted on the literal argv rather than on observed output, because a real
// `ps` run on a single-user machine returns the same lines either way and
// cannot catch the regression.
it("scans only the current user's processes", () => {
let invocation = null;
const commandRunner = (command, args) => {
invocation = { command, args };
return { stdout: "", status: 0 };
};

detectAntigravityProcess({ commandRunner });

assert.equal(invocation.command, "/bin/ps");
assert.deepEqual(invocation.args, ["-x", "-o", "pid=,command="]);
assert.ok(
!invocation.args.some((arg) => /^-[a-z]*a/.test(arg)),
"ps must not be invoked with the all-users flag",
);
});

it("persists live Antigravity quota for use after the process exits", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tokentracker-antigravity-cache-write-"));
try {
Expand Down