-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Expand file tree
/
Copy path_cli_download.py
More file actions
564 lines (480 loc) · 19.9 KB
/
Copy path_cli_download.py
File metadata and controls
564 lines (480 loc) · 19.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
"""Download and cache the Copilot CLI runtime package.
The platform-specific GitHub release package contains the out-of-process runtime
wrapper, native runtime library, and runtime assets, but omits the legacy SEA
``copilot[.exe]``. Its bytes are downloaded and verified, then the filtered hostless
bundle is materialized directly into the SDK's existing cache layout. ``download_cli``
preserves the historical CLI filename by creating a compatibility alias from the
runtime wrapper inside the complete materialized bundle:
- Linux: ~/.cache/github-copilot-sdk/cli/{version}/prebuilds/{platform}/copilot
- macOS: ~/Library/Caches/github-copilot-sdk/cli/{version}/prebuilds/{platform}/copilot
- Windows: %LOCALAPPDATA%/github-copilot-sdk/cli/{version}/prebuilds/{platform}/copilot.exe
Environment variables:
- COPILOT_CLI_EXTRACT_DIR: Override the runtime bundle cache root.
- COPILOT_SKIP_CLI_DOWNLOAD: Set to "1" or "true" to disable auto-download.
- COPILOT_CLI_DOWNLOAD_BASE_URL: Override the GitHub Releases base URL.
"""
from __future__ import annotations
import hashlib
import io
import os
import re
import stat
import sys
import tarfile
import tempfile
import time
from http.client import IncompleteRead
from pathlib import Path, PurePosixPath
from urllib.error import HTTPError, URLError
from urllib.request import urlopen
from ._cli_version import (
CLI_VERSION,
get_checksums_url,
get_cli_binary_name,
get_download_url,
get_release_asset_name,
get_runtime_platform,
)
_CACHE_DIR_NAME = "github-copilot-sdk"
_MAX_RETRIES = 3
_RETRIABLE_DOWNLOAD_ERRORS = (HTTPError, URLError, IncompleteRead)
_HOSTLESS_ASSETS_MARKER = ".hostless-runtime-assets-v2"
def _sanitize_version(version: str) -> str:
"""Sanitize version string for use as a directory name.
Replaces any character not in [a-zA-Z0-9._-] with underscore.
Matches the Rust SDK's sanitization logic.
"""
return re.sub(r"[^a-zA-Z0-9._\-]", "_", version)
def get_cache_dir(version: str | None = None) -> Path:
"""Return the cache directory for runtime bundles.
Args:
version: CLI version string. If None, returns the root cache dir.
"""
# COPILOT_CLI_EXTRACT_DIR overrides the entire version-specific directory.
extract_override = os.environ.get("COPILOT_CLI_EXTRACT_DIR")
if extract_override:
return Path(extract_override)
if sys.platform == "darwin":
root = Path.home() / "Library" / "Caches" / _CACHE_DIR_NAME
elif sys.platform == "win32":
local_app_data = os.environ.get("LOCALAPPDATA")
if local_app_data:
root = Path(local_app_data) / _CACHE_DIR_NAME
else:
root = Path.home() / "AppData" / "Local" / _CACHE_DIR_NAME
else:
xdg = os.environ.get("XDG_CACHE_HOME")
if xdg:
root = Path(xdg) / _CACHE_DIR_NAME
else:
root = Path.home() / ".cache" / _CACHE_DIR_NAME
if version:
return root / "cli" / _sanitize_version(version)
return root / "cli"
def get_cached_cli_path(version: str | None = None) -> str | None:
"""Return the cached compatibility entrypoint for a complete runtime bundle.
Args:
version: CLI version. Defaults to the pinned CLI_VERSION.
Returns:
Path to the binary, or None if not cached.
"""
ver = version or CLI_VERSION
if not ver:
return None
try:
runtime_platform = get_runtime_platform()
except RuntimeError:
return None
binary_name = get_cli_binary_name()
wrapper_name = "copilot-runtime.exe" if sys.platform == "win32" else "copilot-runtime"
pair_dir = get_cache_dir(ver) / "prebuilds" / runtime_platform
binary_path = pair_dir / binary_name
required = (
binary_path,
pair_dir / wrapper_name,
pair_dir / "runtime.node",
pair_dir / _HOSTLESS_ASSETS_MARKER,
)
if all(path.is_file() and path.stat().st_size > 0 for path in required):
return str(binary_path)
return None
def _should_skip_download() -> bool:
"""Check if auto-download is disabled via environment variable."""
val = os.environ.get("COPILOT_SKIP_CLI_DOWNLOAD", "").lower()
return val in ("1", "true", "yes")
def _fetch_checksums(version: str) -> dict[str, str]:
"""Fetch and parse the SHA256SUMS.txt file.
Returns a dict mapping filename → sha256 hex digest.
"""
url = get_checksums_url(version)
try:
text = _fetch_url_bytes(url, timeout=30).decode("utf-8")
except (RuntimeError, UnicodeDecodeError) as exc:
raise RuntimeError(
f"Failed to download checksums from {url}: {exc}\n\n"
"If you are in an offline or firewalled environment, set "
"COPILOT_CLI_PATH to point to a manually-installed binary."
) from exc
checksums: dict[str, str] = {}
for line in text.strip().splitlines():
parts = line.split()
if len(parts) == 2 and re.fullmatch(r"[a-fA-F0-9]{64}", parts[0]):
digest, filename = parts
# Some formats use *filename (binary mode indicator)
checksums[filename.lstrip("*")] = digest.lower()
return checksums
def _verify_checksum(data: bytes, expected_hash: str, filename: str) -> None:
"""Verify SHA-256 checksum of downloaded data."""
actual = hashlib.sha256(data).hexdigest()
if actual != expected_hash:
raise RuntimeError(
f"Checksum mismatch for {filename}:\n expected: {expected_hash}\n actual: {actual}"
)
def _fetch_verified_release_package(version: str, runtime_platform: str) -> bytes:
"""Download and verify the unified platform release package."""
asset_name = get_release_asset_name(version, runtime_platform)
expected_hash = _fetch_checksums(version).get(asset_name)
if not expected_hash:
raise RuntimeError(f"SHA256SUMS.txt does not contain {asset_name}.")
url = get_download_url(version, asset_name)
data = _fetch_url_bytes(url, timeout=600)
_verify_checksum(data, expected_hash, asset_name)
return data
def _runtime_bundle_is_complete(pair_dir: Path, wrapper_name: str) -> bool:
required = (
pair_dir / wrapper_name,
pair_dir / "runtime.node",
pair_dir / _HOSTLESS_ASSETS_MARKER,
)
return all(path.is_file() and path.stat().st_size > 0 for path in required)
def download_cli(version: str | None = None, *, force: bool = False) -> str:
"""Provision a complete runtime bundle with a ``copilot[.exe]`` alias.
Args:
version: CLI version to download. Defaults to the pinned CLI_VERSION.
force: If True, re-download even if already cached.
Returns:
Path to the compatibility entrypoint adjacent to the complete runtime bundle.
Raises:
RuntimeError: If the version is not set, download fails, or
checksum verification fails.
"""
ver = version or CLI_VERSION
if not ver:
raise RuntimeError(
"No CLI version pinned. This is a development install — "
"set COPILOT_CLI_PATH or install a published wheel."
)
binary_name = get_cli_binary_name()
if not force:
cached = get_cached_cli_path(ver)
if cached is not None:
return cached
wrapper_path = Path(ensure_runtime_wrapper(ver, force=force))
binary_path = wrapper_path.with_name(binary_name)
fd, temp_name = tempfile.mkstemp(dir=wrapper_path.parent, prefix=".cli-")
try:
with os.fdopen(fd, "wb") as destination:
destination.write(wrapper_path.read_bytes())
staged = Path(temp_name)
if sys.platform != "win32":
staged.chmod(staged.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
os.replace(staged, binary_path)
except OSError:
try:
os.unlink(temp_name)
except OSError:
pass
if not force:
cached = get_cached_cli_path(ver)
if cached is not None:
return cached
raise
return str(binary_path)
def _fetch_url_bytes(url: str, *, timeout: int) -> bytes:
"""Download bytes from ``url`` with retries."""
last_exc: Exception | None = None
for attempt in range(_MAX_RETRIES):
try:
with urlopen(url, timeout=timeout) as response:
return response.read()
except _RETRIABLE_DOWNLOAD_ERRORS as exc:
last_exc = exc
if attempt < _MAX_RETRIES - 1:
time.sleep(2**attempt)
raise RuntimeError(f"Failed to download from {url}: {last_exc}") from last_exc
_HOSTLESS_EXCLUDED_TOP_LEVEL = {
"app.js",
"assets",
"changelog.json",
"copilot",
"copilot.exe",
"copilot-sdk",
"foundry-local-sdk",
"index.js",
"LICENSE.md",
"napi-oop-runtime",
"npm-loader.js",
"package.json",
"preloads",
"pvrecorder",
"queries",
"README.md",
"sdk",
"sea-loader.js",
"webview",
}
def _hostless_runtime_path(member_name: str, runtime_platform: str) -> Path | None:
if "\\" in member_name:
raise RuntimeError(f"Unsafe runtime package path: {member_name}")
parts = PurePosixPath(member_name).parts
if not parts or parts[0] != "package" or len(parts) < 2:
return None
relative = parts[1:]
top_level = relative[0]
file_name = relative[-1]
if (
top_level in _HOSTLESS_EXCLUDED_TOP_LEVEL
or (top_level.startswith("tree-sitter") and top_level.endswith(".wasm"))
or (top_level.startswith("voice-") and top_level.endswith(".js"))
or file_name == "cli-native.node"
or "mediaremote-adapter" in relative
or file_name.startswith("copilot-runtime-bin")
):
return None
if top_level == "prebuilds":
if len(relative) < 3 or relative[1] != runtime_platform:
return None
relative = relative[2:]
destination = Path(*relative)
if destination.is_absolute() or ".." in destination.parts:
raise RuntimeError(f"Unsafe runtime package path: {member_name}")
return destination
def _materialize_runtime_bundle(data: bytes, runtime_platform: str, destination: Path) -> None:
"""Extract the hostless runtime tree, retaining unknown package assets by default."""
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive:
for member in archive:
relative = _hostless_runtime_path(member.name, runtime_platform)
if relative is None or member.isdir():
continue
if not member.isfile():
raise RuntimeError(f"Unsupported runtime package entry: {member.name}")
extracted = archive.extractfile(member)
if extracted is None:
raise RuntimeError(f"Failed to read runtime package entry: {member.name}")
target = destination / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(extracted.read())
if sys.platform != "win32":
target.chmod(member.mode & 0o777)
def ensure_runtime_wrapper(version: str | None = None, force: bool = False) -> str:
"""Provision the runtime pair and retained assets from the release package."""
ver = version or CLI_VERSION
if not ver:
raise RuntimeError("No runtime version is pinned.")
runtime_platform = get_runtime_platform()
wrapper_name = "copilot-runtime.exe" if sys.platform == "win32" else "copilot-runtime"
pair_dir = get_cache_dir(ver) / "prebuilds" / runtime_platform
wrapper_path = pair_dir / wrapper_name
runtime_path = pair_dir / "runtime.node"
assets_marker = pair_dir / _HOSTLESS_ASSETS_MARKER
wrapper_exists = wrapper_path.is_file() and wrapper_path.stat().st_size > 0
runtime_exists = runtime_path.is_file() and runtime_path.stat().st_size > 0
if _runtime_bundle_is_complete(pair_dir, wrapper_name) and not force:
return str(wrapper_path)
if not force and wrapper_exists != runtime_exists:
raise RuntimeError(
f"Incomplete Copilot runtime bundle in {pair_dir}: "
f"{wrapper_name} and runtime.node are required."
)
if _should_skip_download():
raise RuntimeError(
f"Copilot runtime bundle is not cached in {pair_dir} "
"and automatic downloads are disabled."
)
data = _fetch_verified_release_package(ver, runtime_platform)
import shutil
pair_dir.parent.mkdir(parents=True, exist_ok=True)
staging_dir = Path(tempfile.mkdtemp(dir=pair_dir.parent, prefix=".runtime-bundle-"))
try:
_materialize_runtime_bundle(data, runtime_platform, staging_dir)
staged_wrapper = staging_dir / wrapper_name
staged_runtime = staging_dir / "runtime.node"
if (
not staged_wrapper.is_file()
or staged_wrapper.stat().st_size == 0
or not staged_runtime.is_file()
or staged_runtime.stat().st_size == 0
):
raise RuntimeError("Copilot runtime wrapper and runtime.node must both be non-empty.")
if sys.platform != "win32":
staged_wrapper.chmod(
staged_wrapper.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
)
(staging_dir / assets_marker.name).write_text("1\n", encoding="ascii")
try:
if pair_dir.exists() and (force or not assets_marker.is_file()):
shutil.rmtree(pair_dir, ignore_errors=True)
staging_dir.replace(pair_dir)
except OSError:
if (
wrapper_path.is_file()
and wrapper_path.stat().st_size > 0
and runtime_path.is_file()
and runtime_path.stat().st_size > 0
and assets_marker.is_file()
):
return str(wrapper_path)
raise
finally:
if staging_dir.exists():
shutil.rmtree(staging_dir, ignore_errors=True)
return str(wrapper_path)
def ensure_runtime_library(cli_path: str, version: str | None = None) -> str | None:
"""Ensure the native in-process (FFI) runtime library sits next to ``cli_path``.
The canonical staged bundle contains ``prebuilds/<platform>/runtime.node``.
This helper reuses that verified library and copies it next to the CLI binary
under its natural platform name (``libcopilot_runtime.so`` / ``.dylib`` /
``copilot_runtime.dll``).
Copying the library next to an external CLI is opt-in — this is only invoked when
the in-process transport is selected (lazy) or via
``python -m copilot download-runtime --in-process`` (explicit). The default stdio
path leaves the library in the canonical staged bundle.
Returns the absolute path to the library, or None if it could not be provisioned
(e.g. download disabled or unsupported platform). Raises RuntimeError on
download/verification failure.
"""
# Import lazily to avoid a hard dependency for stdio-only users.
from ._ffi_runtime_host import _natural_library_name, resolve_library_path
# Already present (bundled prebuilds layout in dev, or a prior download)?
existing = resolve_library_path(cli_path)
if existing is not None:
return existing
ver = version or CLI_VERSION
if not ver:
return None
try:
runtime_platform = get_runtime_platform()
except RuntimeError:
return None
cli_dir = Path(cli_path).resolve().parent
lib_path = cli_dir / _natural_library_name()
if lib_path.exists():
return str(lib_path)
pair_dir = get_cache_dir(ver) / "prebuilds" / runtime_platform
wrapper_name = "copilot-runtime.exe" if sys.platform == "win32" else "copilot-runtime"
if _should_skip_download() and not _runtime_bundle_is_complete(pair_dir, wrapper_name):
return None
wrapper_path = Path(ensure_runtime_wrapper(ver))
canonical_runtime = wrapper_path.with_name("runtime.node")
# Write atomically next to the CLI so concurrent starts don't observe a partial
# library. A rename within the same directory is atomic on POSIX and Windows.
import shutil
cli_dir.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(dir=cli_dir, prefix=".runtime-lib-")
try:
with os.fdopen(fd, "wb") as out, canonical_runtime.open("rb") as source:
shutil.copyfileobj(source, out)
os.replace(tmp_name, lib_path)
except OSError:
try:
os.unlink(tmp_name)
except OSError:
# Best-effort cleanup of the temp file; ignore if it's already gone or
# can't be removed (the OS reclaims it, and it doesn't affect correctness).
pass
if lib_path.exists():
return str(lib_path)
raise
return str(lib_path)
def get_or_download_cli(version: str | None = None) -> str | None:
"""Get the cached CLI binary, downloading it if necessary.
Returns None if:
- No version is pinned (dev install)
- Auto-download is disabled via COPILOT_SKIP_CLI_DOWNLOAD
- The platform is unsupported
Raises RuntimeError on download/verification failures.
"""
ver = version or CLI_VERSION
if not ver:
return None
# Check cache first
cached = get_cached_cli_path(ver)
if cached:
return cached
# Check platform support before attempting download
try:
runtime_platform = get_runtime_platform()
except RuntimeError:
return None
if _should_skip_download():
pair_dir = get_cache_dir(ver) / "prebuilds" / runtime_platform
wrapper_name = "copilot-runtime.exe" if sys.platform == "win32" else "copilot-runtime"
if not _runtime_bundle_is_complete(pair_dir, wrapper_name):
return None
# Download
return download_cli(ver)
def main() -> None:
"""CLI entry point for `python -m copilot download-runtime`."""
import argparse
parser = argparse.ArgumentParser(
prog="python -m copilot",
description="Copilot SDK utilities",
)
subparsers = parser.add_subparsers(dest="command")
# download-runtime subcommand
dl_parser = subparsers.add_parser(
"download-runtime",
help="Download the Copilot runtime",
)
dl_parser.add_argument(
"--force",
action="store_true",
help="Re-download even if already cached",
)
dl_parser.add_argument(
"--version",
help="Runtime version to download (default: pinned version)",
)
dl_parser.add_argument(
"--in-process",
action="store_true",
help=(
"Also download the native in-process (FFI) runtime library "
"(prebuilds/<platform>/runtime.node) and place it next to the CLI. "
"Only needed for the experimental in-process transport."
),
)
args = parser.parse_args()
if args.command == "download-runtime":
ver = args.version or CLI_VERSION
if not ver:
print(
"Error: No runtime version pinned (development install). "
"Use --version to specify a version.",
file=sys.stderr,
)
sys.exit(1)
print(f"Downloading Copilot runtime v{ver}...")
try:
if args.in_process:
path = download_cli(ver, force=args.force)
else:
path = ensure_runtime_wrapper(ver, force=args.force)
print(f"Runtime cached at: {path}")
if args.in_process:
print("Downloading in-process (FFI) runtime library...")
lib_path = ensure_runtime_library(path, ver)
if lib_path:
print(f"Runtime library cached at: {lib_path}")
else:
print(
"Warning: could not provision the in-process runtime library "
"(download disabled or unsupported platform).",
file=sys.stderr,
)
except RuntimeError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
else:
parser.print_help()
sys.exit(1)