diff --git a/docs/setup/bundled-cli.md b/docs/setup/bundled-cli.md
index 9d9c80c35b..ad040ecfc4 100644
--- a/docs/setup/bundled-cli.md
+++ b/docs/setup/bundled-cli.md
@@ -82,7 +82,7 @@ await client.stop()
Go
> [!NOTE]
-> Unlike Node.js, Python, and .NET, the Go SDK does not include a CLI as an automatic dependency. With no explicit path, `NewClient(nil)` uses an embedded CLI when available, then falls back to `copilot` on `PATH`. To embed a CLI, run the [bundler tool](../../go/README.md#distributing-your-application-with-an-embedded-github-copilot-cli) at build time. You can also set `COPILOT_CLI_PATH` or point a `Connection` at an existing binary. See [Local CLI Setup](./local-cli.md) for details.
+> Unlike Node.js, Python, and .NET, the Go SDK does not include a CLI as an automatic dependency. `NewClient(nil)` uses `COPILOT_CLI_PATH` when set, then an embedded CLI when available; it does not scan `PATH`. To embed a CLI, run the [bundler tool](../../go/README.md#distributing-your-application-with-an-embedded-github-copilot-cli) at build time. You can also point a `Connection` at an existing binary. See [Local CLI Setup](./local-cli.md) for details.
```go
diff --git a/docs/setup/local-cli.md b/docs/setup/local-cli.md
index 79a656396e..e62ef8bef2 100644
--- a/docs/setup/local-cli.md
+++ b/docs/setup/local-cli.md
@@ -78,7 +78,7 @@ await client.stop()
Go
> [!NOTE]
-> The Go SDK does not ship a CLI automatically. Install `copilot` on `PATH`, set the `COPILOT_CLI_PATH` environment variable, embed a CLI with the [bundler tool](../../go/README.md#distributing-your-application-with-an-embedded-github-copilot-cli), or point `StdioConnection.Path` at an installed binary.
+> The Go SDK does not ship a CLI automatically or scan `PATH`. Set the `COPILOT_CLI_PATH` environment variable, embed a CLI with the [bundler tool](../../go/README.md#distributing-your-application-with-an-embedded-github-copilot-cli), or point `StdioConnection.Path` at an installed binary.
```go
diff --git a/go/README.md b/go/README.md
index 6c85f93ae1..350f73c371 100644
--- a/go/README.md
+++ b/go/README.md
@@ -7,7 +7,8 @@ A Go SDK for programmatic access to the GitHub Copilot CLI.
To use the SDK, you'll need:
- Go 1.24 or later
-- GitHub Copilot CLI installed and in `PATH` (or set `COPILOT_CLI_PATH`)
+- A compatible Copilot runtime provided through `COPILOT_CLI_PATH` or embedded
+ with the bundler described below
## Installation
@@ -108,6 +109,32 @@ That's it! When your application calls `copilot.NewClient` without a `Connection
The bundler prepares the native runtime library required by the [in-process transport](#in-process-transport-experimental). It is included in the application only when building with the `copilot_inprocess` build tag.
+Downstream modules can prepare a compatible CLI for integration tests without
+generating embedded assets or invoking npm. Call `testcli.Setup` from the test
+package's `TestMain`:
+
+```go
+import (
+ "log"
+ "os"
+ "testing"
+
+ "github.com/github/copilot-sdk/go/testcli"
+)
+
+func TestMain(m *testing.M) {
+ if err := testcli.Setup(); err != nil {
+ log.Fatal(err)
+ }
+ os.Exit(m.Run())
+}
+```
+
+This test-only helper resolves the platform package pinned by the SDK version,
+downloads it directly with Go, verifies its lockfile SHA-512 integrity, caches
+the extracted runtime, and sets `COPILOT_CLI_PATH`. An existing
+`COPILOT_CLI_PATH` is honored.
+
## In-process transport (Experimental)
> **Experimental:** the in-process API may change in a future release.
diff --git a/go/cmd/bundler/main.go b/go/cmd/bundler/main.go
index 89f99daf1b..6cafd55383 100644
--- a/go/cmd/bundler/main.go
+++ b/go/cmd/bundler/main.go
@@ -31,6 +31,7 @@ import (
"runtime"
"strings"
+ "github.com/github/copilot-sdk/go/internal/npmregistry"
"github.com/klauspost/compress/zstd"
)
@@ -38,7 +39,6 @@ const (
// Keep these URLs centralized so reviewers can verify all outbound calls in one place.
sdkModule = "github.com/github/copilot-sdk/go"
packageLockURLFmt = "https://raw.githubusercontent.com/github/copilot-sdk/%s/nodejs/package-lock.json"
- tarballURLFmt = "https://registry.npmjs.org/@github/copilot-%s/-/copilot-%s-%s.tgz"
licenseTarballFmt = "https://registry.npmjs.org/@github/copilot/-/copilot-%s.tgz"
defaultPackageName = "main"
)
@@ -430,11 +430,10 @@ func buildBundle(info platformInfo, cliVersion, outputPath, goos string) (bundle
}
rawLibPath := filepath.Join(tempDir, "runtime.node")
- if err := extractFileFromTarball(
+ if err := npmregistry.ExtractFile(
tarballPath,
- tempDir,
"package/prebuilds/"+info.npmPlatform+"/runtime.node",
- "runtime.node",
+ rawLibPath,
); err != nil {
return bundleArtifacts{}, fmt.Errorf("runtime package is missing prebuilds/%s/runtime.node: %w", info.npmPlatform, err)
}
@@ -448,11 +447,10 @@ func buildBundle(info platformInfo, cliVersion, outputPath, goos string) (bundle
wrapperName := runtimeWrapperName(info.binaryName)
rawWrapperPath := filepath.Join(tempDir, wrapperName)
- if err := extractFileFromTarball(
+ if err := npmregistry.ExtractFile(
tarballPath,
- tempDir,
"package/prebuilds/"+info.npmPlatform+"/"+wrapperName,
- wrapperName,
+ rawWrapperPath,
); err != nil {
return bundleArtifacts{}, fmt.Errorf("runtime package is missing prebuilds/%s/%s: %w", info.npmPlatform, wrapperName, err)
}
@@ -896,19 +894,12 @@ func mustDecodeBase64(s string) []byte {
// returns the extracted binary path and the downloaded tarball path (retained so
// callers can extract additional files, such as the runtime library).
func downloadCLIBinary(npmPlatform, binaryName, cliVersion, destDir string) (string, string, error) {
- tarballURL := fmt.Sprintf(tarballURLFmt, npmPlatform, npmPlatform, cliVersion)
-
- fmt.Printf("Downloading from %s...\n", tarballURL)
-
- resp, err := http.Get(tarballURL)
+ tarballURL, err := npmregistry.TarballURL("@github/copilot-"+npmPlatform, cliVersion)
if err != nil {
- return "", "", fmt.Errorf("failed to download: %w", err)
+ return "", "", err
}
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return "", "", fmt.Errorf("failed to download: %s", resp.Status)
- }
+ fmt.Printf("Downloading from %s...\n", tarballURL)
// Save tarball to temp file
tarballPath := filepath.Join(destDir, fmt.Sprintf("copilot-%s-%s.tgz", npmPlatform, cliVersion))
@@ -917,9 +908,9 @@ func downloadCLIBinary(npmPlatform, binaryName, cliVersion, destDir string) (str
return "", "", fmt.Errorf("failed to create tarball file: %w", err)
}
- if _, err := io.Copy(tarballFile, resp.Body); err != nil {
+ if err := npmregistry.Download(http.DefaultClient, tarballURL, "", tarballFile); err != nil {
tarballFile.Close()
- return "", "", fmt.Errorf("failed to save tarball: %w", err)
+ return "", "", fmt.Errorf("failed to download: %w", err)
}
if err := tarballFile.Close(); err != nil {
return "", "", fmt.Errorf("failed to close tarball file: %w", err)
@@ -927,7 +918,7 @@ func downloadCLIBinary(npmPlatform, binaryName, cliVersion, destDir string) (str
// Extract only the CLI binary to avoid unpacking the full package tree.
binaryPath := filepath.Join(destDir, binaryName)
- if err := extractFileFromTarball(tarballPath, destDir, "package/"+binaryName, binaryName); err != nil {
+ if err := npmregistry.ExtractFile(tarballPath, "package/"+binaryName, binaryPath); err != nil {
return "", "", fmt.Errorf("failed to extract binary: %w", err)
}
@@ -1033,69 +1024,6 @@ func extractFileFromTarballStream(r io.Reader, destDir, outputName string, mode
return outFile.Close()
}
-// extractFileFromTarball extracts a single file from a .tgz into destDir with a new name.
-func extractFileFromTarball(tarballPath, destDir, targetPath, outputName string) error {
- file, err := os.Open(tarballPath)
- if err != nil {
- return err
- }
- defer file.Close()
-
- gzReader, err := gzip.NewReader(file)
- if err != nil {
- return fmt.Errorf("failed to create gzip reader: %w", err)
- }
- defer gzReader.Close()
-
- tarReader := tar.NewReader(gzReader)
-
- for {
- header, err := tarReader.Next()
- if err == io.EOF {
- break
- }
- if err != nil {
- return fmt.Errorf("failed to read tar: %w", err)
- }
-
- if header.Name == targetPath {
- outPath := filepath.Join(destDir, outputName)
- outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
- if err != nil {
- return fmt.Errorf("failed to create output file: %w", err)
- }
-
- if _, err := io.Copy(outFile, tarReader); err != nil {
- if cerr := outFile.Close(); cerr != nil {
- return fmt.Errorf("failed to extract binary (copy error: %v, close error: %v)", err, cerr)
- }
- return fmt.Errorf("failed to extract binary: %w", err)
- }
- if err := outFile.Close(); err != nil {
- return fmt.Errorf("failed to close output file: %w", err)
- }
- return nil
- }
- }
-
- return fmt.Errorf("file %q not found in tarball", targetPath)
-}
-
-// extractOptionalFileFromTarball extracts a single file from a .tgz into destDir
-// like extractFileFromTarball, but returns (false, nil) instead of an error when
-// the file is absent. Used for the runtime library, which older CLI packages do
-// not ship.
-func extractOptionalFileFromTarball(tarballPath, destDir, targetPath, outputName string) (bool, error) {
- err := extractFileFromTarball(tarballPath, destDir, targetPath, outputName)
- if err == nil {
- return true, nil
- }
- if strings.Contains(err.Error(), "not found in tarball") {
- return false, nil
- }
- return false, err
-}
-
// compressZstdFile compresses src into dst using zstd.
func compressZstdFile(src, dst string) error {
srcFile, err := os.Open(src)
diff --git a/go/internal/npmregistry/package.go b/go/internal/npmregistry/package.go
new file mode 100644
index 0000000000..c9317f33d7
--- /dev/null
+++ b/go/internal/npmregistry/package.go
@@ -0,0 +1,203 @@
+package npmregistry
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "crypto/sha512"
+ "encoding/base64"
+ "fmt"
+ "hash"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+const registryURL = "https://registry.npmjs.org"
+
+// TarballURL returns the registry tarball URL for an npm package version.
+func TarballURL(packageName, version string) (string, error) {
+ if packageName == "" || version == "" {
+ return "", fmt.Errorf("npm package name and version are required")
+ }
+ baseName := packageName
+ if scope, name, scoped := strings.Cut(packageName, "/"); scoped {
+ if !strings.HasPrefix(scope, "@") || len(scope) == 1 || name == "" || strings.Contains(name, "/") {
+ return "", fmt.Errorf("invalid npm package name %q", packageName)
+ }
+ baseName = name
+ } else if strings.ContainsAny(packageName, `\`) {
+ return "", fmt.Errorf("invalid npm package name %q", packageName)
+ }
+ return fmt.Sprintf(
+ "%s/%s/-/%s-%s.tgz",
+ registryURL,
+ escapePackagePath(packageName),
+ url.PathEscape(baseName),
+ url.PathEscape(version),
+ ), nil
+}
+
+func escapePackagePath(packageName string) string {
+ parts := strings.Split(packageName, "/")
+ for index := range parts {
+ parts[index] = url.PathEscape(parts[index])
+ }
+ return strings.Join(parts, "/")
+}
+
+// Download writes a registry tarball to destination and verifies an optional
+// npm SHA-512 integrity value.
+func Download(client *http.Client, tarballURL, integrity string, destination io.Writer) error {
+ if client == nil {
+ client = http.DefaultClient
+ }
+ response, err := client.Get(tarballURL)
+ if err != nil {
+ return fmt.Errorf("downloading %s: %w", tarballURL, err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ return fmt.Errorf("downloading %s: %s", tarballURL, response.Status)
+ }
+
+ writer := destination
+ var expectedHash []byte
+ var digest hash.Hash
+ if integrity != "" {
+ expectedHash, err = sha512Integrity(integrity)
+ if err != nil {
+ return err
+ }
+ digest = sha512.New()
+ writer = io.MultiWriter(destination, digest)
+ }
+ if _, err := io.Copy(writer, response.Body); err != nil {
+ return fmt.Errorf("saving %s: %w", tarballURL, err)
+ }
+ if digest != nil && !bytes.Equal(digest.Sum(nil), expectedHash) {
+ return fmt.Errorf("integrity check failed for %s", tarballURL)
+ }
+ return nil
+}
+
+func sha512Integrity(integrity string) ([]byte, error) {
+ for candidate := range strings.FieldsSeq(integrity) {
+ encoded, ok := strings.CutPrefix(candidate, "sha512-")
+ if !ok {
+ continue
+ }
+ hash, err := base64.StdEncoding.DecodeString(encoded)
+ if err == nil && len(hash) == sha512.Size {
+ return hash, nil
+ }
+ }
+ return nil, fmt.Errorf("invalid sha512 npm integrity %q", integrity)
+}
+
+// ExtractPackage safely extracts package/ entries from an npm tarball.
+func ExtractPackage(tarballPath, destination string) error {
+ return visitTarball(tarballPath, func(header *tar.Header, reader io.Reader) (bool, error) {
+ if !strings.HasPrefix(header.Name, "package/") {
+ return false, nil
+ }
+ relativePath := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(header.Name, "package/")))
+ if relativePath == "." {
+ return false, nil
+ }
+ if !filepath.IsLocal(relativePath) {
+ return false, fmt.Errorf("unsafe archive path %q", header.Name)
+ }
+ path := filepath.Join(destination, relativePath)
+ switch header.Typeflag {
+ case tar.TypeDir:
+ return false, os.MkdirAll(path, 0755)
+ case tar.TypeReg:
+ return false, writeFile(path, reader, os.FileMode(header.Mode))
+ case tar.TypeSymlink, tar.TypeLink:
+ return false, fmt.Errorf("unsafe archive link %q", header.Name)
+ default:
+ return false, nil
+ }
+ })
+}
+
+// ExtractFile extracts one regular file from a tarball to destination.
+func ExtractFile(tarballPath, targetPath, destination string) error {
+ found := false
+ err := visitTarball(tarballPath, func(header *tar.Header, reader io.Reader) (bool, error) {
+ if header.Name != targetPath {
+ return false, nil
+ }
+ if header.Typeflag != tar.TypeReg {
+ return false, fmt.Errorf("archive entry %q is not a regular file", targetPath)
+ }
+ found = true
+ return true, writeFile(destination, reader, os.FileMode(header.Mode))
+ })
+ if err != nil {
+ return err
+ }
+ if !found {
+ return fmt.Errorf("file %q not found in tarball", targetPath)
+ }
+ return nil
+}
+
+func visitTarball(tarballPath string, visit func(*tar.Header, io.Reader) (bool, error)) error {
+ file, err := os.Open(tarballPath)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ gzipReader, err := gzip.NewReader(file)
+ if err != nil {
+ return fmt.Errorf("creating gzip reader: %w", err)
+ }
+ defer gzipReader.Close()
+ tarReader := tar.NewReader(gzipReader)
+ for {
+ header, err := tarReader.Next()
+ if err == io.EOF {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("reading tarball: %w", err)
+ }
+ done, err := visit(header, tarReader)
+ if err != nil {
+ return err
+ }
+ if done {
+ return nil
+ }
+ }
+}
+
+func writeFile(path string, reader io.Reader, mode os.FileMode) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ return err
+ }
+ mode &= 0777
+ if mode == 0 {
+ mode = 0644
+ }
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
+ if err != nil {
+ return err
+ }
+ _, copyErr := io.Copy(file, reader)
+ closeErr := file.Close()
+ if copyErr != nil {
+ os.Remove(path)
+ return copyErr
+ }
+ if closeErr != nil {
+ os.Remove(path)
+ return closeErr
+ }
+ return nil
+}
diff --git a/go/internal/npmregistry/package_test.go b/go/internal/npmregistry/package_test.go
new file mode 100644
index 0000000000..ab497a5ae6
--- /dev/null
+++ b/go/internal/npmregistry/package_test.go
@@ -0,0 +1,139 @@
+package npmregistry
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "crypto/sha512"
+ "encoding/base64"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestTarballURL(t *testing.T) {
+ got, err := TarballURL("@github/copilot-win32-x64", "1.2.3-0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.2.3-0.tgz"
+ if got != want {
+ t.Fatalf("TarballURL() = %q, want %q", got, want)
+ }
+}
+
+func TestDownloadVerifiesIntegrity(t *testing.T) {
+ content := []byte("package archive")
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ writer.Write(content)
+ }))
+ defer server.Close()
+ hash := sha512.Sum512(content)
+ integrity := "sha512-" + base64.StdEncoding.EncodeToString(hash[:])
+
+ var destination bytes.Buffer
+ if err := Download(server.Client(), server.URL, integrity, &destination); err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(destination.Bytes(), content) {
+ t.Fatalf("Download() wrote %q", destination.Bytes())
+ }
+
+ wrongHash := sha512.Sum512([]byte("different"))
+ err := Download(server.Client(), server.URL, "sha512-"+base64.StdEncoding.EncodeToString(wrongHash[:]), &bytes.Buffer{})
+ if err == nil || !strings.Contains(err.Error(), "integrity check failed") {
+ t.Fatalf("Download() error = %v", err)
+ }
+}
+
+func TestExtractPackage(t *testing.T) {
+ archivePath := writeTestTarball(t, map[string]testTarEntry{
+ "package/package.json": {content: "{}"},
+ "package/prebuilds/linux-x64/runtime.node": {content: "runtime"},
+ "outside": {content: "ignored"},
+ })
+ destination := t.TempDir()
+ if err := ExtractPackage(archivePath, destination); err != nil {
+ t.Fatal(err)
+ }
+ content, err := os.ReadFile(filepath.Join(destination, "prebuilds", "linux-x64", "runtime.node"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(content) != "runtime" {
+ t.Fatalf("extracted runtime = %q", content)
+ }
+ if _, err := os.Stat(filepath.Join(destination, "outside")); !os.IsNotExist(err) {
+ t.Fatalf("outside entry was extracted: %v", err)
+ }
+}
+
+func TestExtractPackageRejectsUnsafeEntries(t *testing.T) {
+ for name, entry := range map[string]testTarEntry{
+ "path traversal": {name: "package/../../outside", content: "unsafe"},
+ "link": {name: "package/link", entryType: tar.TypeSymlink, linkName: "../outside"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ archivePath := writeTestTarball(t, map[string]testTarEntry{"entry": entry})
+ if err := ExtractPackage(archivePath, t.TempDir()); err == nil {
+ t.Fatal("ExtractPackage() accepted an unsafe entry")
+ }
+ })
+ }
+}
+
+type testTarEntry struct {
+ name string
+ content string
+ entryType byte
+ linkName string
+}
+
+func writeTestTarball(t *testing.T, entries map[string]testTarEntry) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "package.tgz")
+ file, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ gzipWriter := gzip.NewWriter(file)
+ tarWriter := tar.NewWriter(gzipWriter)
+ for defaultName, entry := range entries {
+ name := entry.name
+ if name == "" {
+ name = defaultName
+ }
+ entryType := entry.entryType
+ if entryType == 0 {
+ entryType = tar.TypeReg
+ }
+ header := &tar.Header{
+ Name: name,
+ Mode: 0644,
+ Size: int64(len(entry.content)),
+ Typeflag: entryType,
+ Linkname: entry.linkName,
+ }
+ if err := tarWriter.WriteHeader(header); err != nil {
+ t.Fatal(err)
+ }
+ if entry.content != "" {
+ if _, err := tarWriter.Write([]byte(entry.content)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ if err := tarWriter.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := gzipWriter.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := file.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
diff --git a/go/testcli/setup.go b/go/testcli/setup.go
new file mode 100644
index 0000000000..eb95512ee9
--- /dev/null
+++ b/go/testcli/setup.go
@@ -0,0 +1,295 @@
+// Package testcli installs a compatible Copilot CLI for integration tests.
+package testcli
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/github/copilot-sdk/go/internal/ffihost"
+ "github.com/github/copilot-sdk/go/internal/flock"
+ "github.com/github/copilot-sdk/go/internal/npmregistry"
+)
+
+const (
+ testSDKModule = "github.com/github/copilot-sdk/go"
+ testPackageLockURLFmt = "https://raw.githubusercontent.com/github/copilot-sdk/%s/nodejs/package-lock.json"
+)
+
+// Setup installs the Copilot runtime package compatible with this SDK into the
+// user cache and sets COPILOT_CLI_PATH. It returns any setup failure and panics
+// when called outside a test binary. Downstream test packages should call Setup
+// from TestMain before running their tests.
+func Setup() error {
+ if !testing.Testing() {
+ panic("testcli.Setup may only be called from a test binary")
+ }
+ return setup()
+}
+
+// Test hooks for overriding default behavior in tests.
+var (
+ testSetupHTTPClient = &http.Client{Timeout: 10 * time.Minute}
+ testUserCacheDir = os.UserCacheDir
+ testListSDKModule = func() ([]byte, error) {
+ return exec.Command("go", "list", "-m", "-json", testSDKModule).CombinedOutput()
+ }
+ testRuntimeTarballURL = npmregistry.TarballURL
+)
+
+func setup() error {
+ // An explicitly provisioned runtime takes precedence and avoids all discovery
+ // and download work.
+ if configured := os.Getenv("COPILOT_CLI_PATH"); configured != "" {
+ if err := validateTestCLIPath(configured); err != nil {
+ return fmt.Errorf("COPILOT_CLI_PATH %w", err)
+ }
+ return nil
+ }
+
+ // Platform names match the suffixes of the optional Copilot npm packages.
+ platform := ffihost.PrebuildsFolder()
+ if platform == "" {
+ return fmt.Errorf("unsupported Copilot runtime platform %s/%s", runtime.GOOS, runtime.GOARCH)
+ }
+ cacheDir, err := testUserCacheDir()
+ if err != nil {
+ return fmt.Errorf("locating user cache directory: %w", err)
+ }
+ metadata, err := compatibleTestRuntimeMetadata(platform)
+ if err != nil {
+ return err
+ }
+ // Cache each exact platform package independently so SDK versions can coexist.
+ installDir := filepath.Join(cacheDir, "copilot-sdk", "test-runtime", metadata.version, platform)
+ if cliPath, ok := installedTestRuntimePath(installDir, platform, metadata); ok {
+ return os.Setenv("COPILOT_CLI_PATH", cliPath)
+ }
+ if err := os.MkdirAll(filepath.Dir(installDir), 0755); err != nil {
+ return fmt.Errorf("creating Copilot test runtime cache: %w", err)
+ }
+ // Multiple test binaries may start concurrently and target the same cache entry.
+ release, err := flock.Acquire(installDir + ".lock")
+ if err != nil {
+ return fmt.Errorf("locking Copilot test runtime cache: %w", err)
+ }
+ defer release()
+ // Another process may have completed the installation while this process waited.
+ if cliPath, ok := installedTestRuntimePath(installDir, platform, metadata); ok {
+ return os.Setenv("COPILOT_CLI_PATH", cliPath)
+ }
+ if err := os.RemoveAll(installDir); err != nil {
+ return fmt.Errorf("removing stale Copilot test runtime: %w", err)
+ }
+ // Build the runtime in a sibling directory so Rename publishes only a complete
+ // installation to readers that do not hold the lock.
+ stagingDir, err := os.MkdirTemp(filepath.Dir(installDir), "."+platform+"-*")
+ if err != nil {
+ return fmt.Errorf("creating Copilot test runtime staging directory: %w", err)
+ }
+ defer os.RemoveAll(stagingDir)
+ archivePath, err := downloadTestRuntime(filepath.Dir(installDir), platform, metadata)
+ if err != nil {
+ return err
+ }
+ defer os.Remove(archivePath)
+ if err := npmregistry.ExtractPackage(archivePath, stagingDir); err != nil {
+ return fmt.Errorf("extracting @github/copilot-%s@%s: %w", platform, metadata.version, err)
+ }
+ stagingCLIPath := filepath.Join(stagingDir, testCLIBinaryName())
+ if runtime.GOOS != "windows" {
+ if err := os.Chmod(stagingCLIPath, 0755); err != nil {
+ return fmt.Errorf("making Copilot test runtime executable: %w", err)
+ }
+ }
+ // package.json proves the version, while this marker binds the cache entry to
+ // the exact archive integrity pinned by the SDK lockfile.
+ if err := os.WriteFile(filepath.Join(stagingDir, ".integrity"), []byte(metadata.integrity+"\n"), 0644); err != nil {
+ return fmt.Errorf("writing Copilot test runtime integrity: %w", err)
+ }
+ if _, ok := installedTestRuntimePath(stagingDir, platform, metadata); !ok {
+ return fmt.Errorf("download did not contain a complete @github/copilot-%s@%s package", platform, metadata.version)
+ }
+ if err := os.Rename(stagingDir, installDir); err != nil {
+ return fmt.Errorf("installing Copilot test runtime: %w", err)
+ }
+ cliPath := filepath.Join(installDir, testCLIBinaryName())
+ if err := os.Setenv("COPILOT_CLI_PATH", cliPath); err != nil {
+ return fmt.Errorf("configuring Copilot test runtime: %w", err)
+ }
+ return nil
+}
+
+type testModuleInfo struct {
+ Version string `json:"Version"`
+ Dir string `json:"Dir"`
+ Replace *testModuleInfo `json:"Replace"`
+}
+
+type testRuntimeMetadata struct {
+ version string
+ integrity string
+}
+
+func compatibleTestRuntimeMetadata(platform string) (testRuntimeMetadata, error) {
+ // Query the effective module so local replace directives are reflected in both
+ // the source directory and version used below.
+ output, err := testListSDKModule()
+ if err != nil {
+ return testRuntimeMetadata{}, fmt.Errorf("locating %s: %w: %s", testSDKModule, err, strings.TrimSpace(string(output)))
+ }
+ var module testModuleInfo
+ if err := json.Unmarshal(output, &module); err != nil {
+ return testRuntimeMetadata{}, fmt.Errorf("parsing Go module information: %w", err)
+ }
+ // A source checkout or local replacement has the monorepo's Node lockfile next
+ // to the Go module. Prefer it so local SDK changes use their matching runtime.
+ moduleDir := module.Dir
+ if module.Replace != nil && module.Replace.Dir != "" {
+ moduleDir = module.Replace.Dir
+ }
+ if moduleDir != "" {
+ lockPath := filepath.Join(filepath.Dir(moduleDir), "nodejs", "package-lock.json")
+ if lockFile, err := os.Open(lockPath); err == nil {
+ defer lockFile.Close()
+ return parseTestRuntimeMetadata(lockFile, platform)
+ }
+ }
+
+ // Published Go module archives do not contain the sibling Node project, so use
+ // the SDK version to read the same lockfile from the repository.
+ version := module.Version
+ if module.Replace != nil && module.Replace.Version != "" {
+ version = module.Replace.Version
+ }
+ if version == "" {
+ return testRuntimeMetadata{}, fmt.Errorf("could not resolve a published version for %s", testSDKModule)
+ }
+ // A pseudo-version ends in the source commit hash, which is the exact Git ref
+ // corresponding to that module build.
+ gitRef := version
+ if index := strings.LastIndexByte(version, '-'); index >= 0 {
+ suffix := version[index+1:]
+ if len(suffix) == 12 && testIsHex(suffix) {
+ gitRef = suffix
+ }
+ }
+ response, err := testSetupHTTPClient.Get(fmt.Sprintf(testPackageLockURLFmt, gitRef))
+ if err != nil {
+ return testRuntimeMetadata{}, fmt.Errorf("fetching compatible Copilot CLI version: %w", err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ return testRuntimeMetadata{}, fmt.Errorf("fetching compatible Copilot CLI version: %s", response.Status)
+ }
+ return parseTestRuntimeMetadata(response.Body, platform)
+}
+
+func parseTestRuntimeMetadata(reader io.Reader, platform string) (testRuntimeMetadata, error) {
+ var packageLock struct {
+ Packages map[string]struct {
+ Version string `json:"version"`
+ Integrity string `json:"integrity"`
+ } `json:"packages"`
+ }
+ if err := json.NewDecoder(reader).Decode(&packageLock); err != nil {
+ return testRuntimeMetadata{}, fmt.Errorf("parsing package-lock.json: %w", err)
+ }
+ // The platform package entry supplies both the downloadable version and npm's
+ // SHA-512 SRI value used to authenticate the archive.
+ packageName := "node_modules/@github/copilot-" + platform
+ entry := packageLock.Packages[packageName]
+ if entry.Version == "" || entry.Integrity == "" {
+ return testRuntimeMetadata{}, fmt.Errorf("%s is not pinned with integrity in package-lock.json", packageName)
+ }
+ return testRuntimeMetadata{version: entry.Version, integrity: entry.Integrity}, nil
+}
+
+func installedTestRuntimePath(packageDir, platform string, expected testRuntimeMetadata) (string, bool) {
+ packageJSON, err := os.Open(filepath.Join(packageDir, "package.json"))
+ if err != nil {
+ return "", false
+ }
+ defer packageJSON.Close()
+ var metadata struct {
+ Version string `json:"version"`
+ }
+ if json.NewDecoder(packageJSON).Decode(&metadata) != nil || metadata.Version != expected.version {
+ return "", false
+ }
+ integrity, err := os.ReadFile(filepath.Join(packageDir, ".integrity"))
+ if err != nil || strings.TrimSpace(string(integrity)) != expected.integrity {
+ return "", false
+ }
+ cliPath := filepath.Join(packageDir, testCLIBinaryName())
+ if validateTestCLIPath(cliPath) != nil || !testRegularFile(filepath.Join(packageDir, "prebuilds", platform, "runtime.node")) {
+ return "", false
+ }
+ return cliPath, true
+}
+
+func validateTestCLIPath(path string) error {
+ info, err := os.Stat(path)
+ if err != nil || !info.Mode().IsRegular() {
+ return fmt.Errorf("%q is not a file", path)
+ }
+ // JavaScript entrypoints are passed to Node.js; native entrypoints must be
+ // directly executable on Unix.
+ if runtime.GOOS != "windows" && !strings.HasSuffix(path, ".js") && info.Mode().Perm()&0111 == 0 {
+ return fmt.Errorf("%q is not executable", path)
+ }
+ return nil
+}
+
+func testCLIBinaryName() string {
+ if runtime.GOOS == "windows" {
+ return "copilot.exe"
+ }
+ return "copilot"
+}
+
+func testRegularFile(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && info.Mode().IsRegular()
+}
+
+func testIsHex(value string) bool {
+ for _, char := range value {
+ if (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') {
+ return false
+ }
+ }
+ return true
+}
+
+func downloadTestRuntime(destination, platform string, metadata testRuntimeMetadata) (string, error) {
+ url, err := testRuntimeTarballURL("@github/copilot-"+platform, metadata.version)
+ if err != nil {
+ return "", err
+ }
+ // Download verifies the lockfile integrity while streaming to a temporary file;
+ // extraction never sees an unverified archive.
+ archive, err := os.CreateTemp(destination, ".copilot-runtime-*.tgz")
+ if err != nil {
+ return "", fmt.Errorf("creating Copilot test runtime archive: %w", err)
+ }
+ archivePath := archive.Name()
+ if err := npmregistry.Download(testSetupHTTPClient, url, metadata.integrity, archive); err != nil {
+ archive.Close()
+ os.Remove(archivePath)
+ return "", err
+ }
+ if err := archive.Close(); err != nil {
+ os.Remove(archivePath)
+ return "", fmt.Errorf("closing Copilot test runtime archive: %w", err)
+ }
+ return archivePath, nil
+}
diff --git a/go/testcli/setup_external_test.go b/go/testcli/setup_external_test.go
new file mode 100644
index 0000000000..65f72c3c93
--- /dev/null
+++ b/go/testcli/setup_external_test.go
@@ -0,0 +1,78 @@
+package testcli_test
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/github/copilot-sdk/go/testcli"
+)
+
+func TestSetupHonorsConfiguredPath(t *testing.T) {
+ cliPath := filepath.Join(t.TempDir(), "copilot")
+ if err := os.WriteFile(cliPath, []byte("test"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("COPILOT_CLI_PATH", cliPath)
+
+ // A valid override keeps setup local; success is a nil error and an unchanged path.
+ if err := testcli.Setup(); err != nil {
+ t.Fatal(err)
+ }
+ if got := os.Getenv("COPILOT_CLI_PATH"); got != cliPath {
+ t.Fatalf("COPILOT_CLI_PATH = %q, want %q", got, cliPath)
+ }
+}
+
+func TestSetupHonorsJavaScriptConfiguredPath(t *testing.T) {
+ cliPath := filepath.Join(t.TempDir(), "copilot.js")
+ if err := os.WriteFile(cliPath, []byte("test"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("COPILOT_CLI_PATH", cliPath)
+
+ if err := testcli.Setup(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestSetupRejectsNonExecutableNativePath(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("Windows does not use Unix execute permission bits")
+ }
+ cliPath := filepath.Join(t.TempDir(), "copilot")
+ if err := os.WriteFile(cliPath, []byte("test"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("COPILOT_CLI_PATH", cliPath)
+
+ requireErrorContaining(t, testcli.Setup(), "COPILOT_CLI_PATH", "is not executable")
+}
+
+func TestSetupRejectsInvalidConfiguredPath(t *testing.T) {
+ tests := map[string]string{
+ "missing file": filepath.Join(t.TempDir(), "missing-copilot"),
+ "directory": t.TempDir(),
+ }
+ for name, cliPath := range tests {
+ t.Run(name, func(t *testing.T) {
+ t.Setenv("COPILOT_CLI_PATH", cliPath)
+
+ requireErrorContaining(t, testcli.Setup(), "COPILOT_CLI_PATH", "is not a file")
+ })
+ }
+}
+
+func requireErrorContaining(t *testing.T, err error, fragments ...string) {
+ t.Helper()
+ if err == nil {
+ t.Fatal("testcli.Setup() returned nil")
+ }
+ for _, fragment := range fragments {
+ if !strings.Contains(err.Error(), fragment) {
+ t.Fatalf("error = %q, want it to contain %q", err, fragment)
+ }
+ }
+}
diff --git a/go/testcli/setup_test.go b/go/testcli/setup_test.go
new file mode 100644
index 0000000000..b1e8750e38
--- /dev/null
+++ b/go/testcli/setup_test.go
@@ -0,0 +1,211 @@
+package testcli
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "crypto/sha512"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/github/copilot-sdk/go/internal/ffihost"
+)
+
+func TestSetupFailsWhenUserCacheIsUnavailable(t *testing.T) {
+ previousUserCacheDir := testUserCacheDir
+ testUserCacheDir = func() (string, error) { return "", errors.New("cache unavailable") }
+ t.Cleanup(func() { testUserCacheDir = previousUserCacheDir })
+ t.Setenv("COPILOT_CLI_PATH", "")
+
+ err := Setup()
+ if err == nil || !strings.Contains(err.Error(), "locating user cache directory: cache unavailable") {
+ t.Fatalf("Setup() error = %v", err)
+ }
+}
+
+func TestSetupInstallsAndReusesRuntime(t *testing.T) {
+ platform := ffihost.PrebuildsFolder()
+ if platform == "" {
+ t.Skipf("unsupported test platform %s/%s", runtime.GOOS, runtime.GOARCH)
+ }
+ version := "1.2.3"
+ archive := testRuntimeArchive(t, platform, version)
+ digest := sha512.Sum512(archive)
+ integrity := "sha512-" + base64.StdEncoding.EncodeToString(digest[:])
+
+ moduleRoot := t.TempDir()
+ moduleDir := filepath.Join(moduleRoot, "go")
+ lockDir := filepath.Join(moduleRoot, "nodejs")
+ if err := os.MkdirAll(moduleDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(lockDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ lockData, err := json.Marshal(map[string]any{
+ "packages": map[string]any{
+ "node_modules/@github/copilot-" + platform: map[string]string{
+ "version": version,
+ "integrity": integrity,
+ },
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(lockDir, "package-lock.json"), lockData, 0644); err != nil {
+ t.Fatal(err)
+ }
+ moduleData, err := json.Marshal(testModuleInfo{Version: "v1.0.0", Dir: moduleDir})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var requestCount atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ requestCount.Add(1)
+ writer.Write(archive)
+ }))
+ defer server.Close()
+
+ previousHTTPClient := testSetupHTTPClient
+ previousUserCacheDir := testUserCacheDir
+ previousListSDKModule := testListSDKModule
+ previousRuntimeTarballURL := testRuntimeTarballURL
+ testSetupHTTPClient = server.Client()
+ cacheDir := t.TempDir()
+ testUserCacheDir = func() (string, error) { return cacheDir, nil }
+ testListSDKModule = func() ([]byte, error) { return moduleData, nil }
+ testRuntimeTarballURL = func(packageName, packageVersion string) (string, error) {
+ if packageName != "@github/copilot-"+platform || packageVersion != version {
+ t.Fatalf("runtime package = %s@%s", packageName, packageVersion)
+ }
+ return server.URL + "/runtime.tgz", nil
+ }
+ t.Cleanup(func() {
+ testSetupHTTPClient = previousHTTPClient
+ testUserCacheDir = previousUserCacheDir
+ testListSDKModule = previousListSDKModule
+ testRuntimeTarballURL = previousRuntimeTarballURL
+ })
+ t.Setenv("COPILOT_CLI_PATH", "")
+
+ if err := Setup(); err != nil {
+ t.Fatal(err)
+ }
+ expectedCLIPath := filepath.Join(cacheDir, "copilot-sdk", "test-runtime", version, platform, testCLIBinaryName())
+ if got := os.Getenv("COPILOT_CLI_PATH"); got != expectedCLIPath {
+ t.Fatalf("COPILOT_CLI_PATH = %q, want %q", got, expectedCLIPath)
+ }
+ if content, err := os.ReadFile(expectedCLIPath); err != nil || string(content) != "cli" {
+ t.Fatalf("installed CLI content = %q, err = %v", content, err)
+ }
+ runtimePath, err := ffihost.ResolveLibraryPath(expectedCLIPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ expectedRuntimePath := filepath.Join(filepath.Dir(expectedCLIPath), "prebuilds", platform, "runtime.node")
+ if runtimePath != expectedRuntimePath {
+ t.Fatalf("runtime path = %q, want %q", runtimePath, expectedRuntimePath)
+ }
+ if got := requestCount.Load(); got != 1 {
+ t.Fatalf("download requests = %d, want 1", got)
+ }
+
+ if err := os.Unsetenv("COPILOT_CLI_PATH"); err != nil {
+ t.Fatal(err)
+ }
+ if err := Setup(); err != nil {
+ t.Fatal(err)
+ }
+ if got := os.Getenv("COPILOT_CLI_PATH"); got != expectedCLIPath {
+ t.Fatalf("cached COPILOT_CLI_PATH = %q, want %q", got, expectedCLIPath)
+ }
+ if got := requestCount.Load(); got != 1 {
+ t.Fatalf("download requests after cache reuse = %d, want 1", got)
+ }
+}
+
+func TestParseTestRuntimeMetadata(t *testing.T) {
+ metadata, err := parseTestRuntimeMetadata(strings.NewReader(`{"packages":{"node_modules/@github/copilot-linux-x64":{"version":"1.2.3","integrity":"sha512-test"}}}`), "linux-x64")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if metadata.version != "1.2.3" || metadata.integrity != "sha512-test" {
+ t.Fatalf("parseTestRuntimeMetadata() = %#v", metadata)
+ }
+}
+
+func TestInstalledTestRuntimePathRejectsStaleRuntime(t *testing.T) {
+ packageDir := t.TempDir()
+ platform := "linux-x64"
+ if err := os.MkdirAll(filepath.Join(packageDir, "prebuilds", platform), 0755); err != nil {
+ t.Fatal(err)
+ }
+ for path, content := range map[string]string{
+ filepath.Join(packageDir, "package.json"): `{"version":"1.2.3"}`,
+ filepath.Join(packageDir, testCLIBinaryName()): "cli",
+ filepath.Join(packageDir, "prebuilds", platform, "runtime.node"): "runtime",
+ filepath.Join(packageDir, ".integrity"): "sha512-current\n",
+ } {
+ if err := os.WriteFile(path, []byte(content), 0755); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if _, ok := installedTestRuntimePath(packageDir, platform, testRuntimeMetadata{version: "1.2.3", integrity: "sha512-current"}); !ok {
+ t.Fatal("installedTestRuntimePath() rejected a matching runtime")
+ }
+ if _, ok := installedTestRuntimePath(packageDir, platform, testRuntimeMetadata{version: "2.0.0", integrity: "sha512-current"}); ok {
+ t.Fatal("installedTestRuntimePath() accepted a stale version")
+ }
+ if _, ok := installedTestRuntimePath(packageDir, platform, testRuntimeMetadata{version: "1.2.3", integrity: "sha512-new"}); ok {
+ t.Fatal("installedTestRuntimePath() accepted stale integrity")
+ }
+}
+
+func testRuntimeArchive(t *testing.T, platform, version string) []byte {
+ t.Helper()
+ var buffer bytes.Buffer
+ gzipWriter := gzip.NewWriter(&buffer)
+ tarWriter := tar.NewWriter(gzipWriter)
+ entries := []struct {
+ name string
+ content string
+ mode int64
+ }{
+ {name: "package/package.json", content: `{"version":"` + version + `"}`, mode: 0644},
+ {name: "package/" + testCLIBinaryName(), content: "cli", mode: 0755},
+ {name: "package/prebuilds/" + platform + "/runtime.node", content: "runtime", mode: 0644},
+ }
+ for _, entry := range entries {
+ header := &tar.Header{
+ Name: entry.name,
+ Mode: entry.mode,
+ Size: int64(len(entry.content)),
+ Typeflag: tar.TypeReg,
+ }
+ if err := tarWriter.WriteHeader(header); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tarWriter.Write([]byte(entry.content)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := tarWriter.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := gzipWriter.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return buffer.Bytes()
+}