diff --git a/src/lib/usage-limits.js b/src/lib/usage-limits.js index 00d22b181..437b56549 100644 --- a/src/lib/usage-limits.js +++ b/src/lib/usage-limits.js @@ -2167,10 +2167,19 @@ async function detectAntigravityProcess({ ); processes = parseWindowsProcesses(result?.stdout); } else { - const result = await runCommand(commandRunner, "/bin/ps", ["-ax", "-o", "pid=,command="], { + let result = await runCommand(commandRunner, "/bin/ps", ["-ax", "-o", "pid=,command="], { timeout: timeoutMs, signal, }); + const isSpawnFailure = result?.error + && result.error.code !== "ETIMEDOUT" + && result.error.name !== "AbortError"; + if (isSpawnFailure && !result?.stdout) { + result = await runCommand(commandRunner, "ps", ["-ax", "-o", "pid=,command="], { + timeout: timeoutMs, + signal, + }); + } processes = String(result?.stdout || "") .split("\n") .map(parseProcessLine) @@ -2633,10 +2642,70 @@ function parseWindowsListeningPorts(output, pid) { return Array.from(ports).sort((a, b) => a - b); } +function parseLinuxProcListeningPorts(pid, { procRoot = "/proc" } = {}) { + const numPid = Number(pid); + if (!Number.isFinite(numPid) || numPid <= 0) return []; + const inodes = new Set(); + try { + const fds = fs.readdirSync(path.join(procRoot, String(numPid), "fd")); + for (const fd of fds) { + try { + const link = fs.readlinkSync(path.join(procRoot, String(numPid), "fd", fd)); + const m = link.match(/^socket:\[(\d+)\]$/); + if (m) inodes.add(m[1]); + } catch {} + } + } catch { + return []; + } + if (inodes.size === 0) return []; + + const ports = new Set(); + for (const table of [path.join(procRoot, "net", "tcp"), path.join(procRoot, "net", "tcp6")]) { + try { + const content = fs.readFileSync(table, "utf8"); + for (const line of content.split("\n")) { + const parts = line.trim().split(/\s+/); + if (parts.length > 9 && parts[3] === "0A") { + const inode = parts[9]; + if (inodes.has(inode)) { + const portHex = parts[1]?.split(":")[1]; + if (portHex) { + const port = parseInt(portHex, 16); + if (Number.isInteger(port) && port > 0 && port <= 65535) { + ports.add(port); + } + } + } + } + } + } catch {} + } + return Array.from(ports).sort((a, b) => a - b); +} + +function parseSsListeningPorts(output, pid) { + const ports = new Set(); + const pidPattern = pid ? new RegExp(`\\bpid=${pid}\\b`) : null; + for (const line of String(output || "").split("\n")) { + if (!line.includes("LISTEN")) continue; + if (pidPattern && !pidPattern.test(line)) continue; + const match = line.match(/:(\d+)\s+/); + if (match) { + const port = Number(match[1]); + if (Number.isInteger(port) && port > 0 && port <= 65535) { + ports.add(port); + } + } + } + return Array.from(ports).sort((a, b) => a - b); +} + async function listAntigravityPorts(pid, { commandRunner, platform = process.platform, timeoutMs = ANTIGRAVITY_PROCESS_SCAN_TIMEOUT_MS, + procRoot = "/proc", signal, } = {}) { if (platform === "win32") { @@ -2652,21 +2721,48 @@ async function listAntigravityPorts(pid, { } return ports; } + + // On Linux, try procfs first when no mock command runner is provided (zero-spawn, no dependencies). + if (platform === "linux" && !commandRunner) { + const procPorts = parseLinuxProcListeningPorts(pid, { procRoot }); + if (procPorts.length > 0) return procPorts; + } + const lsof = await resolveLsofBinary({ commandRunner }); - if (!lsof) { - throw new Error("Antigravity port detection needs lsof. Install it, then retry."); + if (lsof) { + const result = await runCommand( + commandRunner, + lsof, + ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", String(pid)], + { timeout: timeoutMs, signal }, + ); + const ports = parseListeningPorts(result?.stdout); + if (ports.length > 0) return ports; } - const result = await runCommand( - commandRunner, - lsof, - ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", String(pid)], - { timeout: timeoutMs, signal }, - ); - const ports = parseListeningPorts(result?.stdout); - if (!ports.length) { - throw new Error("Antigravity is running but not exposing ports yet. Try again in a few seconds."); + + // On Linux, fall back to procfs (honoring procRoot) or ss if lsof is absent or yielded no ports. + if (platform === "linux") { + const procPorts = parseLinuxProcListeningPorts(pid, { procRoot }); + if (procPorts.length > 0) return procPorts; + + const ss = await whichBinary("ss", { commandRunner }); + if (ss) { + const result = await runCommand( + commandRunner, + ss, + ["-H", "-tlpn"], + { timeout: timeoutMs, signal }, + ); + const ssPorts = parseSsListeningPorts(result?.stdout, pid); + if (ssPorts.length > 0) return ssPorts; + } + } + + if (!lsof && platform !== "linux") { + throw new Error("Antigravity port detection needs lsof. Install it, then retry."); } - return ports; + + throw new Error("Antigravity is running but not exposing ports yet. Try again in a few seconds."); } function antigravityDefaultBody() { @@ -3969,6 +4065,8 @@ module.exports = { loadAntigravityCredentials, parseListeningPorts, parseWindowsListeningPorts, + parseLinuxProcListeningPorts, + parseSsListeningPorts, listAntigravityPorts, detectAntigravityProcess, fetchAntigravityLimits, diff --git a/test/usage-limits.test.js b/test/usage-limits.test.js index 54e42de7b..ad3841d43 100644 --- a/test/usage-limits.test.js +++ b/test/usage-limits.test.js @@ -24,6 +24,8 @@ const { loadAntigravityCredentials, parseListeningPorts, parseWindowsListeningPorts, + parseLinuxProcListeningPorts, + parseSsListeningPorts, listAntigravityPorts, detectAntigravityProcess, fetchAntigravityLimits, @@ -3455,6 +3457,120 @@ lang 123 me 23u IPv4 0x124 0t0 TCP 127.0.0.1:51235 (LIS assert.equal(result.configured, false); }); + it("falls back to ps when /bin/ps is missing or returns error", async () => { + const calls = []; + const commandRunner = (command, args) => { + calls.push({ command, args }); + if (command === "/bin/ps") { + return { status: null, stdout: "", stderr: "", error: new Error("spawn /bin/ps ENOENT") }; + } + if (command === "ps") { + return { + status: 0, + stdout: "\n 456 agy\n", + }; + } + return { status: 1, stdout: "", stderr: "" }; + }; + + const result = await detectAntigravityProcess({ commandRunner }); + assert.equal(calls[0].command, "/bin/ps"); + assert.equal(calls[1].command, "ps"); + assert.equal(result.configured, true); + assert.equal(result.pid, 456); + }); + + it("discovers listening ports via Linux procfs", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tokentracker-procfs-test-")); + try { + const pidDir = path.join(tmp, "456", "fd"); + const netDir = path.join(tmp, "net"); + fs.mkdirSync(pidDir, { recursive: true }); + fs.mkdirSync(netDir, { recursive: true }); + fs.symlinkSync("socket:[123456]", path.join(pidDir, "12")); + fs.writeFileSync( + path.join(netDir, "tcp"), + [ + " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode", + " 0: 0100007F:8A11 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 123456 1 00000000 100 0 0 10 0", + ].join("\n"), + "utf8", + ); + + const ports = parseLinuxProcListeningPorts(456, { procRoot: tmp }); + assert.deepEqual(ports, [35345]); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("does not retry ps on timeout or abort errors", async () => { + const calls = []; + const timeoutError = new Error("spawn /bin/ps ETIMEDOUT"); + timeoutError.code = "ETIMEDOUT"; + + const commandRunner = (command, args) => { + calls.push({ command, args }); + return { status: null, stdout: "", stderr: "", error: timeoutError }; + }; + + const result = await detectAntigravityProcess({ commandRunner }); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, "/bin/ps"); + assert.equal(result.configured, false); + }); + + it("parses listening ports from ss command output", () => { + const output = [ + "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process", + 'LISTEN 0 4096 127.0.0.1:35345 0.0.0.0:* users:(("agy",pid=456,fd=14))', + 'LISTEN 0 4096 127.0.0.1:32919 0.0.0.0:* users:(("agy",pid=456,fd=12))', + 'LISTEN 0 4096 127.0.0.1:9999 0.0.0.0:* users:(("other",pid=789,fd=3))', + ].join("\n"); + + const ports = parseSsListeningPorts(output, 456); + assert.deepEqual(ports, [32919, 35345]); + }); + + it("exercises listAntigravityPorts Linux fallback chain (procfs miss -> lsof miss -> ss hit)", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tokentracker-listports-fallback-")); + try { + const calls = []; + const commandRunner = (command, args) => { + calls.push({ command, args }); + if (command === "which") { + if (args[0] === "lsof") return { status: 1, stdout: "", stderr: "" }; + if (args[0] === "ss") return { status: 0, stdout: "/usr/bin/ss\n", stderr: "" }; + } + if (command === "/usr/bin/ss" || command === "ss") { + return { + status: 0, + stdout: [ + "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process", + 'LISTEN 0 4096 127.0.0.1:35345 0.0.0.0:* users:(("agy",pid=456,fd=14))', + 'LISTEN 0 4096 127.0.0.1:32919 0.0.0.0:* users:(("agy",pid=456,fd=12))', + ].join("\n"), + stderr: "", + }; + } + return { status: 1, stdout: "", stderr: "" }; + }; + + const ports = await listAntigravityPorts(456, { + commandRunner, + platform: "linux", + procRoot: tmp, // procfs miss + }); + + assert.deepEqual(ports, [32919, 35345]); + assert.ok(calls.some((c) => (c.command === "which" && c.args?.[0] === "lsof") || String(c.command).endsWith("lsof"))); + assert.ok(calls.some((c) => c.command === "which" && c.args?.[0] === "ss")); + assert.ok(calls.some((c) => c.command === "/usr/bin/ss" || c.command === "ss")); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + 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 {