Skip to content

Commit ea27bb6

Browse files
Address PR review comments for Python in-process transport
- Remove in-process PATH resolution of the CLI entrypoint (#1467): a bare `copilot` name resolved differently for provisioning vs loading, orphaning the native lib. In-process entrypoints are always absolute; no PATH lookup. - Fix FFI-host cancellation ownership (#3972): assign self._ffi_host/_process before the blocking handshake so a cancelled start remains disposable. - Honor an empty per-connection env dict in child-process CLI-path resolution (#1422): use explicit `is not None` checks instead of truthiness. - Fail closed on missing/unsupported runtime-lib integrity (#418): refuse to load unverified native code; raise on unsupported hash algorithms. Add a regression test (test_cli_download.py). - Document the empty-except temp-file cleanup (CodeQL #433). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a96d1a82-c80e-40f1-a3df-a9708429b68b
1 parent 7b5d503 commit ea27bb6

3 files changed

Lines changed: 99 additions & 17 deletions

File tree

python/copilot/_cli_download.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,12 @@ def _verify_integrity(data: bytes, integrity: str) -> None:
345345
algo, _, b64 = integrity.partition("-")
346346
algo = algo.lower()
347347
if algo not in ("sha512", "sha384", "sha256"):
348-
return # Unknown/unsupported algorithm — skip rather than fail.
348+
# Fail closed: an unrecognized algorithm means we cannot verify this native
349+
# library, so refuse rather than loading unverified native code.
350+
raise RuntimeError(
351+
f"Unsupported integrity algorithm '{algo}' for the in-process runtime "
352+
"library; refusing to load unverified native code."
353+
)
349354
expected = base64.b64decode(b64)
350355
actual = hashlib.new(algo, data).digest()
351356
if actual != expected:
@@ -414,8 +419,17 @@ def ensure_runtime_library(cli_path: str, version: str | None = None) -> str | N
414419
data = _fetch_url_bytes(url, timeout=600)
415420

416421
integrity = _fetch_runtime_integrity(npm_platform, ver)
417-
if integrity:
418-
_verify_integrity(data, integrity)
422+
if not integrity:
423+
# Fail closed: this native library is loaded into the host process, so it must
424+
# be verified before use. The npm packument (which carries dist.integrity) was
425+
# unavailable, so refuse rather than loading unverified native code — mirroring
426+
# the CLI download, which requires a checksum. Retry when the registry is
427+
# reachable, or install a runtime package that ships the library.
428+
raise RuntimeError(
429+
"No Subresource Integrity value available for the in-process runtime "
430+
f"library ({npm_platform}@{ver}); refusing to load unverified native code."
431+
)
432+
_verify_integrity(data, integrity)
419433

420434
lib_bytes = _extract_runtime_node(data, npm_platform)
421435

@@ -431,6 +445,8 @@ def ensure_runtime_library(cli_path: str, version: str | None = None) -> str | N
431445
try:
432446
os.unlink(tmp_name)
433447
except OSError:
448+
# Best-effort cleanup of the temp file; ignore if it's already gone or
449+
# can't be removed (the OS reclaims it, and it doesn't affect correctness).
434450
pass
435451
if lib_path.exists():
436452
return str(lib_path)

python/copilot/client.py

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,7 +1419,16 @@ def __init__(
14191419
self._effective_connection_token = None
14201420

14211421
# Resolve CLI path: explicit > COPILOT_CLI_PATH env var > downloaded binary.
1422-
effective_env = connection.env or options.env or os.environ
1422+
# Select the environment by identity, not truthiness, so an intentionally
1423+
# empty per-connection or client env stays authoritative (the spawned child
1424+
# receives that empty mapping) instead of falling back to os.environ and
1425+
# unexpectedly honoring a host COPILOT_CLI_PATH.
1426+
if connection.env is not None:
1427+
effective_env: Mapping[str, str] = connection.env
1428+
elif options.env is not None:
1429+
effective_env = options.env
1430+
else:
1431+
effective_env = os.environ
14231432
connection.path = self._resolve_runtime_entrypoint(connection.path, env=effective_env)
14241433

14251434
# Resolve use_logged_in_user default
@@ -3948,14 +3957,14 @@ async def _start_inprocess_ffi(self) -> None:
39483957
cli_path = conn.path
39493958
assert cli_path is not None # resolved in __init__
39503959

3951-
# A packaged single-file CLI is invoked directly; a `.js` dev entrypoint is
3952-
# launched via node. Both are handled inside FfiRuntimeHost.
3953-
if not os.path.exists(cli_path) and cli_path.endswith(".js") is False:
3954-
resolved = shutil.which(cli_path)
3955-
if resolved is None:
3956-
raise RuntimeError(f"Copilot CLI not found at {cli_path}")
3957-
cli_path = resolved
3958-
3960+
# No PATH lookup here (unlike the child-process transport): the in-process
3961+
# entrypoint is always an absolute on-disk path — an explicit path,
3962+
# COPILOT_CLI_PATH, or the downloaded CLI — so the native library provisioned
3963+
# next to it in __init__ is exactly the one FfiRuntimeHost loads. Resolving a
3964+
# bare command name via PATH would look for the library beside a different
3965+
# directory than the one it was provisioned into and fail. A packaged
3966+
# single-file CLI is invoked directly; a `.js` dev entrypoint is launched via
3967+
# node — both handled inside FfiRuntimeHost.
39593968
logger.info(
39603969
"CopilotClient._start_inprocess_ffi hosting Copilot runtime in-process",
39613970
extra={"cli_path": cli_path, "cli_path_source": self._cli_path_source},
@@ -3966,6 +3975,15 @@ async def _start_inprocess_ffi(self) -> None:
39663975
# ambient environment. Pass extra worker args via connection.args.
39673976
host = FfiRuntimeHost.create(cli_path, environment=None, args=conn.args)
39683977

3978+
# Track the host and expose its process-like adapter *before* the blocking
3979+
# handshake. asyncio.to_thread keeps running host_start after a cancellation
3980+
# (a thread can't be interrupted), and CancelledError bypasses start()'s
3981+
# `except Exception`, so assigning here — as .NET does before StartAsync —
3982+
# keeps a completed native host owned so stop()/force_stop() can dispose it
3983+
# instead of leaking it.
3984+
self._ffi_host = host
3985+
self._process = host.process
3986+
39693987
ffi_start = time.perf_counter()
39703988
# host_start blocks until the worker connects back and signals readiness,
39713989
# so run the handshake off the event loop.
@@ -3977,11 +3995,6 @@ async def _start_inprocess_ffi(self) -> None:
39773995
ffi_start,
39783996
)
39793997

3980-
self._ffi_host = host
3981-
# Expose the process-like adapter so the JSON-RPC client and stop/force_stop
3982-
# paths treat it like a spawned runtime process.
3983-
self._process = host.process
3984-
39853998
async def _connect_to_server(self) -> None:
39863999
"""Connect to the runtime via the configured transport.
39874000

python/test_cli_download.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Tests for the in-process runtime library download integrity checks."""
2+
3+
from __future__ import annotations
4+
5+
import base64
6+
import hashlib
7+
from unittest.mock import patch
8+
9+
import pytest
10+
11+
from copilot import _cli_download
12+
13+
14+
def _integrity(data: bytes, algo: str = "sha512") -> str:
15+
digest = hashlib.new(algo, data).digest()
16+
return f"{algo}-{base64.b64encode(digest).decode('ascii')}"
17+
18+
19+
class TestVerifyIntegrity:
20+
def test_accepts_matching_checksum(self):
21+
data = b"native-library-bytes"
22+
_cli_download._verify_integrity(data, _integrity(data))
23+
24+
def test_rejects_mismatched_checksum(self):
25+
with pytest.raises(RuntimeError, match="Integrity mismatch"):
26+
_cli_download._verify_integrity(b"tampered", _integrity(b"original"))
27+
28+
def test_rejects_unsupported_algorithm(self):
29+
# Fail closed rather than silently skipping verification of native code.
30+
with pytest.raises(RuntimeError, match="Unsupported integrity algorithm"):
31+
_cli_download._verify_integrity(b"bytes", "md5-deadbeef")
32+
33+
34+
class TestEnsureRuntimeLibraryFailsClosed:
35+
def test_raises_when_integrity_unavailable(self, tmp_path):
36+
"""A missing npm integrity value must abort the download, not load unverified code."""
37+
cli_path = tmp_path / "copilot"
38+
cli_path.write_bytes(b"#!/bin/sh\n")
39+
40+
with (
41+
patch("copilot._ffi_runtime_host.resolve_library_path", return_value=None),
42+
patch.object(_cli_download, "_should_skip_download", return_value=False),
43+
patch.object(_cli_download, "get_npm_platform", return_value="linux-x64"),
44+
patch.object(_cli_download, "get_runtime_lib_url", return_value="https://example/lib"),
45+
patch.object(_cli_download, "_fetch_url_bytes", return_value=b"tarball-bytes"),
46+
patch.object(_cli_download, "_fetch_runtime_integrity", return_value=None),
47+
patch.object(_cli_download, "_extract_runtime_node") as extract,
48+
):
49+
with pytest.raises(RuntimeError, match="refusing to load unverified native code"):
50+
_cli_download.ensure_runtime_library(str(cli_path), version="1.2.3")
51+
52+
# The library bytes must never be extracted/written when verification is impossible.
53+
extract.assert_not_called()

0 commit comments

Comments
 (0)