Skip to content

Commit 6d92b09

Browse files
Isolate embedded CLI versions by directory
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 447feac4-35df-4c0b-ab34-00789ad78da7
1 parent 820cf7d commit 6d92b09

6 files changed

Lines changed: 120 additions & 144 deletions

File tree

‎go/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ Resolution and requirements:
125125
transport when `ClientOptions.Connection` is nil. An explicit connection
126126
always takes precedence.
127127
- The entrypoint is resolved with **no `PATH` lookup**: explicit `InProcessConnection.Path`, then `COPILOT_CLI_PATH`, then the bundled embedded CLI. It must be an on-disk path.
128-
- The runtime library is loaded from next to the resolved entrypoint (the matching versioned `libcopilot_runtime_*` name installed for an embedded CLI, a flat package library name, or the package's `prebuilds/<platform>/runtime.node`). Start fails loudly if it cannot be found.
128+
- The runtime library is loaded from next to the resolved entrypoint (the natural platform library name installed for an embedded CLI or flat package, or the package's `prebuilds/<platform>/runtime.node`). Embedded CLI versions are isolated in separate cache directories so the CLI and runtime library always remain paired. Start fails loudly if the library cannot be found.
129129
- The runtime library is loaded once per process; loading a different library path in the same process is an error.
130130

131131
The in-process transport rejects options that cannot be honored by a runtime hosted in your shared process (each panics at `NewClient`):

‎go/embeddedcli/installer.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import "github.com/github/copilot-sdk/go/internal/embeddedcli"
55
// Config defines the inputs used to install and locate the embedded Copilot CLI.
66
//
77
// Cli and CliHash are required. If Dir is empty, the CLI is installed into the
8-
// system cache directory. Version is used to suffix the installed binary and
9-
// runtime-library names so multiple versions can coexist. License, when
10-
// provided, is written next to the installed binary.
8+
// system cache directory. When Version is set, the CLI and runtime library are
9+
// installed into a version-specific child directory so multiple versions can
10+
// coexist. License, when provided, is written next to the installed binary.
1111
type Config = embeddedcli.Config
1212

1313
// Setup sets the embedded GitHub Copilot CLI install configuration.

‎go/internal/embeddedcli/embeddedcli.go‎

Lines changed: 31 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@ import (
1818
// Config defines the inputs used to install and locate the embedded Copilot CLI.
1919
//
2020
// Cli and CliHash are required. If Dir is empty, the CLI is installed into the
21-
// system cache directory. Version is used to suffix the installed binary and
22-
// runtime-library names so multiple versions can coexist. License, when
23-
// provided, is written next to the installed binary.
21+
// system cache directory. When Version is set, the CLI is installed into a
22+
// version-specific child directory so multiple versions can coexist. License,
23+
// when provided, is written next to the installed binary.
2424
//
2525
// RuntimeLib and RuntimeLibHash are optional: when set, the native in-process
26-
// runtime library (cdylib) is installed next to the CLI binary with the same
27-
// version suffix so the in-process (FFI) transport can load it. They are omitted
28-
// for CLI packages that do not ship the native runtime.
26+
// runtime library (cdylib) is installed next to the CLI binary so the in-process
27+
// (FFI) transport can load it. They are omitted for CLI packages that do not
28+
// ship the native runtime.
2929
type Config struct {
3030
Cli io.Reader
3131
CliHash []byte
@@ -123,25 +123,24 @@ func install() (path string) {
123123
}
124124

125125
func installAt(installDir string) (string, error) {
126-
if err := os.MkdirAll(installDir, 0755); err != nil {
127-
return "", fmt.Errorf("creating install directory: %w", err)
128-
}
129126
version := sanitizeVersion(config.Version)
130-
lockName := ".copilot-cli.lock"
131127
if version != "" {
132-
lockName = fmt.Sprintf(".copilot-cli-%s.lock", version)
128+
installDir = filepath.Join(installDir, version)
129+
}
130+
if err := os.MkdirAll(installDir, 0755); err != nil {
131+
return "", fmt.Errorf("creating install directory: %w", err)
133132
}
134133

135134
// Best effort to prevent concurrent installs.
136-
if release, _ := flock.Acquire(filepath.Join(installDir, lockName)); release != nil {
135+
if release, _ := flock.Acquire(filepath.Join(installDir, ".copilot-cli.lock")); release != nil {
137136
defer release()
138137
}
139138

140139
binaryName := "copilot"
141140
if runtime.GOOS == "windows" {
142141
binaryName += ".exe"
143142
}
144-
finalPath := versionedBinaryPath(installDir, binaryName, version)
143+
finalPath := filepath.Join(installDir, binaryName)
145144

146145
if _, err := os.Stat(finalPath); err == nil {
147146
existingHash, err := hashFile(finalPath)
@@ -151,6 +150,13 @@ func installAt(installDir string) (string, error) {
151150
if !bytes.Equal(existingHash, config.CliHash) {
152151
return "", fmt.Errorf("existing binary hash mismatch")
153152
}
153+
if config.RuntimeLib != nil {
154+
libPath, err := installRuntimeLib(installDir)
155+
if err != nil {
156+
return "", err
157+
}
158+
runtimeLibPath = libPath
159+
}
154160
return finalPath, nil
155161
}
156162

@@ -175,11 +181,10 @@ func installAt(installDir string) (string, error) {
175181
}
176182
}
177183

178-
// Install the native in-process runtime library (if bundled) next to the CLI
179-
// binary with the same version suffix. Fail closed on any hash mismatch —
180-
// never place unverified native code.
184+
// Install the native in-process runtime library (if bundled) next to the CLI.
185+
// Fail closed on any hash mismatch; never place unverified native code.
181186
if config.RuntimeLib != nil {
182-
libPath, err := installRuntimeLib(installDir, version)
187+
libPath, err := installRuntimeLib(installDir)
183188
if err != nil {
184189
return "", err
185190
}
@@ -189,14 +194,14 @@ func installAt(installDir string) (string, error) {
189194
return finalPath, nil
190195
}
191196

192-
// installRuntimeLib writes the embedded runtime cdylib into installDir under a
193-
// versioned platform file name, verifying its SHA-256. It is idempotent: an
197+
// installRuntimeLib writes the embedded runtime cdylib into installDir under its
198+
// natural platform file name, verifying its SHA-256. It is idempotent: an
194199
// existing file with a matching hash is reused; a mismatch is a hard error.
195-
func installRuntimeLib(installDir, version string) (string, error) {
200+
func installRuntimeLib(installDir string) (string, error) {
196201
if len(config.RuntimeLibHash) != sha256.Size {
197202
return "", fmt.Errorf("RuntimeLibHash must be a SHA-256 hash (%d bytes), got %d bytes", sha256.Size, len(config.RuntimeLibHash))
198203
}
199-
libPath := versionedRuntimeLibPath(installDir, naturalRuntimeLibName(), version)
204+
libPath := filepath.Join(installDir, naturalRuntimeLibName())
200205

201206
if _, err := os.Stat(libPath); err == nil {
202207
existingHash, err := hashFile(libPath)
@@ -252,27 +257,6 @@ func naturalRuntimeLibName() string {
252257
}
253258
}
254259

255-
// versionedBinaryPath builds the unpacked binary filename with an optional version suffix.
256-
func versionedBinaryPath(dir, binaryName, version string) string {
257-
if version == "" {
258-
return filepath.Join(dir, binaryName)
259-
}
260-
base := strings.TrimSuffix(binaryName, filepath.Ext(binaryName))
261-
ext := filepath.Ext(binaryName)
262-
return filepath.Join(dir, fmt.Sprintf("%s_%s%s", base, version, ext))
263-
}
264-
265-
// versionedRuntimeLibPath builds the runtime-library filename with the same
266-
// optional version suffix as the embedded CLI.
267-
func versionedRuntimeLibPath(dir, libraryName, version string) string {
268-
if version == "" {
269-
return filepath.Join(dir, libraryName)
270-
}
271-
base := strings.TrimSuffix(libraryName, filepath.Ext(libraryName))
272-
ext := filepath.Ext(libraryName)
273-
return filepath.Join(dir, fmt.Sprintf("%s_%s%s", base, version, ext))
274-
}
275-
276260
// sanitizeVersion makes a version string safe for filenames.
277261
func sanitizeVersion(version string) string {
278262
if version == "" {
@@ -293,7 +277,11 @@ func sanitizeVersion(version string) string {
293277
b.WriteRune('_')
294278
}
295279
}
296-
return b.String()
280+
sanitized := b.String()
281+
if sanitized == "." || sanitized == ".." {
282+
return strings.Repeat("_", len(sanitized))
283+
}
284+
return sanitized
297285
}
298286

299287
// hashFile returns the SHA-256 hash of a file on disk.

‎go/internal/embeddedcli/embeddedcli_test.go‎

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ func TestInstallAtWritesBinaryAndLicense(t *testing.T) {
6666

6767
path := Path()
6868

69-
expectedPath := versionedBinaryPath(tempDir, binaryNameForOS(), "1.2.3")
69+
expectedPath := filepath.Join(tempDir, "1.2.3", binaryNameForOS())
7070
if path != expectedPath {
7171
t.Fatalf("unexpected path: got %q want %q", path, expectedPath)
7272
}
@@ -100,7 +100,7 @@ func TestInstallAtWritesBinaryAndLicense(t *testing.T) {
100100
func TestInstallAtExistingBinaryHashMismatch(t *testing.T) {
101101
resetGlobals()
102102
tempDir := t.TempDir()
103-
binaryPath := versionedBinaryPath(tempDir, binaryNameForOS(), "")
103+
binaryPath := filepath.Join(tempDir, binaryNameForOS())
104104
if err := os.MkdirAll(filepath.Dir(binaryPath), 0755); err != nil {
105105
t.Fatalf("mkdir: %v", err)
106106
}
@@ -121,18 +121,15 @@ func TestInstallAtExistingBinaryHashMismatch(t *testing.T) {
121121
}
122122

123123
func TestSanitizeVersion(t *testing.T) {
124-
got := sanitizeVersion("v1.2.3+build/abc")
125-
want := "v1.2.3_build_abc"
126-
if got != want {
127-
t.Fatalf("sanitizeVersion() = %q want %q", got, want)
128-
}
129-
}
130-
131-
func TestVersionedBinaryPath(t *testing.T) {
132-
got := versionedBinaryPath("/tmp", "copilot.exe", "1.0.0")
133-
want := filepath.Join("/tmp", "copilot_1.0.0.exe")
134-
if got != want {
135-
t.Fatalf("versionedBinaryPath() = %q want %q", got, want)
124+
tests := map[string]string{
125+
"v1.2.3+build/abc": "v1.2.3_build_abc",
126+
".": "_",
127+
"..": "__",
128+
}
129+
for input, want := range tests {
130+
if got := sanitizeVersion(input); got != want {
131+
t.Errorf("sanitizeVersion(%q) = %q want %q", input, got, want)
132+
}
136133
}
137134
}
138135

@@ -168,10 +165,58 @@ func TestInstallAtAllowsMultipleRuntimeVersions(t *testing.T) {
168165
if runtime1 == runtime2 {
169166
t.Fatalf("Expected versioned runtime paths to differ, got %q", runtime1)
170167
}
168+
if got, want := filepath.Base(cli1), binaryNameForOS(); got != want {
169+
t.Fatalf("First CLI filename = %q, want %q", got, want)
170+
}
171+
if got, want := filepath.Base(runtime1), naturalRuntimeLibName(); got != want {
172+
t.Fatalf("First runtime filename = %q, want %q", got, want)
173+
}
174+
if got, want := filepath.Base(filepath.Dir(cli1)), "1.0.0"; got != want {
175+
t.Fatalf("First CLI version directory = %q, want %q", got, want)
176+
}
177+
if filepath.Dir(cli1) != filepath.Dir(runtime1) {
178+
t.Fatalf("CLI and runtime were installed in different directories: %q and %q", cli1, runtime1)
179+
}
171180
if got, err := os.ReadFile(runtime1); err != nil || string(got) != "runtime-one" {
172181
t.Fatalf("Unexpected first runtime: content=%q err=%v", got, err)
173182
}
174183
if got, err := os.ReadFile(runtime2); err != nil || string(got) != "runtime-two" {
175184
t.Fatalf("Unexpected second runtime: content=%q err=%v", got, err)
176185
}
177186
}
187+
188+
func TestInstallAtExistingBinaryInstallsMissingRuntime(t *testing.T) {
189+
resetGlobals()
190+
tempDir := t.TempDir()
191+
versionDir := filepath.Join(tempDir, "1.2.3")
192+
if err := os.MkdirAll(versionDir, 0755); err != nil {
193+
t.Fatalf("mkdir: %v", err)
194+
}
195+
196+
cliContent := []byte("cli")
197+
cliPath := filepath.Join(versionDir, binaryNameForOS())
198+
if err := os.WriteFile(cliPath, cliContent, 0755); err != nil {
199+
t.Fatalf("write CLI: %v", err)
200+
}
201+
cliHash := sha256.Sum256(cliContent)
202+
runtimeContent := []byte("runtime")
203+
runtimeHash := sha256.Sum256(runtimeContent)
204+
config = Config{
205+
Cli: bytes.NewReader(cliContent),
206+
CliHash: cliHash[:],
207+
RuntimeLib: bytes.NewReader(runtimeContent),
208+
RuntimeLibHash: runtimeHash[:],
209+
Version: "1.2.3",
210+
}
211+
212+
gotCLIPath, err := installAt(tempDir)
213+
if err != nil {
214+
t.Fatalf("installAt(): %v", err)
215+
}
216+
if gotCLIPath != cliPath {
217+
t.Fatalf("installAt() = %q, want %q", gotCLIPath, cliPath)
218+
}
219+
if got, err := os.ReadFile(filepath.Join(versionDir, naturalRuntimeLibName())); err != nil || string(got) != "runtime" {
220+
t.Fatalf("Unexpected runtime: content=%q err=%v", got, err)
221+
}
222+
}

‎go/internal/ffihost/resolve.go‎

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -62,28 +62,17 @@ func PrebuildsFolder() string {
6262
// ResolveLibraryPath resolves the native runtime library next to the given CLI
6363
// entrypoint. It checks, in order:
6464
//
65-
// 1. A versioned platform library name matching a versioned embedded CLI.
66-
// 2. The natural platform library name next to the CLI (flat package layout).
67-
// 3. prebuilds/<platform>/runtime.node next to the CLI (dev/package layout).
65+
// 1. The natural platform library name next to the CLI (bundled/flat layout).
66+
// 2. prebuilds/<platform>/runtime.node next to the CLI (dev/package layout).
6867
//
69-
// Versioned embedded CLIs require their exactly matching versioned library.
70-
// Unversioned entrypoints fall back to the flat and package layouts.
68+
// It returns an error when neither exists.
7169
func ResolveLibraryPath(cliEntrypoint string) (string, error) {
7270
abs, err := filepath.Abs(cliEntrypoint)
7371
if err != nil {
7472
abs = cliEntrypoint
7573
}
7674
dir := filepath.Dir(abs)
7775

78-
if versioned := versionedLibraryPathForEntrypoint(abs); versioned != "" {
79-
if fileExists(versioned) {
80-
return versioned, nil
81-
}
82-
return "", fmt.Errorf(
83-
"in-process FFI runtime library matching versioned CLI %q not found at %q",
84-
abs, versioned)
85-
}
86-
8776
flat := filepath.Join(dir, NaturalLibraryName())
8877
if fileExists(flat) {
8978
return flat, nil
@@ -97,30 +86,11 @@ func ResolveLibraryPath(cliEntrypoint string) (string, error) {
9786
}
9887

9988
return "", fmt.Errorf(
100-
"in-process FFI runtime library not found next to %q (looked for a matching versioned library, %q, and prebuilds/%s/runtime.node); "+
89+
"in-process FFI runtime library not found next to %q (looked for %q and prebuilds/%s/runtime.node); "+
10190
"use a runtime package that ships the native library",
10291
abs, NaturalLibraryName(), PrebuildsFolder())
10392
}
10493

105-
func versionedLibraryPathForEntrypoint(cliEntrypoint string) string {
106-
name := filepath.Base(cliEntrypoint)
107-
stem := name
108-
if strings.HasSuffix(strings.ToLower(stem), ".exe") {
109-
stem = stem[:len(stem)-len(".exe")]
110-
}
111-
version, ok := strings.CutPrefix(stem, "copilot_")
112-
if !ok || version == "" {
113-
return ""
114-
}
115-
116-
libraryName := NaturalLibraryName()
117-
libraryStem := strings.TrimSuffix(libraryName, filepath.Ext(libraryName))
118-
return filepath.Join(
119-
filepath.Dir(cliEntrypoint),
120-
libraryStem+"_"+version+filepath.Ext(libraryName),
121-
)
122-
}
123-
12494
func fileExists(path string) bool {
12595
info, err := os.Stat(path)
12696
return err == nil && !info.IsDir()

0 commit comments

Comments
 (0)