diff --git a/src/lib/process-list.js b/src/lib/process-list.js index e67a3201..0a6e78f0 100644 --- a/src/lib/process-list.js +++ b/src/lib/process-list.js @@ -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; diff --git a/src/lib/usage-limits.js b/src/lib/usage-limits.js index 25533940..f4805b40 100644 --- a/src/lib/usage-limits.js +++ b/src/lib/usage-limits.js @@ -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 }; @@ -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"); diff --git a/test/process-list.test.js b/test/process-list.test.js index 1c9569dc..7bf2063e 100644 --- a/test/process-list.test.js +++ b/test/process-list.test.js @@ -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, diff --git a/test/usage-limits.test.js b/test/usage-limits.test.js index cf078133..76b5dadf 100644 --- a/test/usage-limits.test.js +++ b/test/usage-limits.test.js @@ -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 {