Files
software-mansion__argent/packages/tool-server/test/vega-process.test.ts
Piotr Krawiec 49e020bafd fix(tool-server): resolve ps by absolute path so VVD dedup survives a sanitized PATH (#508)
## What

A running Vega VVD registers a second adb transport (`emulator-<port>`),
so it can surface in `list-devices` **twice** — once as
`platform:"vega"` and once as `platform:"android"`. There's dedup logic
(`resolveVvdShadowAdbSerials` + `filterVvdShadowsFromAndroid`) that
drops the adb "shadow" row by matching the running VVD's console port —
read from the process table via `ps` — against the adb serial's port.

That dedup was spawning a **bare `"ps"`**, which relies on `PATH`.

## Why it broke

An MCP server is frequently launched from a GUI / launchd context that
inherits a **sanitized `PATH`** omitting `/bin`. There, `execFile("ps",
…)` fails with `ENOENT`; the surrounding `catch` returns an empty port
set, and the dedup silently no-ops — so the VVD shows up as two devices.
It works fine from an interactive shell (where `/bin` is on `PATH`),
which is why it doesn't reproduce in a terminal or in the existing
tests.

Observed: `list-devices` returned both a phantom `platform:"android"`
`emulator-5554` and the real `platform:"vega"` device for a single
running VVD.

## Fix

Pin `ps` to its canonical absolute location, matching how the codebase
already hardcodes `/bin/sh` (`android-binary.ts`, `vega-cli.ts`,
`check-deps.ts`):

```ts
export const PS_BIN = ["/bin/ps", "/usr/bin/ps"].find((p) => existsSync(p)) ?? "ps";
```

Applied to all four `ps` spawn sites:
- `vega-process.ts` — `listRunningVvdConsolePorts`, `listRunningVvdPids`
- `vega-cli.ts` — `collectDescendantPids`, `pgidMembers` (same latent
PATH fragility in the process-reaping paths)

The bare-`"ps"` fallback is retained only if neither canonical path
exists, preserving `PATH` resolution for an atypical layout.

## Tests

- New regression test in `vega-process.test.ts` that strips `/bin` and
`/usr/bin` from `PATH` and asserts the probe still spawns `ps` (a bare
`"ps"` ENOENTs there — verified the test fails against the pre-fix
code).
- Updated the `list-devices.test.ts` mock to match `ps` by basename,
since production now passes an absolute path.
- `npm run build`, typecheck, lint (`--max-warnings 0`), and the full
vega/list-devices suite (47 tests) all pass.

## Verification

End-to-end `list-devices.execute()` under a launchd-style minimal `PATH`
now returns exactly **one** device (the Vega), with no android shadow.
`PS_BIN` resolves to `/bin/ps` and reads the VVD's console port even
with `/bin` removed from `PATH`.

---------

Co-authored-by: Piotr Krawiec <krawipio@amazon.pl>
2026-07-20 17:05:16 +02:00

183 lines
7.6 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { existsSync } from "node:fs";
import {
parseVvdConsolePorts,
parseVvdPids,
listRunningVvdConsolePorts,
listRunningVvdPids,
PS_ARGS,
PS_ARGS_WITH_PID,
PS_BIN,
} from "../src/utils/vega-process";
const execFileAsync = promisify(execFile);
// A `ps` line for a running VVD: the `vega-virtual-device` binary + `-ports`/`-qmp`.
function vvdLine(consolePort: number, opts: { ports?: boolean } = {}): string {
const { ports = true } = opts;
let line =
"/Users/me/vega/sdk/vega-sdk/main/0.22.6759/vvd/images/tv/vmtools/agent/qemu/" +
"darwin-aarch64/vega-virtual-device -avd-arch arm64 -skin tv-remote";
if (ports) line += ` -ports ${consolePort},${consolePort + 1}`;
line += " -dns-server auto";
line += ` -qemu -qmp unix:/tmp/qmp-socket-${consolePort}.sock,server,nowait`;
return line;
}
// Must NOT yield a port: an Android emulator (has `-ports`, wrong process), a
// crashpad sibling under the SDK path, and a git ref that names the branch.
const ANDROID_EMULATOR =
"/Users/me/Library/Android/sdk/emulator/qemu/darwin-aarch64/qemu-system-aarch64 " +
"-avd Pixel_7 -ports 5554,5555";
const CRASHPAD =
"/Users/me/vega/sdk/vega-sdk/main/0.22.6759/vvd/images/tv/vmtools/agent/crashpad_handler " +
"--database=/tmp/x --annotation=prod=AndroidEmulator";
const GIT_REF = "git push --dry-run origin vega-review-work:krawipio/vega-virtual-device";
describe("parseVvdConsolePorts", () => {
it("reads the console port from a running VVD's -ports flag", () => {
expect([...parseVvdConsolePorts(vvdLine(5554))]).toEqual([5554]);
});
it("ignores a co-running Android emulator, a crashpad sibling, and a branch ref", () => {
const ps = [ANDROID_EMULATOR, CRASHPAD, GIT_REF, vvdLine(5556)].join("\n");
// The git ref matches the name gate but has no -ports/-qmp → no phantom port.
expect([...parseVvdConsolePorts(ps)]).toEqual([5556]);
});
it("returns empty when no VVD process is present", () => {
const ps = [ANDROID_EMULATOR, CRASHPAD, "/sbin/launchd"].join("\n");
expect(parseVvdConsolePorts(ps).size).toBe(0);
});
it("reports every distinct console port when multiple VVDs run", () => {
const ps = [vvdLine(5554), vvdLine(5556)].join("\n");
expect([...parseVvdConsolePorts(ps)].sort((a, b) => a - b)).toEqual([5554, 5556]);
});
it("falls back to the qmp-socket path when -ports is absent", () => {
expect([...parseVvdConsolePorts(vvdLine(5558, { ports: false }))]).toEqual([5558]);
});
it("matches the legacy kepler-virtual-device process name", () => {
const line = vvdLine(5554).replace("vega-virtual-device", "kepler-virtual-device");
expect([...parseVvdConsolePorts(line)]).toEqual([5554]);
});
it("does not match a `…-virtual-device-wrapper` substring", () => {
const line = vvdLine(5554).replace("vega-virtual-device", "vega-virtual-device-wrapper");
expect(parseVvdConsolePorts(line).size).toBe(0);
});
});
// A `ps -o pid=,command=` line: a leading pid column, then the same argv.
function vvdPidLine(pid: number, consolePort: number): string {
return ` ${pid} ${vvdLine(consolePort)}`;
}
describe("parseVvdPids", () => {
it("reads the pid of a running VVD emulator process", () => {
expect(parseVvdPids(vvdPidLine(75137, 5554))).toEqual([75137]);
});
it("ignores a co-running Android emulator, a crashpad sibling, and a branch ref", () => {
const ps = [
` 4242 ${ANDROID_EMULATOR}`,
` 4243 ${CRASHPAD}`,
` 4244 ${GIT_REF}`,
vvdPidLine(75137, 5556),
].join("\n");
expect(parseVvdPids(ps)).toEqual([75137]);
});
it("returns empty when no VVD process is present", () => {
const ps = [` 1 /sbin/launchd`, ` 4242 ${ANDROID_EMULATOR}`].join("\n");
expect(parseVvdPids(ps)).toEqual([]);
});
it("reports every pid when multiple VVDs run", () => {
const ps = [vvdPidLine(75137, 5554), vvdPidLine(80001, 5556)].join("\n");
expect(parseVvdPids(ps).sort((a, b) => a - b)).toEqual([75137, 80001]);
});
it("matches the legacy kepler-virtual-device process name", () => {
const line = vvdPidLine(75137, 5554).replace("vega-virtual-device", "kepler-virtual-device");
expect(parseVvdPids(line)).toEqual([75137]);
});
it("does not match a `…-virtual-device-wrapper` substring", () => {
const line = vvdPidLine(75137, 5554).replace(
"vega-virtual-device",
"vega-virtual-device-wrapper"
);
expect(parseVvdPids(line)).toEqual([]);
});
});
// Exercises the REAL `ps` invocation (not mocked) so a flag invalid on the host —
// e.g. the BSD-only `-x` on the Linux CI runner — fails here, fast, instead of only
// surfacing in the slow Vega e2e (where the catch would otherwise hide it).
describe("listRunningVvdConsolePorts (real ps)", () => {
it("PS_ARGS is accepted by the host `ps` (exit 0, non-empty output)", async () => {
const { stdout } = await execFileAsync("ps", [...PS_ARGS], { maxBuffer: 16 * 1024 * 1024 });
expect(stdout.split("\n").filter(Boolean).length).toBeGreaterThan(0);
});
it("resolves to a Set of positive console ports without throwing", async () => {
const ports = await listRunningVvdConsolePorts();
expect(ports).toBeInstanceOf(Set);
for (const p of ports) expect(Number.isInteger(p) && p > 0).toBe(true);
});
it("PS_ARGS_WITH_PID is accepted by the host `ps` (exit 0, non-empty output)", async () => {
const { stdout } = await execFileAsync("ps", [...PS_ARGS_WITH_PID], {
maxBuffer: 16 * 1024 * 1024,
});
expect(stdout.split("\n").filter(Boolean).length).toBeGreaterThan(0);
});
it("resolves to positive integer pids without throwing", async () => {
const pids = await listRunningVvdPids();
expect(Array.isArray(pids)).toBe(true);
for (const p of pids) expect(Number.isInteger(p) && p > 0).toBe(true);
});
});
// Regression: under an MCP server's sanitized PATH (no /bin), a bare-name `ps`
// spawn ENOENTs; the catch then yields an empty port set and the VVD adb-shadow
// dedup no-ops, listing the VVD twice. An absolute `ps` path survives this.
describe("PS_BIN (survives a sanitized PATH)", () => {
// Every host CI runs on has /bin/ps or /usr/bin/ps, so PS_BIN resolves absolute
// here. But production deliberately falls back to bare "ps" when neither exists
// (Nix, a non-POSIX layout) to preserve PATH resolution — so assert the contract
// as a disjunction, not a hard "always absolute", and don't fail the intentional
// fallback path.
const psBinIsAbsolute = PS_BIN.startsWith("/");
it("is either an existing absolute path or the bare-`ps` PATH fallback", () => {
if (psBinIsAbsolute) expect(existsSync(PS_BIN)).toBe(true);
else expect(PS_BIN).toBe("ps");
});
// Only meaningful when PS_BIN is absolute: a bare "ps" is exactly what ENOENTs
// under a sanitized PATH, so asserting it survives there would contradict the
// fallback's intent. Skip rather than fail on a host without a canonical `ps`.
it.skipIf(!psBinIsAbsolute)("spawns `ps` with PATH stripped of /bin and /usr/bin", async () => {
const savedPath = process.env.PATH;
process.env.PATH = "/nonexistent-dir";
try {
// Load-bearing: a bare "ps" would reject with ENOENT here, so a non-empty
// exit-0 result proves the absolute-path resolution is what survives.
const { stdout } = await execFileAsync(PS_BIN, [...PS_ARGS], {
maxBuffer: 16 * 1024 * 1024,
});
expect(stdout.split("\n").filter(Boolean).length).toBeGreaterThan(0);
} finally {
if (savedPath === undefined) delete process.env.PATH;
else process.env.PATH = savedPath;
}
});
});