Skip to content

Commit 1275871

Browse files
python: add in-process (FFI) transport
Implement the in-process (FFI) transport for the Python SDK at parity with the .NET/Rust/TypeScript implementations. Instead of spawning the runtime as a child process, the SDK loads the runtime's native shared library into the host process (via stdlib ctypes) and drives JSON-RPC over its C ABI; the native host spawns the residual worker itself. - api: RuntimeConnection.for_inprocess() + InProcessRuntimeConnection; add a per-connection env field to ChildProcessRuntimeConnection (stdio/tcp). - default transport: honor COPILOT_SDK_DEFAULT_CONNECTION=inprocess. - ffi host: new copilot/_ffi_runtime_host.py (ctypes bindings, outbound callback with drain/lifetime guarding, RTLD_NOW load, process-like adapter so JsonRpcClient works unchanged). - client wiring: start/stop/force_stop route through the FFI host. - env guards: reject env/telemetry/working_directory with in-process, and reject env set on both client and connection for child-process transports. - bundling: fetch the native runtime library only opt-in — via `download-runtime --in-process` or lazily on first in-process use; never for stdio/tcp users. - tests: in-process E2E smoke test; harness honors the inprocess default with env mirroring + cwd redirect; conftest neutralizes ambient HMAC for in-process; CI transport matrix cell. - docs: python/README.md in-process section; APIs flagged experimental. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a96d1a82-c80e-40f1-a3df-a9708429b68b
1 parent b3bee0e commit 1275871

11 files changed

Lines changed: 1245 additions & 43 deletions

File tree

.github/workflows/python-sdk-tests.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ permissions:
3131

3232
jobs:
3333
test:
34-
name: "Python SDK Tests"
34+
name: "Python SDK Tests (${{ matrix.os }}, ${{ matrix.transport }})"
3535
if: github.event.repository.fork == false
3636
env:
3737
POWERSHELL_UPDATECHECK: Off
@@ -41,6 +41,7 @@ jobs:
4141
os: [ubuntu-latest, macos-latest, windows-latest]
4242
# Test the oldest supported Python version to make sure compatibility is maintained.
4343
python-version: ["3.11"]
44+
transport: ["default", "inprocess"]
4445
runs-on: ${{ matrix.os }}
4546
defaults:
4647
run:
@@ -86,6 +87,11 @@ jobs:
8687
if: runner.os == 'Windows'
8788
run: pwsh.exe -Command "Write-Host 'PowerShell ready'"
8889

90+
- name: Select inprocess transport
91+
if: matrix.transport == 'inprocess'
92+
run: |
93+
echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV"
94+
8995
- name: Run Python SDK tests
9096
env:
9197
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}

python/README.md

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ python -m copilot download-runtime
3232
This caches the runtime binary locally. If you skip this step, the SDK will
3333
attempt to download it automatically on first use as a fallback.
3434

35+
To pre-provision the native library required by the in-process (FFI) transport
36+
(see [In-process (FFI) transport](#in-process-ffi-transport)), pass `--in-process`:
37+
38+
```bash
39+
python -m copilot download-runtime --in-process
40+
```
41+
42+
This additionally fetches the native runtime shared library and places it next to
43+
the cached binary. Stdio/TCP users never download it. When omitted, the library is
44+
downloaded lazily on first use of the in-process transport.
45+
3546
| Platform | Cache path |
3647
|----------|-----------|
3748
| Linux | `~/.cache/github-copilot-sdk/cli/<version>/copilot` |
@@ -136,7 +147,7 @@ asyncio.run(main())
136147
## Features
137148

138149
- ✅ Full JSON-RPC protocol support
139-
- ✅ stdio and TCP transports
150+
- ✅ stdio, TCP, and in-process (FFI) transports
140151
- ✅ Real-time streaming events
141152
- ✅ Session history with `get_events()`
142153
- ✅ Type hints throughout
@@ -184,8 +195,9 @@ CopilotClient(connection=..., log_level="debug", github_token=..., ...)
184195
All options are kw-only parameters:
185196

186197
- `connection` (RuntimeConnection | None): How to reach the runtime. Use
187-
`RuntimeConnection.for_stdio(...)`, `RuntimeConnection.for_tcp(...)`, or
188-
`RuntimeConnection.for_uri(...)`. Defaults to a stdio connection with the bundled binary.
198+
`RuntimeConnection.for_stdio(...)`, `RuntimeConnection.for_tcp(...)`,
199+
`RuntimeConnection.for_uri(...)`, or `RuntimeConnection.for_inprocess(...)`.
200+
Defaults to a stdio connection with the bundled binary.
189201
- `working_directory` (str | None): Working directory for the CLI process (default: current dir).
190202
- `log_level` (str): Log level (default: "info").
191203
- `env` (dict | None): Environment variables for the CLI process.
@@ -204,6 +216,49 @@ All options are kw-only parameters:
204216
- `RuntimeConnection.for_stdio(path=None, args=None)` — spawn a local CLI process and talk over stdio.
205217
- `RuntimeConnection.for_tcp(port=0, connection_token=None, path=None, args=None)` — spawn a local CLI in TCP mode.
206218
- `RuntimeConnection.for_uri(url, connection_token=None)` — connect to an existing CLI server (e.g. `"localhost:8080"`).
219+
- `RuntimeConnection.for_inprocess(path=None, args=None)` — host the runtime in-process via its native C ABI (FFI). See [In-process (FFI) transport](#in-process-ffi-transport).
220+
221+
Child-process connections (`for_stdio`/`for_tcp`) also expose a per-connection
222+
`env` field for the spawned process. Set it on the returned connection instead of
223+
the client-level `env` — setting both raises:
224+
225+
```python
226+
conn = RuntimeConnection.for_stdio()
227+
conn.env = {"MY_VAR": "value"}
228+
client = CopilotClient(connection=conn) # do NOT also pass env=... here
229+
```
230+
231+
### In-process (FFI) transport
232+
233+
> ⚠️ **Experimental.** The in-process transport loads the runtime's native shared
234+
> library into your process and drives JSON-RPC over its C ABI (via stdlib
235+
> `ctypes`), instead of spawning a child process. The native host spawns the
236+
> residual worker itself.
237+
238+
```python
239+
from copilot import CopilotClient, RuntimeConnection
240+
241+
client = CopilotClient(connection=RuntimeConnection.for_inprocess())
242+
await client.start()
243+
try:
244+
pong = await client.ping("hello")
245+
print(pong.message)
246+
finally:
247+
await client.stop()
248+
```
249+
250+
**Requirements & behavior:**
251+
252+
- Requires the native runtime library next to the CLI. Pre-provision it with
253+
`python -m copilot download-runtime --in-process`, or let the SDK download it
254+
lazily on first use of this transport.
255+
- Because the runtime shares this single host process, per-client options that
256+
lower to environment variables or a working directory **cannot** be honored and
257+
are rejected: `env`, `telemetry`, and `working_directory` all raise `ValueError`
258+
with `for_inprocess()`. Set the corresponding values on the host process
259+
environment / working directory before creating the client instead.
260+
- Set `COPILOT_SDK_DEFAULT_CONNECTION=inprocess` to select the in-process
261+
transport by default when no explicit `connection` is supplied.
207262

208263
**`CopilotClient.create_session()`:**
209264

python/copilot/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
CopilotClient,
3737
GetAuthStatusResponse,
3838
GetStatusResponse,
39+
InProcessRuntimeConnection,
3940
LogLevel,
4041
ModelBilling,
4142
ModelCapabilities,
@@ -235,6 +236,7 @@
235236
"GitHubTelemetryEvent",
236237
"GitHubTelemetryNotification",
237238
"InfiniteSessionConfig",
239+
"InProcessRuntimeConnection",
238240
"InputOptions",
239241
"LargeToolOutputConfig",
240242
"LlmInferenceHeaders",

python/copilot/_cli_download.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from __future__ import annotations
1818

19+
import base64
1920
import hashlib
2021
import io
2122
import os
@@ -35,6 +36,9 @@
3536
get_asset_info,
3637
get_checksums_url,
3738
get_download_url,
39+
get_npm_platform,
40+
get_runtime_lib_packument_url,
41+
get_runtime_lib_url,
3842
)
3943

4044
_CACHE_DIR_NAME = "github-copilot-sdk"
@@ -304,6 +308,137 @@ def download_cli(version: str | None = None, *, force: bool = False) -> str:
304308
return str(binary_path)
305309

306310

311+
def _fetch_url_bytes(url: str, *, timeout: int) -> bytes:
312+
"""Download bytes from ``url`` with retries."""
313+
last_exc: Exception | None = None
314+
for attempt in range(_MAX_RETRIES):
315+
try:
316+
with urlopen(url, timeout=timeout) as response:
317+
return response.read()
318+
except (HTTPError, URLError) as exc:
319+
last_exc = exc
320+
if attempt < _MAX_RETRIES - 1:
321+
time.sleep(2**attempt)
322+
raise RuntimeError(f"Failed to download from {url}: {last_exc}") from last_exc
323+
324+
325+
def _fetch_runtime_integrity(npm_platform: str, version: str) -> str | None:
326+
"""Return the npm ``dist.integrity`` (Subresource Integrity) for the tarball.
327+
328+
Best-effort: returns None if the packument can't be fetched or parsed.
329+
"""
330+
import json
331+
332+
url = get_runtime_lib_packument_url(npm_platform)
333+
try:
334+
raw = _fetch_url_bytes(url, timeout=30)
335+
packument = json.loads(raw)
336+
dist = packument.get("versions", {}).get(version, {}).get("dist", {})
337+
integrity = dist.get("integrity")
338+
return integrity if isinstance(integrity, str) else None
339+
except (RuntimeError, ValueError, KeyError):
340+
return None
341+
342+
343+
def _verify_integrity(data: bytes, integrity: str) -> None:
344+
"""Verify data against an npm Subresource Integrity string (e.g. ``sha512-<b64>``)."""
345+
algo, _, b64 = integrity.partition("-")
346+
algo = algo.lower()
347+
if algo not in ("sha512", "sha384", "sha256"):
348+
return # Unknown/unsupported algorithm — skip rather than fail.
349+
expected = base64.b64decode(b64)
350+
actual = hashlib.new(algo, data).digest()
351+
if actual != expected:
352+
raise RuntimeError(
353+
f"Integrity mismatch for runtime library ({algo}): "
354+
"downloaded tarball does not match the npm registry checksum."
355+
)
356+
357+
358+
def _extract_runtime_node(data: bytes, npm_platform: str) -> bytes:
359+
"""Extract ``package/prebuilds/<npm_platform>/runtime.node`` from an npm tarball."""
360+
target = f"package/prebuilds/{npm_platform}/runtime.node"
361+
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf:
362+
for name in tf.getnames():
363+
if name == target or name.endswith(f"/prebuilds/{npm_platform}/runtime.node"):
364+
member = tf.getmember(name)
365+
extracted = tf.extractfile(member)
366+
if extracted is not None:
367+
return extracted.read()
368+
raise RuntimeError(f"'{target}' not found in runtime package for {npm_platform}.")
369+
370+
371+
def ensure_runtime_library(cli_path: str, version: str | None = None) -> str | None:
372+
"""Ensure the native in-process (FFI) runtime library sits next to ``cli_path``.
373+
374+
The library is NOT part of the GitHub Releases CLI archive; it ships in the npm
375+
platform package ``@github/copilot-<platform>`` under
376+
``package/prebuilds/<platform>/runtime.node``. This helper downloads that tarball
377+
and writes the library next to the CLI binary under its natural platform name
378+
(``libcopilot_runtime.so`` / ``.dylib`` / ``copilot_runtime.dll``).
379+
380+
This is opt-in — only invoked when the in-process transport is actually selected
381+
(lazy) or via ``python -m copilot download-runtime --in-process`` (explicit). The
382+
default stdio download path never fetches these extra bytes.
383+
384+
Returns the absolute path to the library, or None if it could not be provisioned
385+
(e.g. download disabled or unsupported platform). Raises RuntimeError on
386+
download/verification failure.
387+
"""
388+
# Import lazily to avoid a hard dependency for stdio-only users.
389+
from ._ffi_runtime_host import _natural_library_name, resolve_library_path
390+
391+
# Already present (bundled prebuilds layout in dev, or a prior download)?
392+
existing = resolve_library_path(cli_path)
393+
if existing is not None:
394+
return existing
395+
396+
if _should_skip_download():
397+
return None
398+
399+
ver = version or CLI_VERSION
400+
if not ver:
401+
return None
402+
403+
try:
404+
npm_platform = get_npm_platform()
405+
except RuntimeError:
406+
return None
407+
408+
cli_dir = Path(cli_path).resolve().parent
409+
lib_path = cli_dir / _natural_library_name()
410+
if lib_path.exists():
411+
return str(lib_path)
412+
413+
url = get_runtime_lib_url(ver, npm_platform)
414+
data = _fetch_url_bytes(url, timeout=600)
415+
416+
integrity = _fetch_runtime_integrity(npm_platform, ver)
417+
if integrity:
418+
_verify_integrity(data, integrity)
419+
420+
lib_bytes = _extract_runtime_node(data, npm_platform)
421+
422+
# Write atomically next to the CLI so concurrent starts don't observe a partial
423+
# library. A rename within the same directory is atomic on POSIX and Windows.
424+
cli_dir.mkdir(parents=True, exist_ok=True)
425+
fd, tmp_name = tempfile.mkstemp(dir=cli_dir, prefix=".runtime-lib-")
426+
try:
427+
with os.fdopen(fd, "wb") as out:
428+
out.write(lib_bytes)
429+
os.replace(tmp_name, lib_path)
430+
except OSError:
431+
try:
432+
os.unlink(tmp_name)
433+
except OSError:
434+
pass
435+
if lib_path.exists():
436+
return str(lib_path)
437+
raise
438+
439+
return str(lib_path)
440+
441+
307442
def get_or_download_cli(version: str | None = None) -> str | None:
308443
"""Get the cached CLI binary, downloading it if necessary.
309444
@@ -361,6 +496,15 @@ def main() -> None:
361496
"--version",
362497
help="Runtime version to download (default: pinned version)",
363498
)
499+
dl_parser.add_argument(
500+
"--in-process",
501+
action="store_true",
502+
help=(
503+
"Also download the native in-process (FFI) runtime library "
504+
"(prebuilds/<platform>/runtime.node) and place it next to the CLI. "
505+
"Only needed for the experimental in-process transport."
506+
),
507+
)
364508

365509
args = parser.parse_args()
366510

@@ -378,6 +522,17 @@ def main() -> None:
378522
try:
379523
path = download_cli(ver, force=args.force)
380524
print(f"Runtime cached at: {path}")
525+
if args.in_process:
526+
print("Downloading in-process (FFI) runtime library...")
527+
lib_path = ensure_runtime_library(path, ver)
528+
if lib_path:
529+
print(f"Runtime library cached at: {lib_path}")
530+
else:
531+
print(
532+
"Warning: could not provision the in-process runtime library "
533+
"(download disabled or unsupported platform).",
534+
file=sys.stderr,
535+
)
381536
except RuntimeError as exc:
382537
print(f"Error: {exc}", file=sys.stderr)
383538
sys.exit(1)

0 commit comments

Comments
 (0)