Skip to content

Commit 744cc3f

Browse files
committed
Keep service passwords out of repr and search only PATH for the command
BasicAuth and the registration record hide their password from repr, so endpoints can be logged safely. The service command is resolved from PATH entries only: shutil.which on Windows also tries the current directory, which Node's spawn does not.
1 parent a69ffc7 commit 744cc3f

2 files changed

Lines changed: 34 additions & 4 deletions

File tree

‎src/opencode/_service.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ class BasicAuth:
7878

7979
username: str
8080
"""Basic auth user name, always ``opencode``."""
81-
password: str
82-
"""The service's private password."""
81+
password: str = field(repr=False)
82+
"""The service's private password. Hidden from ``repr`` so it stays out of logs."""
8383

8484

8585
@dataclass(frozen=True)
@@ -101,7 +101,7 @@ class _Info:
101101
pid: int
102102
id: str | None = None
103103
version: str | None = None
104-
password: str | None = None
104+
password: str | None = field(default=None, repr=False)
105105

106106

107107
@dataclass(frozen=True)
@@ -540,7 +540,7 @@ class _Contender:
540540
def __init__(self, command: Sequence[str], env: Mapping[str, str | None]) -> None:
541541
merged = {**os.environ, **env}
542542
environment = {key: value for key, value in merged.items() if value is not None}
543-
executable = shutil.which(command[0]) or command[0]
543+
executable = _resolve(command[0])
544544
if sys.platform == "win32": # pragma: no cover - Windows only
545545
self.process = subprocess.Popen(
546546
[executable, *command[1:]],
@@ -604,6 +604,20 @@ def failure(self) -> ServiceError | None:
604604
return _startup_error(f"Server process terminated by {name}", self.stderr())
605605

606606

607+
def _resolve(command: str) -> str:
608+
"""Find ``command`` on ``PATH`` only.
609+
610+
``shutil.which`` on Windows also searches the current directory first, which Node's spawn in the TypeScript
611+
client does not, so a planted ``opencode.exe`` there could run. A command with a directory part is used as is.
612+
"""
613+
if os.path.dirname(command):
614+
return command
615+
for directory in os.environ.get("PATH", os.defpath).split(os.pathsep):
616+
if directory and (found := shutil.which(os.path.join(directory, command))) is not None:
617+
return found
618+
return command
619+
620+
607621
def _startup_error(message: str, stderr: str) -> ServiceError:
608622
return ServiceError(f"{message}\n{stderr}" if stderr else message)
609623

‎tests/test_service.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,19 @@ def test_probe_timeout_is_reported(file: Path) -> None:
271271
registration = impl._registered(file, 0.1)
272272
assert registration.timed_out
273273
assert registration.service is None
274+
275+
276+
def test_resolve_searches_path_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
277+
bin_dir, cwd = tmp_path / "bin", tmp_path / "cwd"
278+
bin_dir.mkdir()
279+
cwd.mkdir()
280+
name = "tool.exe" if sys.platform == "win32" else "tool"
281+
for folder in (bin_dir, cwd):
282+
(folder / name).write_text("")
283+
(folder / name).chmod(0o755)
284+
monkeypatch.chdir(cwd)
285+
monkeypatch.setenv("PATH", str(bin_dir))
286+
assert Path(impl._resolve("tool")).parent == bin_dir
287+
monkeypatch.setenv("PATH", "")
288+
assert impl._resolve("tool") == "tool"
289+
assert impl._resolve(str(cwd / name)) == str(cwd / name)

0 commit comments

Comments
 (0)