Skip to content

Commit c959ec5

Browse files
committed
Fix npx auth verification on Windows
1 parent 43e6684 commit c959ec5

3 files changed

Lines changed: 92 additions & 12 deletions

File tree

.github/workflows/client.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,7 @@ def send_jsonrpc(proc: subprocess.Popen, method: str, params: dict, msg_id: int
105105

106106
def read_jsonrpc(proc: subprocess.Popen, timeout: float) -> dict | None:
107107
"""Read a JSON-RPC response from the process (raw JSON, newline-delimited)."""
108-
import select
109-
110-
ready, _, _ = select.select([proc.stdout], [], [], timeout)
111-
if not ready:
112-
return None
113-
114-
line = proc.stdout.readline()
108+
line = _read_stdout_line(proc, timeout)
115109
if not line:
116110
return None
117111

@@ -126,6 +120,33 @@ def read_jsonrpc(proc: subprocess.Popen, timeout: float) -> dict | None:
126120
) from e
127121

128122

123+
def _read_stdout_line(proc: subprocess.Popen, timeout: float) -> str | None:
124+
"""Read one stdout line with a timeout on both Unix and Windows pipes."""
125+
import queue
126+
import select
127+
import threading
128+
129+
try:
130+
ready, _, _ = select.select([proc.stdout], [], [], timeout)
131+
except (OSError, ValueError):
132+
line_queue: queue.Queue[str] = queue.Queue(maxsize=1)
133+
134+
def read_line() -> None:
135+
line_queue.put(proc.stdout.readline())
136+
137+
thread = threading.Thread(target=read_line, daemon=True)
138+
thread.start()
139+
try:
140+
return line_queue.get(timeout=timeout)
141+
except queue.Empty:
142+
return None
143+
144+
if not ready:
145+
return None
146+
147+
return proc.stdout.readline()
148+
149+
129150
def _collect_proc_diagnostics(proc: subprocess.Popen) -> tuple[str | None, int | None]:
130151
"""Collect stderr tail and exit code from a process (non-blocking).
131152

.github/workflows/tests/test_verify_agents.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import os
22
import stat
3+
import sys
34
from pathlib import Path
45

56
from verify_agents import (
67
build_installed_npx_command,
8+
build_npx_command,
79
ensure_executable,
810
npm_package_bin_name,
11+
read_jsonrpc,
912
resolve_binary_executable,
1013
should_retry_npx_auth_with_install,
1114
)
@@ -23,6 +26,9 @@ def test_resolve_binary_executable_renames_single_raw_binary(tmp_path: Path):
2326

2427

2528
def test_ensure_executable_adds_execute_bits(tmp_path: Path):
29+
if sys.platform == "win32":
30+
return
31+
2632
binary = tmp_path / "tool"
2733
binary.write_text("#!/bin/sh\n")
2834
binary.chmod(0o644)
@@ -74,3 +80,41 @@ def test_should_retry_npx_auth_with_install_on_shim_error():
7480
"Timeout after 120s waiting for initialize response",
7581
"[Junie] Shim not found at /tmp/home/.local/bin/junie\nPlease reinstall: npm install",
7682
)
83+
84+
85+
def test_build_npx_command_uses_resolved_executable():
86+
command = build_npx_command("C:/node/npx.cmd", Path("sandbox"), "omp-acp@0.1.2", [])
87+
88+
assert command == ["C:/node/npx.cmd", "--prefix", "sandbox", "--yes", "omp-acp@0.1.2"]
89+
90+
91+
def test_build_npx_command_preserves_distribution_args():
92+
command = build_npx_command("/usr/bin/npx", Path("sandbox"), "agent@1.2.3", ["--acp"])
93+
94+
assert command == ["/usr/bin/npx", "--prefix", "sandbox", "--yes", "agent@1.2.3", "--acp"]
95+
96+
97+
class _FakePipe:
98+
def __init__(self, line: str):
99+
self._line = line
100+
101+
def fileno(self):
102+
if sys.platform == "win32":
103+
return -1
104+
raise AssertionError("fileno should not be used outside the Windows fallback test")
105+
106+
def readline(self):
107+
line = self._line
108+
self._line = ""
109+
return line
110+
111+
112+
class _FakeProcess:
113+
def __init__(self, line: str):
114+
self.stdout = _FakePipe(line)
115+
116+
117+
def test_read_jsonrpc_falls_back_for_non_socket_windows_pipe():
118+
response = read_jsonrpc(_FakeProcess('{"jsonrpc":"2.0","id":1,"result":{}}\n'), 1)
119+
120+
assert response == {"jsonrpc": "2.0", "id": 1, "result": {}}

.github/workflows/verify_agents.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
# Import auth client (only needed when --auth-check is used)
2424
try:
25-
from client import run_auth_check
25+
from client import read_jsonrpc, run_auth_check
2626

2727
HAS_AUTH_CLIENT = True
2828
except ImportError:
@@ -69,6 +69,19 @@ def check_command_exists(cmd: str) -> bool:
6969
"""Check if a command exists in PATH."""
7070
return shutil.which(cmd) is not None
7171

72+
def resolve_command(cmd: str) -> str | None:
73+
"""Resolve a command name to an executable path usable by subprocess on all platforms."""
74+
return shutil.which(cmd)
75+
76+
77+
def build_npx_command(npx_executable: str, sandbox: Path, package: str, args: list[str]) -> list[str]:
78+
"""Build an npx command using the resolved executable path.
79+
80+
Windows Python subprocess does not resolve PATHEXT entries such as npx.cmd
81+
when invoked as a bare command. Passing the resolved path keeps the same
82+
behavior on Unix while making host-native Windows verification work.
83+
"""
84+
return [npx_executable, "--prefix", str(sandbox), "--yes", package] + args
7285

7386
def download_file(url: str, dest: Path) -> bool:
7487
"""Download a file from URL with progress."""
@@ -383,8 +396,9 @@ def verify_npx(agent: dict, sandbox: Path, timeout: int, verbose: bool) -> Resul
383396
"""Verify npx distribution."""
384397
agent_id = agent["id"]
385398

386-
if not check_command_exists("npm"):
387-
return Result(agent_id, "npx", False, "npm not installed", skipped=True)
399+
npx_executable = resolve_command("npx")
400+
if npx_executable is None:
401+
return Result(agent_id, "npx", False, "npx not installed", skipped=True)
388402

389403
npx_dist = agent["distribution"].get("npx", {})
390404
package = npx_dist.get("package", "")
@@ -393,7 +407,7 @@ def verify_npx(agent: dict, sandbox: Path, timeout: int, verbose: bool) -> Resul
393407

394408
print(f" → Running: npx {package} {' '.join(args)}")
395409

396-
cmd = ["npx", "--prefix", str(sandbox), "--yes", package] + args
410+
cmd = build_npx_command(npx_executable, sandbox, package, args)
397411
exit_code, stdout, stderr = run_process(cmd, sandbox, env, timeout)
398412

399413
if exit_code is None:
@@ -505,7 +519,8 @@ def build_agent_command(
505519
package = npx_dist.get("package", "")
506520
args = npx_dist.get("args", [])
507521
env = npx_dist.get("env", {})
508-
cmd = ["npx", "--prefix", str(sandbox), "--yes", package] + args
522+
npx_executable = resolve_command("npx")
523+
cmd = build_npx_command(npx_executable, sandbox, package, args) if npx_executable else []
509524
cwd = sandbox
510525
elif dist_type == "uvx":
511526
uvx_dist = distribution.get("uvx", {})

0 commit comments

Comments
 (0)