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
113 changes: 104 additions & 9 deletions src/Capacitor.Cli/ProcessHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ static partial class ProcessHelpers {
[LibraryImport("kernel32.dll", SetLastError = true)]
private static partial nint GetCurrentProcess();

// Fills lpExeName with the full path of the process image (e.g. "C:\...\claude.exe").
// On input lpdwSize is the buffer size in chars; on success it's set to the number of
// chars written (excluding the terminating null). dwFlags 0 = Win32 path format.
[LibraryImport("kernel32.dll", EntryPoint = "QueryFullProcessImageNameW", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static unsafe partial bool QueryFullProcessImageName(nint hProcess, uint dwFlags, char* lpExeName, ref uint lpdwSize);

[LibraryImport("kernel32.dll", SetLastError = true)]
private static partial nint GetStdHandle(int nStdHandle);

Expand Down Expand Up @@ -85,6 +92,14 @@ struct ProcessBasicInformation {
const uint SYNCHRONIZE = 0x00100000;
const uint WAIT_TIMEOUT = 258;

// QueryFullProcessImageName sets this when lpExeName is too small for the image path.
const int ERROR_INSUFFICIENT_BUFFER = 122;

// Minimal access right that still permits NtQueryInformationProcess(ProcessBasicInformation)
// and QueryFullProcessImageName on Vista+. Cheaper than PROCESS_QUERY_INFORMATION and
// succeeds for same-user processes even when they're protected/elevated-adjacent.
const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;

// GetStdHandle ids and the SetHandleInformation inherit flag.
const int STD_INPUT_HANDLE = -10;
const int STD_OUTPUT_HANDLE = -11;
Expand Down Expand Up @@ -218,9 +233,10 @@ internal static bool TryClearInheritFlag(nint handle) {
/// the fallback is <c>getppid</c> — monitoring ourselves would let the
/// watcher self-terminate immediately.
///
/// On Windows there's no process-group equivalent so we fall back to the
/// parent PID. Codex on Windows is uncommon and the Claude path is already
/// covered by Claude's own SessionEnd hook.
/// On Windows there's no process-group equivalent, so the vendor-aware overload
/// walks the parent-PID ancestry by process name instead (see
/// <see cref="GetCodingAgentPid(string?)"/>); this parameterless overload, with no
/// vendor to match, falls back to the immediate parent PID.
/// </remarks>
public static int? GetCodingAgentPid() => GetCodingAgentPid(vendor: null);

Expand All @@ -238,7 +254,23 @@ internal static bool TryClearInheritFlag(nint handle) {
/// </summary>
public static int? GetCodingAgentPid(string? vendor) {
if (OperatingSystem.IsWindows()) {
return GetParentPidWindows();
// Walk the ppid ancestry by process name to skip the transient per-hook
// executor. GetParentPidWindows() alone returns that executor, which has
// usually already exited by the time the watcher boots — so the parent-PID
// watchdog saw a dead PID at startup and silently never armed (AI-822).
// Falls back to the immediate parent when no agent is found on the chain
// (preserving prior behaviour for the no-vendor / unmatched cases). PID reuse
// is a known Windows hazard for ppid walks, but the by-name match means a
// recycled PID would have to *be* the agent's executable to falsely match.
var startPid = GetParentPidWindows();

if (!string.IsNullOrEmpty(vendor)
&& startPid is { } sp
&& ResolveCodingAgentPid(sp, vendor, GetProcessInfo) is { } winAgentPid) {
return winAgentPid;
}

return startPid;
}

// Walk the ancestry for any named vendor. The match is by the vendor's process
Expand Down Expand Up @@ -299,19 +331,82 @@ internal static bool TryClearInheritFlag(nint handle) {

/// <summary>
/// Returns <c>(ppid, comm)</c> for an arbitrary live PID, or null if the process
/// can't be inspected (gone, or unsupported platform). Feeds the ancestry walk in
/// <see cref="ResolveCodingAgentPid"/>. Unix-only — Windows keeps the legacy
/// parent-PID path (codex/Windows is uncommon and claude has its own SessionEnd
/// hook there), so this returns null on Windows.
/// can't be inspected (gone, access denied, or unsupported platform). Feeds the
/// ancestry walk in <see cref="ResolveCodingAgentPid"/> on every platform — the
/// Windows implementation (AI-822) reads the parent PID via
/// <c>NtQueryInformationProcess</c> and the image name via
/// <c>QueryFullProcessImageName</c>.
/// </summary>
public static (int ppid, string comm)? GetProcessInfo(int pid) {
if (pid <= 0 || OperatingSystem.IsWindows()) {
if (pid <= 0) {
return null;
}

if (OperatingSystem.IsWindows()) {
return GetProcessInfoWindows(pid);
}

return OperatingSystem.IsMacOS() ? GetProcessInfoMac(pid) : GetProcessInfoLinux(pid);
}

static unsafe (int ppid, string comm)? GetProcessInfoWindows(int pid) {
var handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, (uint)pid);

if (handle == 0) {
return null;
}

try {
var pbi = default(ProcessBasicInformation);
var status = NtQueryInformationProcess(handle, 0, ref pbi, Unsafe.SizeOf<ProcessBasicInformation>(), out _);

if (status != 0) {
return null;
}

var ppid = (int)pbi.InheritedFromUniqueProcessId;

// comm is the full image path; MatchesAgentName takes the basename. An empty
// string on failure still lets the ancestry walk continue past this node (it
// just won't match here) — but if it's the agent's own path that fails to read,
// resolution falls back to the immediate parent and the watchdog can mis-arm,
// so QueryImageName grows the buffer rather than giving up on long paths.
return (ppid, QueryImageName(handle) ?? "");
} finally {
CloseHandle(handle);
}
}

/// <summary>
/// Reads a process's full image path via <c>QueryFullProcessImageName</c>, retrying on a
/// heap buffer if the path exceeds the initial stack buffer (Windows long paths can reach
/// ~32K chars). Returns null if the call fails for any reason other than buffer size.
/// </summary>
static unsafe string? QueryImageName(nint handle) {
const int stackCap = 1024;
char* stackBuf = stackalloc char[stackCap];
var len = (uint)stackCap;

if (QueryFullProcessImageName(handle, 0, stackBuf, ref len)) {
return new string(stackBuf, 0, (int)len);
}

if (Marshal.GetLastPInvokeError() != ERROR_INSUFFICIENT_BUFFER) {
return null;
}

// Extended-length path maximum (\\?\-prefixed paths). One retry covers every
// realistic case; too large for stackalloc, so use a heap buffer.
const int maxCap = 32768;
var heapBuf = new char[maxCap];

fixed (char* p = heapBuf) {
len = maxCap;

return QueryFullProcessImageName(handle, 0, p, ref len) ? new string(p, 0, (int)len) : null;
}
}

static unsafe (int ppid, string comm)? GetProcessInfoMac(int pid) {
Span<byte> buf = stackalloc byte[ProcBsdInfoSize];
buf.Clear();
Expand Down
18 changes: 18 additions & 0 deletions test/Capacitor.Cli.Tests.Unit/CodingAgentPidResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,24 @@ public async Task Matches_agent_name_in_a_full_path_case_insensitively() {
await Assert.That(pid).IsEqualTo(50);
}

[Test]
public async Task Matches_windows_exe_path_with_backslashes() {
// On Windows the per-PID lookup (GetProcessInfo) returns the full image path
// from QueryFullProcessImageNameW, e.g. "C:\...\claude.exe". The walk must match
// it by basename plus a single ".exe" extension so the Windows parent-PID
// watchdog can resolve the durable agent past the transient hook executor (AI-822).
// hook(100) -> cmd(90) -> claude.exe(50) -> explorer(20)
var lookup = ProcTable.Of(
(90, 50, @"C:\Windows\System32\cmd.exe"),
(50, 20, @"C:\Users\me\AppData\Local\Programs\claude\claude.exe"),
(20, 1, @"C:\Windows\explorer.exe")
);

var pid = ProcessHelpers.ResolveCodingAgentPid(startPid: 90, vendor: "claude", lookup);

await Assert.That(pid).IsEqualTo(50);
}

[Test]
public async Task Returns_null_when_no_agent_in_chain() {
// No claude/codex ancestor — caller falls back to the legacy heuristic.
Expand Down
7 changes: 3 additions & 4 deletions test/Capacitor.Cli.Tests.Unit/ProcessHelpersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,9 @@ public async Task GetCodingAgentPid_with_vendor_returns_a_live_non_self_process(
public async Task GetProcessInfo_returns_ppid_and_name_for_current_process() {
// Backs the ancestry walk: it must report a process's real parent PID and a
// non-empty executable name so the walk can match the coding agent by name.
if (OperatingSystem.IsWindows()) {
return; // Unix-only; Windows keeps the legacy parent-PID path.
}

// Runs on every platform now that Windows has a native implementation (AI-822):
// the Windows branch previously returned null, so the parent-PID watchdog had no
// way to resolve the durable coding-agent process and silently never armed.
var info = ProcessHelpers.GetProcessInfo(Environment.ProcessId);

await Assert.That(info).IsNotNull();
Expand Down
Loading