argv = new ArrayList<>();
- if (entrypointPath.toLowerCase().endsWith(".js")) {
- argv.add("node");
+ if (entrypointPath != null) {
+ if (entrypointPath.toLowerCase().endsWith(".js")) {
+ argv.add("node");
+ }
+ argv.add(entrypointPath);
+ argv.add("--embedded-host");
+ argv.add("--no-auto-update");
}
- argv.add(entrypointPath);
- argv.add("--embedded-host");
- argv.add("--no-auto-update");
String logLevel = options.getLogLevel();
if (logLevel != null && !logLevel.isBlank()) {
diff --git a/java/sdk/src/main/java/com/github/copilot/ffi/NativeRuntimeLoader.java b/java/sdk/src/main/java/com/github/copilot/ffi/NativeRuntimeLoader.java
index 6733f4afb2..bd4b185a07 100644
--- a/java/sdk/src/main/java/com/github/copilot/ffi/NativeRuntimeLoader.java
+++ b/java/sdk/src/main/java/com/github/copilot/ffi/NativeRuntimeLoader.java
@@ -5,9 +5,12 @@
package com.github.copilot.ffi;
import java.io.FileNotFoundException;
+import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
+import java.io.InputStreamReader;
import java.net.URL;
+import java.nio.charset.StandardCharsets;
import java.nio.channels.FileChannel;
import java.nio.file.AccessDeniedException;
import java.nio.file.AtomicMoveNotSupportedException;
@@ -42,7 +45,10 @@ public final class NativeRuntimeLoader {
static final String RUNTIME_FILENAME = "runtime.node";
static final String CLI_FILENAME = "copilot";
static final String CLI_FILENAME_WINDOWS = "copilot.exe";
+ static final String RUNTIME_WRAPPER_FILENAME = "copilot-runtime";
+ static final String RUNTIME_WRAPPER_FILENAME_WINDOWS = "copilot-runtime.exe";
static final String PLATFORM_PROPERTIES_FILENAME = "platform.properties";
+ static final String RUNTIME_ASSETS_FILENAME = "runtime-assets.list";
/** Environment variable that overrides where the runtime is loaded from. */
public static final String COPILOT_CLI_PATH_ENV = "COPILOT_CLI_PATH";
static final String VERSION_RESOURCE = "copilot-runtime.properties";
@@ -125,10 +131,10 @@ public static Path resolve() throws IOException {
}
/**
- * Resolves the copilot CLI executable from the same location as the bundled
- * {@code runtime.node}. The CLI is used as {@code argv[0]} in
- * {@code copilot_runtime_host_start} — the Rust runtime spawns it as a child
- * process.
+ * Resolves the legacy copilot CLI entrypoint from the same location as the
+ * bundled {@code runtime.node}. Callers may pass this entrypoint through
+ * {@code copilot_runtime_host_start} when legacy extension hosting is
+ * requested.
*
*
* This method calls {@link #resolve()} to locate {@code runtime.node}, then
@@ -144,6 +150,73 @@ public static Path resolveEntrypoint() throws IOException {
return resolveEntrypoint(configuredCli, resolve());
}
+ /**
+ * Resolves an explicitly configured legacy CLI entrypoint, if it has a
+ * compatible adjacent runtime library.
+ *
+ * @return the absolute CLI path, or {@code null} when no compatible override is
+ * configured
+ * @throws IOException
+ * if the configured files cannot be inspected
+ */
+ public static Path resolveConfiguredEntrypoint() throws IOException {
+ String configuredCli = System.getenv(COPILOT_CLI_PATH_ENV);
+ if (configuredCli == null || configuredCli.isBlank()) {
+ return null;
+ }
+ Path configuredPath = Path.of(configuredCli).toAbsolutePath().normalize();
+ return resolveFromCliPath(configuredCli) != null && Files.isRegularFile(configuredPath)
+ && Files.size(configuredPath) > 0 ? configuredPath : null;
+ }
+
+ /**
+ * Resolves the out-of-process runtime wrapper from the platform classifier JAR
+ * and extracts it beside {@code runtime.node}.
+ *
+ * @return absolute path to the runtime wrapper executable
+ * @throws IOException
+ * if the classifier artifacts cannot be extracted
+ */
+ public static Path resolveRuntimeWrapper() throws IOException {
+ ClassLoader loader = NativeRuntimeLoader.class.getClassLoader();
+ String classifier = PlatformDetector.detectClassifier();
+ String version = readVersion(loader);
+ return resolveRuntimeWrapper(defaultCacheBase(), loader, classifier, version);
+ }
+
+ static Path resolveRuntimeWrapper(Path cacheBase, ClassLoader loader, String classifier, String version)
+ throws IOException {
+ Path runtimePath = extractRuntimeToCache(cacheBase, loader, classifier, version, DEFAULT_PUBLISHER, false);
+ Path cacheDir = runtimePath.getParent();
+ String wrapperName = classifier.startsWith("win32-")
+ ? RUNTIME_WRAPPER_FILENAME_WINDOWS
+ : RUNTIME_WRAPPER_FILENAME;
+ Path cachedWrapper = cacheDir.resolve(wrapperName);
+ if (isValidCachedCli(cachedWrapper)) {
+ return cachedWrapper;
+ }
+
+ String resourcePath = "native/" + classifier + "/" + wrapperName;
+ URL resource = loader.getResource(resourcePath);
+ if (resource == null) {
+ throw new FileNotFoundException("Runtime wrapper not found on classpath: " + resourcePath
+ + " — add the matching classifier JAR to the classpath");
+ }
+
+ Path temp = Files.createTempFile(cacheDir, "runtime-wrapper-tmp-", "");
+ try {
+ copyResourceToTemp(resource, resourcePath, temp);
+ makeExecutable(temp);
+ DEFAULT_PUBLISHER.publish(temp, cachedWrapper);
+ } finally {
+ tryDelete(temp);
+ }
+ if (!isValidCachedCli(cachedWrapper)) {
+ throw new IOException("Published runtime wrapper is not a non-empty executable file: " + cachedWrapper);
+ }
+ return cachedWrapper;
+ }
+
static Path resolveEntrypoint(String configuredCli, Path runtimePath) throws IOException {
if (configuredCli != null && !configuredCli.isBlank()) {
Path configuredPath = Path.of(configuredCli).toAbsolutePath().normalize();
@@ -318,6 +391,11 @@ static Path extractToCache(Path cacheBase, ClassLoader loader, String classifier
*/
static Path extractToCache(Path cacheBase, ClassLoader loader, String classifier, String version,
AtomicPublisher publisher) throws IOException {
+ return extractRuntimeToCache(cacheBase, loader, classifier, version, publisher, true);
+ }
+
+ private static Path extractRuntimeToCache(Path cacheBase, ClassLoader loader, String classifier, String version,
+ AtomicPublisher publisher, boolean extractCli) throws IOException {
String resourcePath = "native/" + classifier + "/" + RUNTIME_FILENAME;
String nativeVersion = readNativePackageVersion(loader, classifier);
Path cacheDir = cacheBase.resolve(version).resolve(nativeVersion).resolve(classifier);
@@ -325,7 +403,10 @@ static Path extractToCache(Path cacheBase, ClassLoader loader, String classifier
// Step 1 — fast path: return an existing valid cache entry.
if (isValidCachedFile(cached)) {
- extractCliToCache(cacheDir, loader, classifier, publisher);
+ extractRuntimeAssetsToCache(cacheDir, loader, classifier, publisher);
+ if (extractCli) {
+ extractCliToCache(cacheDir, loader, classifier, publisher);
+ }
return cached;
}
@@ -348,12 +429,68 @@ static Path extractToCache(Path cacheBase, ClassLoader loader, String classifier
tryDelete(temp);
}
- // Step 5 — also extract the copilot CLI executable alongside runtime.node.
- extractCliToCache(cacheDir, loader, classifier, publisher);
+ extractRuntimeAssetsToCache(cacheDir, loader, classifier, publisher);
+ if (extractCli) {
+ extractCliToCache(cacheDir, loader, classifier, publisher);
+ }
return cached;
}
+ private static void extractRuntimeAssetsToCache(Path cacheDir, ClassLoader loader, String classifier,
+ AtomicPublisher publisher) throws IOException {
+ String inventoryResourcePath = "native/" + classifier + "/" + RUNTIME_ASSETS_FILENAME;
+ URL inventoryResource = loader.getResource(inventoryResourcePath);
+ if (inventoryResource == null) {
+ return;
+ }
+
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(inventoryResource.openStream(), StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.isBlank()) {
+ continue;
+ }
+ String[] fields = line.split("\\t", 2);
+ if (fields.length != 2) {
+ throw new IOException("Invalid runtime asset inventory entry: " + line);
+ }
+ boolean executable = (Integer.parseInt(fields[0], 8) & 0111) != 0;
+ Path relative = Path.of(fields[1]).normalize();
+ if (relative.isAbsolute() || relative.startsWith("..")) {
+ throw new IOException("Unsafe runtime asset inventory path: " + fields[1]);
+ }
+ Path cached = cacheDir.resolve(relative).normalize();
+ if (!cached.startsWith(cacheDir)) {
+ throw new IOException("Runtime asset escapes cache directory: " + fields[1]);
+ }
+ if (isValidCachedFile(cached) && (!executable || isWindows() || Files.isExecutable(cached))) {
+ continue;
+ }
+
+ String resourcePath = "native/" + classifier + "/" + fields[1];
+ URL resource = loader.getResource(resourcePath);
+ if (resource == null) {
+ throw new FileNotFoundException("Runtime asset not found on classpath: " + resourcePath);
+ }
+ Files.createDirectories(cached.getParent());
+ Path temp = Files.createTempFile(cached.getParent(), "runtime-asset-tmp-", "");
+ try {
+ copyResourceToTemp(resource, resourcePath, temp);
+ if (executable) {
+ makeExecutable(temp);
+ }
+ publisher.publish(temp, cached);
+ } finally {
+ tryDelete(temp);
+ }
+ }
+ } catch (NumberFormatException ex) {
+ throw new IOException("Invalid runtime asset mode in " + inventoryResourcePath, ex);
+ }
+ }
+
/**
* Extracts the copilot CLI executable from the classpath to the same cache
* directory as {@code runtime.node}. Idempotent — skips extraction if already
diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java b/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java
index d3515509b5..a9c9cbcda7 100644
--- a/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java
+++ b/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java
@@ -192,19 +192,20 @@ public CopilotClientOptions setCliArgs(String[] cliArgs) {
}
/**
- * Gets the path to the Copilot CLI executable.
+ * Gets the path to an explicitly configured Copilot executable.
*
- * @return the CLI path, or {@code null} to use "copilot" from PATH
+ * @return the executable path, or {@code null} to use the bundled runtime
+ * wrapper
*/
public String getCliPath() {
return cliPath;
}
/**
- * Sets the path to the Copilot CLI executable.
+ * Sets the path to the Copilot CLI or runtime wrapper executable.
*
* @param cliPath
- * the path to the CLI executable
+ * the path to the executable
* @return this options instance for method chaining
*/
public CopilotClientOptions setCliPath(String cliPath) {
diff --git a/java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java b/java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java
index 68555a35b4..511d23831e 100644
--- a/java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java
+++ b/java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java
@@ -9,8 +9,10 @@
import java.io.IOException;
import java.net.ServerSocket;
import java.net.URI;
+import java.nio.file.Path;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import com.github.copilot.rpc.CopilotClientOptions;
import com.github.copilot.rpc.TelemetryConfig;
@@ -22,6 +24,17 @@
*/
class CliServerManagerTest {
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void explicitCliPathDoesNotRequireRuntimeBundle() throws Exception {
+ Path explicit = tempDir.resolve("copilot");
+ var manager = new CliServerManager(new CopilotClientOptions().setCliPath(explicit.toString()));
+
+ assertEquals(explicit.toString(), manager.resolveCliLaunch().executable());
+ }
+
// ===== parseCliUrl tests =====
@Test
diff --git a/java/sdk/src/test/java/com/github/copilot/e2e/InProcessTransportIT.java b/java/sdk/src/test/java/com/github/copilot/e2e/InProcessTransportIT.java
index 23408faa2a..0294b511ec 100644
--- a/java/sdk/src/test/java/com/github/copilot/e2e/InProcessTransportIT.java
+++ b/java/sdk/src/test/java/com/github/copilot/e2e/InProcessTransportIT.java
@@ -50,10 +50,10 @@
*
*
* Run with {@code mvn verify -Pinprocess} from the {@code java} reactor root,
- * which builds the {@code copilot-sdk-java-runtime} artifact and sets
- * {@code COPILOT_CLI_PATH} to the pinned CLI whose sibling {@code runtime.node}
- * this test loads, and forces {@code forkCount=1} because the FFI host and env
- * guard mutate process-global state.
+ * which builds the {@code copilot-sdk-java-runtime} artifact and sets the
+ * classifier JAR containing {@code runtime.node}, and forces
+ * {@code forkCount=1} because the FFI host and env guard mutate process-global
+ * state.
*
*
* {@link RequireInProcess} disables this test unless the {@code -Pinprocess}
@@ -86,11 +86,6 @@ void shouldStartPingAndStopOverInProcessFfi() throws Exception {
// replay proxy, mirroring how a session-level in-process test would
// redirect COPILOT_API_URL. `ping` never reaches the network, but this
// demonstrates the guard's intended usage for future in-process tests.
- // COPILOT_CLI_PATH is intentionally NOT set here: NativeRuntimeLoader and
- // CopilotClient.resolveInProcessEntrypoint() read it via
- // System.getenv(), which is a JVM-startup-time snapshot that native
- // setenv() calls made after the JVM starts cannot update — it must be
- // set before the JVM starts (see the -Pinprocess Maven profile).
try (InProcessEnvGuard envGuard = new InProcessEnvGuard(Map.of("COPILOT_API_URL", ctx.getProxyUrl()))) {
CopilotClientOptions options = new CopilotClientOptions().setConnection(RuntimeConnection.forInProcess());
try (CopilotClient client = new CopilotClient(options)) {
diff --git a/java/sdk/src/test/java/com/github/copilot/e2e/OutOfProcessTransportIT.java b/java/sdk/src/test/java/com/github/copilot/e2e/OutOfProcessTransportIT.java
new file mode 100644
index 0000000000..19c14a0644
--- /dev/null
+++ b/java/sdk/src/test/java/com/github/copilot/e2e/OutOfProcessTransportIT.java
@@ -0,0 +1,38 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------------------------------------------*/
+
+package com.github.copilot.e2e;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.junit.jupiter.api.Test;
+
+import com.github.copilot.AllowCopilotExperimental;
+import com.github.copilot.CopilotClient;
+import com.github.copilot.rpc.CopilotClientOptions;
+import com.github.copilot.rpc.PingResponse;
+import com.github.copilot.rpc.RuntimeConnection;
+
+/**
+ * Failsafe smoke test for the managed out-of-process runtime wrapper.
+ */
+@AllowCopilotExperimental
+@RequireInProcess
+class OutOfProcessTransportIT {
+
+ @Test
+ void shouldStartPingAndStopOverStdio() throws Exception {
+ CopilotClientOptions options = new CopilotClientOptions().setConnection(RuntimeConnection.forStdio());
+ try (CopilotClient client = new CopilotClient(options)) {
+ client.start().get();
+
+ PingResponse pong = client.ping("wrapper message").get();
+ assertEquals("pong: wrapper message", pong.message());
+ assertNotNull(pong.timestamp());
+
+ client.stop().get();
+ }
+ }
+}
diff --git a/java/sdk/src/test/java/com/github/copilot/ffi/FfiRuntimeHostTest.java b/java/sdk/src/test/java/com/github/copilot/ffi/FfiRuntimeHostTest.java
index cc98d24f6b..545b316c8c 100644
--- a/java/sdk/src/test/java/com/github/copilot/ffi/FfiRuntimeHostTest.java
+++ b/java/sdk/src/test/java/com/github/copilot/ffi/FfiRuntimeHostTest.java
@@ -99,6 +99,47 @@ public boolean connectionClose(int connectionId) {
assertEquals("1", env.get("COPILOT_DISABLE_KEYTAR"));
}
+ @Test
+ void startWithoutEntrypointPassesOnlyRuntimeOptions() throws Exception {
+ AtomicReference argvJson = new AtomicReference<>();
+ NativeBinding binding = new NativeBinding() {
+ @Override
+ public int hostStart(byte[] argv, int argvLen, byte[] env, int envLen) {
+ argvJson.set(argv);
+ return 11;
+ }
+
+ @Override
+ public boolean hostShutdown(int serverId) {
+ return true;
+ }
+
+ @Override
+ public int connectionOpen(int serverId, OutboundCallback callback, Pointer userData, byte[] extSource,
+ int extSourceLen, byte[] extName, int extNameLen, byte[] connToken, int connTokenLen) {
+ return 21;
+ }
+
+ @Override
+ public boolean connectionWrite(int connectionId, byte[] data, int dataLen) {
+ return true;
+ }
+
+ @Override
+ public boolean connectionClose(int connectionId) {
+ return true;
+ }
+ };
+
+ try (FfiRuntimeHost host = new FfiRuntimeHost(binding, "/tmp/runtime.node")) {
+ host.start(null, new CopilotClientOptions().setLogLevel("debug"));
+ }
+
+ List argv = MAPPER.readValue(argvJson.get(), new TypeReference>() {
+ });
+ assertEquals(List.of("--log-level", "debug"), argv);
+ }
+
@Test
void callbackExceptionIsContainedAndDoesNotEscapeAcrossFfiBoundary() {
AtomicBoolean callbackReturned = new AtomicBoolean(false);
diff --git a/java/sdk/src/test/java/com/github/copilot/ffi/NativeRuntimeLoaderTest.java b/java/sdk/src/test/java/com/github/copilot/ffi/NativeRuntimeLoaderTest.java
index fc8bd51011..84ef7d8de4 100644
--- a/java/sdk/src/test/java/com/github/copilot/ffi/NativeRuntimeLoaderTest.java
+++ b/java/sdk/src/test/java/com/github/copilot/ffi/NativeRuntimeLoaderTest.java
@@ -34,17 +34,13 @@
class NativeRuntimeLoaderTest {
- private static final String TEST_CLASSIFIER = PlatformDetector.detectClassifier();
- private static final String OTHER_CLASSIFIER = TEST_CLASSIFIER.equals("darwin-arm64")
- ? "linux-x64"
- : "darwin-arm64";
- private static final String TEST_CLI_FILENAME = TEST_CLASSIFIER.startsWith("win32")
- ? NativeRuntimeLoader.CLI_FILENAME_WINDOWS
- : NativeRuntimeLoader.CLI_FILENAME;
+ private static final String TEST_CLASSIFIER = "linux-x64";
+ private static final String OTHER_CLASSIFIER = "darwin-arm64";
private static final String TEST_VERSION = "1.2.3-test";
private static final String TEST_NATIVE_VERSION = "0.0.1-test";
private static final byte[] FAKE_BINARY_CONTENT = "fake runtime.node binary content".getBytes();
private static final byte[] FAKE_CLI_CONTENT = "fake copilot CLI content".getBytes();
+ private static final byte[] FAKE_WRAPPER_CONTENT = "fake runtime wrapper content".getBytes();
private static final byte[] OTHER_BINARY_CONTENT = "other runtime.node binary content".getBytes();
private static final byte[] OTHER_CLI_CONTENT = "other copilot CLI content".getBytes();
@@ -160,19 +156,24 @@ void resolveEntrypointUsesConfiguredCliWhenRuntimeIsInPrebuilds(@TempDir Path te
@Test
void resolveFromCliPathReturnsAbsolutePathForRelativeCliPath() throws Exception {
Path workingDirectory = Path.of("").toAbsolutePath();
- Path fakeCliDir = Files.createTempDirectory(Path.of("target").toAbsolutePath(), "relative-cli-test-");
- Path fakeCliPath = fakeCliDir.resolve("copilot");
- Files.createFile(fakeCliPath);
- Path runtimeNode = fakeCliDir.resolve(NativeRuntimeLoader.RUNTIME_FILENAME);
- Files.write(runtimeNode, FAKE_BINARY_CONTENT);
-
- Path relativeCliPath = workingDirectory.relativize(fakeCliPath);
-
- assertEquals(runtimeNode, NativeRuntimeLoader.resolveFromCliPath(relativeCliPath.toString()));
+ Path fakeCliDir = Files.createTempDirectory(workingDirectory.resolve("target"), "relative-cli-");
+ try {
+ Path fakeCliPath = Files.createFile(fakeCliDir.resolve("copilot"));
+ Path runtimeNode = Files.write(fakeCliDir.resolve(NativeRuntimeLoader.RUNTIME_FILENAME),
+ FAKE_BINARY_CONTENT);
+ Path relativeCliPath = workingDirectory.relativize(fakeCliPath);
+
+ assertEquals(runtimeNode, NativeRuntimeLoader.resolveFromCliPath(relativeCliPath.toString()));
+ } finally {
+ Files.deleteIfExists(fakeCliDir.resolve(NativeRuntimeLoader.RUNTIME_FILENAME));
+ Files.deleteIfExists(fakeCliDir.resolve("copilot"));
+ Files.deleteIfExists(fakeCliDir);
+ }
}
@Test
void cliPathOverrideTakesPriorityOverClasspathExtraction(@TempDir Path tempDir) throws Exception {
+ assumeLinuxX64();
// Create a valid runtime.node alongside the fake CLI path
Path fakeCliDir = tempDir.resolve("cli-dir");
Files.createDirectories(fakeCliDir);
@@ -197,6 +198,7 @@ void cliPathOverrideTakesPriorityOverClasspathExtraction(@TempDir Path tempDir)
@Test
void extractToCacheCopiesResourceToVersionedCacheDirectory(@TempDir Path tempDir) throws Exception {
+ assumeLinuxX64();
Path cacheBase = tempDir.resolve("cache");
ClassLoader loader = classLoaderWithRuntimeResource(tempDir, TEST_CLASSIFIER);
@@ -211,6 +213,7 @@ void extractToCacheCopiesResourceToVersionedCacheDirectory(@TempDir Path tempDir
@Test
void extractToCacheReturnsCachedFileOnSecondCall(@TempDir Path tempDir) throws Exception {
+ assumeLinuxX64();
Path cacheBase = tempDir.resolve("cache");
ClassLoader loader = classLoaderWithRuntimeResource(tempDir, TEST_CLASSIFIER);
@@ -230,6 +233,7 @@ void extractToCacheReturnsCachedFileOnSecondCall(@TempDir Path tempDir) throws E
@Test
void changedNativeVersionDoesNotReuseCachedArtifactsForSameSdkVersion(@TempDir Path tempDir) throws Exception {
+ assumeLinuxX64();
Path cacheBase = tempDir.resolve("cache");
ClassLoader firstLoader = classLoaderWithNativeArtifacts(tempDir.resolve("native-v1"), TEST_CLASSIFIER, "1.0.0",
FAKE_BINARY_CONTENT, FAKE_CLI_CONTENT);
@@ -241,13 +245,16 @@ void changedNativeVersionDoesNotReuseCachedArtifactsForSameSdkVersion(@TempDir P
assertNotEquals(firstRuntime, secondRuntime, "Different native versions must use different cache entries");
assertBytesEqual(FAKE_BINARY_CONTENT, Files.readAllBytes(firstRuntime));
- assertBytesEqual(FAKE_CLI_CONTENT, Files.readAllBytes(firstRuntime.getParent().resolve(TEST_CLI_FILENAME)));
+ assertBytesEqual(FAKE_CLI_CONTENT,
+ Files.readAllBytes(firstRuntime.getParent().resolve(NativeRuntimeLoader.CLI_FILENAME)));
assertBytesEqual(OTHER_BINARY_CONTENT, Files.readAllBytes(secondRuntime));
- assertBytesEqual(OTHER_CLI_CONTENT, Files.readAllBytes(secondRuntime.getParent().resolve(TEST_CLI_FILENAME)));
+ assertBytesEqual(OTHER_CLI_CONTENT,
+ Files.readAllBytes(secondRuntime.getParent().resolve(NativeRuntimeLoader.CLI_FILENAME)));
}
@Test
void extractToCacheThrowsWhenClasspathResourceMissing(@TempDir Path tempDir) {
+ assumeLinuxX64();
Path cacheBase = tempDir.resolve("cache");
ClassLoader emptyLoader = new URLClassLoader(new URL[0], null);
@@ -257,6 +264,7 @@ void extractToCacheThrowsWhenClasspathResourceMissing(@TempDir Path tempDir) {
@Test
void extractToCacheThrowsWhenNativeMetadataMissing(@TempDir Path tempDir) throws Exception {
+ assumeLinuxX64();
Path resourceDir = tempDir.resolve("native").resolve(TEST_CLASSIFIER);
Files.createDirectories(resourceDir);
Files.write(resourceDir.resolve(NativeRuntimeLoader.RUNTIME_FILENAME), FAKE_BINARY_CONTENT);
@@ -270,6 +278,7 @@ void extractToCacheThrowsWhenNativeMetadataMissing(@TempDir Path tempDir) throws
@Test
void extractedBinaryContentsMatchClasspathResource(@TempDir Path tempDir) throws Exception {
+ assumeLinuxX64();
Path cacheBase = tempDir.resolve("cache");
ClassLoader loader = classLoaderWithRuntimeResource(tempDir, TEST_CLASSIFIER);
@@ -281,6 +290,7 @@ void extractedBinaryContentsMatchClasspathResource(@TempDir Path tempDir) throws
@Test
void extractToCacheFiltersClasspathByClassifier(@TempDir Path tempDir) throws Exception {
+ assumeLinuxX64();
Path cacheBase = tempDir.resolve("cache");
writeRuntimeResource(tempDir, TEST_CLASSIFIER, FAKE_BINARY_CONTENT);
writeRuntimeResource(tempDir, OTHER_CLASSIFIER, OTHER_BINARY_CONTENT);
@@ -294,6 +304,7 @@ void extractToCacheFiltersClasspathByClassifier(@TempDir Path tempDir) throws Ex
@Test
void extractToCacheRepairsInvalidCacheEntry(@TempDir Path tempDir) throws Exception {
+ assumeLinuxX64();
Path cacheBase = tempDir.resolve("cache");
Path cached = cacheBase.resolve(TEST_VERSION).resolve(TEST_NATIVE_VERSION).resolve(TEST_CLASSIFIER)
.resolve(NativeRuntimeLoader.RUNTIME_FILENAME);
@@ -309,13 +320,13 @@ void extractToCacheRepairsInvalidCacheEntry(@TempDir Path tempDir) throws Except
@Test
void nonExecutableCachedCliIsNotAcceptedAsValid(@TempDir Path tempDir) throws Exception {
- assumeTrue(!TEST_CLASSIFIER.startsWith("win32"));
+ assumeLinuxX64();
assumeTrue(Files.getFileStore(tempDir).supportsFileAttributeView("posix"));
Path cacheBase = tempDir.resolve("cache");
Path cacheDir = cacheBase.resolve(TEST_VERSION).resolve(TEST_NATIVE_VERSION).resolve(TEST_CLASSIFIER);
Files.createDirectories(cacheDir);
Files.write(cacheDir.resolve(NativeRuntimeLoader.RUNTIME_FILENAME), FAKE_BINARY_CONTENT);
- Path cachedCli = Files.write(cacheDir.resolve(TEST_CLI_FILENAME), FAKE_CLI_CONTENT);
+ Path cachedCli = Files.write(cacheDir.resolve(NativeRuntimeLoader.CLI_FILENAME), FAKE_CLI_CONTENT);
Files.setPosixFilePermissions(cachedCli, PosixFilePermissions.fromString("rw-------"));
ClassLoader loader = classLoaderWithRuntimeAndCliResources(tempDir, TEST_CLASSIFIER);
@@ -330,6 +341,7 @@ void nonExecutableCachedCliIsNotAcceptedAsValid(@TempDir Path tempDir) throws Ex
@Test
void bundledCliSiblingIsUsedWhenClasspathResourceAbsent(@TempDir Path tempDir) throws Exception {
+ assumeLinuxX64();
Path bundledCliDir = tempDir.resolve("bundled-cli");
Files.createDirectories(bundledCliDir);
Path runtimeNode = bundledCliDir.resolve(NativeRuntimeLoader.RUNTIME_FILENAME);
@@ -347,6 +359,7 @@ void bundledCliSiblingIsUsedWhenClasspathResourceAbsent(@TempDir Path tempDir) t
@Test
void classpathResourceWinsOverBundledCliSibling(@TempDir Path tempDir) throws Exception {
+ assumeLinuxX64();
// Source 3: bundled CLI dir with runtime.node (should NOT win)
Path bundledCliDir = tempDir.resolve("bundled-cli");
Files.createDirectories(bundledCliDir);
@@ -368,6 +381,7 @@ void classpathResourceWinsOverBundledCliSibling(@TempDir Path tempDir) throws Ex
@Test
void bundledCliSiblingIsIgnoredWhenRuntimeNodeMissing(@TempDir Path tempDir) {
+ assumeLinuxX64();
Path bundledCliDir = tempDir.resolve("bundled-cli-no-runtime");
// bundledCliDir doesn't even exist — no runtime.node present
@@ -399,12 +413,12 @@ void defaultPublisherMovesSourceToTarget(@TempDir Path tempDir) throws Exception
@Test
void cliIsExecutableBeforeAtomicPublication(@TempDir Path tempDir) throws Exception {
- assumeTrue(!TEST_CLASSIFIER.startsWith("win32"));
+ assumeLinuxX64();
assumeTrue(Files.getFileStore(tempDir).supportsFileAttributeView("posix"));
Path cacheBase = tempDir.resolve("cache");
ClassLoader loader = classLoaderWithRuntimeAndCliResources(tempDir, TEST_CLASSIFIER);
NativeRuntimeLoader.AtomicPublisher publisher = (temp, cached) -> {
- if (cached.getFileName().toString().equals(TEST_CLI_FILENAME)) {
+ if (cached.getFileName().toString().equals(NativeRuntimeLoader.CLI_FILENAME)) {
assertTrue(Files.isExecutable(temp), "CLI temp file must be executable before atomic publication");
}
Files.move(temp, cached, StandardCopyOption.REPLACE_EXISTING);
@@ -415,6 +429,7 @@ void cliIsExecutableBeforeAtomicPublication(@TempDir Path tempDir) throws Except
@Test
void extractionCleansUpTempFileWhenPublicationFails(@TempDir Path tempDir) throws Exception {
+ assumeLinuxX64();
Path cacheBase = tempDir.resolve("cache");
ClassLoader loader = classLoaderWithRuntimeResource(tempDir, TEST_CLASSIFIER);
@@ -435,6 +450,7 @@ void extractionCleansUpTempFileWhenPublicationFails(@TempDir Path tempDir) throw
@Test
void extractionCleansUpTempFileWhenPublisherThrowsIllegalStateException(@TempDir Path tempDir) throws Exception {
+ assumeLinuxX64();
Path cacheBase = tempDir.resolve("cache");
ClassLoader loader = classLoaderWithRuntimeResource(tempDir, TEST_CLASSIFIER);
@@ -462,6 +478,7 @@ void extractionCleansUpTempFileWhenPublisherThrowsIllegalStateException(@TempDir
@Test
void concurrentExtractionByMultipleThreadsBothSucceed(@TempDir Path tempDir) throws Exception {
+ assumeLinuxX64();
Path cacheBase = tempDir.resolve("cache");
ClassLoader loader = classLoaderWithRuntimeResource(tempDir, TEST_CLASSIFIER);
int threadCount = 8;
@@ -499,6 +516,7 @@ void concurrentExtractionByMultipleThreadsBothSucceed(@TempDir Path tempDir) thr
@Test
void resolveWithNullCliEnvExtractsFromClasspath(@TempDir Path tempDir) throws Exception {
+ assumeLinuxX64();
Path cacheBase = tempDir.resolve("cache");
ClassLoader loader = classLoaderWithRuntimeResource(tempDir, TEST_CLASSIFIER);
@@ -509,8 +527,54 @@ void resolveWithNullCliEnvExtractsFromClasspath(@TempDir Path tempDir) throws Ex
assertTrue(Files.size(result) > 0);
}
+ @Test
+ void resolveRuntimeWrapperExtractsAdjacentPairFromAbsentCache(@TempDir Path tempDir) throws Exception {
+ Path cacheBase = tempDir.resolve("cache");
+ assertFalse(Files.exists(cacheBase));
+ ClassLoader loader = classLoaderWithRuntimeWrapperArtifacts(tempDir, TEST_CLASSIFIER, TEST_NATIVE_VERSION);
+
+ Path wrapper = NativeRuntimeLoader.resolveRuntimeWrapper(cacheBase, loader, TEST_CLASSIFIER, TEST_VERSION);
+
+ assertEquals(NativeRuntimeLoader.RUNTIME_WRAPPER_FILENAME, wrapper.getFileName().toString());
+ assertTrue(Files.isRegularFile(wrapper));
+ assertTrue(Files.isRegularFile(wrapper.resolveSibling(NativeRuntimeLoader.RUNTIME_FILENAME)));
+ assertFalse(Files.exists(wrapper.resolveSibling(NativeRuntimeLoader.CLI_FILENAME)));
+ }
+
+ @Test
+ void resolveRuntimeWrapperExtractsRetainedRuntimeAssets(@TempDir Path tempDir) throws Exception {
+ Path resourceDir = tempDir.resolve("native").resolve(TEST_CLASSIFIER);
+ writeRuntimeResource(tempDir, TEST_CLASSIFIER, FAKE_BINARY_CONTENT);
+ Files.write(resourceDir.resolve(NativeRuntimeLoader.RUNTIME_WRAPPER_FILENAME), FAKE_WRAPPER_CONTENT);
+ Path ripgrep = resourceDir.resolve("ripgrep/bin/linux-x64/rg");
+ Files.createDirectories(ripgrep.getParent());
+ Files.writeString(ripgrep, "ripgrep");
+ Files.writeString(resourceDir.resolve(NativeRuntimeLoader.RUNTIME_ASSETS_FILENAME),
+ "644\truntime.node\n" + "755\tcopilot-runtime\n" + "755\tripgrep/bin/linux-x64/rg\n");
+ ClassLoader loader = new URLClassLoader(new URL[]{tempDir.toUri().toURL()}, null);
+
+ Path wrapper = NativeRuntimeLoader.resolveRuntimeWrapper(tempDir.resolve("cache"), loader, TEST_CLASSIFIER,
+ TEST_VERSION);
+
+ Path installedRipgrep = wrapper.getParent().resolve("ripgrep/bin/linux-x64/rg");
+ assertEquals("ripgrep", Files.readString(installedRipgrep));
+ assertTrue(Files.isExecutable(installedRipgrep));
+ }
+
+ @Test
+ void resolveRuntimeWrapperRejectsClassifierWithoutWrapper(@TempDir Path tempDir) throws Exception {
+ writeRuntimeResource(tempDir, TEST_CLASSIFIER, FAKE_BINARY_CONTENT);
+ ClassLoader loader = new URLClassLoader(new URL[]{tempDir.toUri().toURL()}, null);
+
+ IOException error = assertThrows(IOException.class, () -> NativeRuntimeLoader
+ .resolveRuntimeWrapper(tempDir.resolve("cache"), loader, TEST_CLASSIFIER, TEST_VERSION));
+
+ assertTrue(error.getMessage().contains(NativeRuntimeLoader.RUNTIME_WRAPPER_FILENAME));
+ }
+
@Test
void resolveThrowsWhenNoSourceIsAvailable(@TempDir Path tempDir) {
+ assumeLinuxX64();
Path cacheBase = tempDir.resolve("cache");
ClassLoader emptyLoader = new URLClassLoader(new URL[0], null);
@@ -521,6 +585,7 @@ void resolveThrowsWhenNoSourceIsAvailable(@TempDir Path tempDir) {
@Test
void resolveFallsBackToRuntimeAlongsideBundledCli(@TempDir Path tempDir) throws Exception {
+ assumeLinuxX64();
Path cacheBase = tempDir.resolve("cache");
ClassLoader emptyLoader = new URLClassLoader(new URL[0], null);
Path bundledCli = tempDir.resolve("copilot");
@@ -538,6 +603,17 @@ void resolveFallsBackToRuntimeAlongsideBundledCli(@TempDir Path tempDir) throws
// Helpers
// -------------------------------------------------------------------------
+ private static void assumeLinuxX64() {
+ String actualClassifier;
+ try {
+ actualClassifier = PlatformDetector.detectClassifier();
+ } catch (IllegalStateException ex) {
+ actualClassifier = "unsupported";
+ }
+ assumeTrue(TEST_CLASSIFIER.equals(actualClassifier),
+ "Requires linux-x64; detected " + actualClassifier + "; see #2323");
+ }
+
private static ClassLoader classLoaderWithVersionResource(Path tempDir, String version) throws IOException {
Path propsFile = tempDir.resolve(NativeRuntimeLoader.VERSION_RESOURCE);
Files.writeString(propsFile, "version=" + version + "\n");
@@ -553,7 +629,7 @@ private static ClassLoader classLoaderWithRuntimeAndCliResources(Path tempDir, S
throws IOException {
writeRuntimeResource(tempDir, classifier, FAKE_BINARY_CONTENT);
Path resourceDir = tempDir.resolve("native").resolve(classifier);
- Files.write(resourceDir.resolve(TEST_CLI_FILENAME), FAKE_CLI_CONTENT);
+ Files.write(resourceDir.resolve(NativeRuntimeLoader.CLI_FILENAME), FAKE_CLI_CONTENT);
return new URLClassLoader(new URL[]{tempDir.toUri().toURL()}, null);
}
@@ -561,7 +637,19 @@ private static ClassLoader classLoaderWithNativeArtifacts(Path tempDir, String c
byte[] runtimeContent, byte[] cliContent) throws IOException {
writeRuntimeResource(tempDir, classifier, runtimeContent);
Path resourceDir = tempDir.resolve("native").resolve(classifier);
- Files.write(resourceDir.resolve(TEST_CLI_FILENAME), cliContent);
+ Files.write(resourceDir.resolve(NativeRuntimeLoader.CLI_FILENAME), cliContent);
+ Files.write(resourceDir.resolve(NativeRuntimeLoader.RUNTIME_WRAPPER_FILENAME), FAKE_WRAPPER_CONTENT);
+ Files.writeString(resourceDir.resolve("platform.properties"),
+ "classifier=" + classifier + "\nversion=" + nativeVersion + "\n");
+ return new URLClassLoader(new URL[]{tempDir.toUri().toURL()}, null);
+ }
+
+ private static ClassLoader classLoaderWithRuntimeWrapperArtifacts(Path tempDir, String classifier,
+ String nativeVersion) throws IOException {
+ writeRuntimeResource(tempDir, classifier, FAKE_BINARY_CONTENT);
+ Path resourceDir = tempDir.resolve("native").resolve(classifier);
+ Files.write(resourceDir.resolve(NativeRuntimeLoader.RUNTIME_WRAPPER_FILENAME), FAKE_WRAPPER_CONTENT);
+ Files.write(resourceDir.resolve(NativeRuntimeLoader.CLI_FILENAME), FAKE_CLI_CONTENT);
Files.writeString(resourceDir.resolve("platform.properties"),
"classifier=" + classifier + "\nversion=" + nativeVersion + "\n");
return new URLClassLoader(new URL[]{tempDir.toUri().toURL()}, null);
diff --git a/nodejs/README.md b/nodejs/README.md
index 93f9c3fa6b..b517b5a47e 100644
--- a/nodejs/README.md
+++ b/nodejs/README.md
@@ -95,6 +95,7 @@ new CopilotClient(options?: CopilotClientOptions)
- `RuntimeConnection.forUri(url, { connectionToken? })` — connect to an already-running runtime (mutually exclusive with `gitHubToken`/`useLoggedInUser`). There is no top-level `cliUrl` shortcut; use this factory for URL-based connections.
- `RuntimeConnection.forInProcess()` — host the runtime in-process over its native C ABI (FFI). **Experimental.** Because the runtime shares this process, `env`, `telemetry`, and `workingDirectory` are rejected with this transport; set them on the host process instead.
- The child-process transports (`forStdio`/`forTcp`) also accept a per-connection `env`. Set it there or via the top-level `env` option — not both (setting both throws).
+ - Managed child-process connections materialize the bundled `copilot-runtime` and adjacent `runtime.node`, then launch the wrapper by default. An explicit connection `path` or `COPILOT_CLI_PATH` overrides the bundled runtime.
- `mode?: "empty" | "copilot-cli"` - Defaulting strategy. Use `"empty"` for multi-user server mode; defaults to `"copilot-cli"`.
- `workingDirectory?: string` - Working directory for the runtime process (default: current process cwd).
- `baseDirectory?: string` - Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime. When not set, the runtime defaults to `~/.copilot`. Ignored when connecting via `RuntimeConnection.forUri`.
diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts
index 9b853aa597..c27e0f2508 100644
--- a/nodejs/src/client.ts
+++ b/nodejs/src/client.ts
@@ -16,7 +16,7 @@ import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { Socket } from "node:net";
-import { dirname, isAbsolute, join } from "node:path";
+import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
createMessageConnection,
@@ -43,6 +43,7 @@ import type {
import { getSdkProtocolVersion } from "./sdkProtocolVersion.js";
import { CopilotSession } from "./session.js";
import type { FfiRuntimeHost } from "./ffiRuntimeHost.js";
+import { materializeRuntimeBundle } from "./runtimeArtifacts.js";
import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js";
import { createCopilotRequestAdapter } from "./copilotRequestHandler.js";
import type { CopilotRequestHandler } from "./copilotRequestHandler.js";
@@ -365,16 +366,19 @@ function getCliPlatformPackageNames(): string[] {
return variants.map((variant) => `@github/copilot-${variant}-${arch}`);
}
+interface BundledCliPackage {
+ root: string;
+ platform: string;
+}
+
/**
- * Gets the path to the bundled CLI from the platform-specific @github/copilot-*
- * package. Uses index.js directly rather than the native binary so the CLI runs
- * under the current Node.js runtime.
+ * Resolves the current platform package and its npm prebuilds folder.
*
* In ESM, uses import.meta.resolve directly. In CJS (e.g., VS Code extensions
* bundled with esbuild format:"cjs"), import.meta is empty so we fall back to
* walking node_modules to find the package.
*/
-function getBundledCliPath(): string {
+function getBundledCliPackage(): BundledCliPackage {
const packageNames = getCliPlatformPackageNames();
if (typeof import.meta.resolve === "function") {
@@ -383,7 +387,10 @@ function getBundledCliPath(): string {
try {
const packageEntryUrl = import.meta.resolve(packageName);
const packageEntryPath = fileURLToPath(packageEntryUrl);
- return join(dirname(packageEntryPath), "index.js");
+ return {
+ root: dirname(packageEntryPath),
+ platform: packageName.slice("@github/copilot-".length),
+ };
} catch {
// Try the next candidate platform package.
}
@@ -400,9 +407,13 @@ function getBundledCliPath(): string {
const searchPaths = req.resolve.paths("@github/copilot") ?? [];
for (const base of searchPaths) {
for (const packageName of packageNames) {
- const candidate = join(base, ...packageName.split("/"), "index.js");
+ const root = join(base, ...packageName.split("/"));
+ const candidate = join(root, "index.js");
if (existsSync(candidate)) {
- return candidate;
+ return {
+ root,
+ platform: packageName.slice("@github/copilot-".length),
+ };
}
}
}
@@ -413,6 +424,14 @@ function getBundledCliPath(): string {
);
}
+function getBundledRuntimePath(): string {
+ const bundled = getBundledCliPackage();
+ return materializeRuntimeBundle({
+ packageRoot: bundled.root,
+ platform: bundled.platform,
+ });
+}
+
/**
* Main client for interacting with the Copilot CLI.
*
@@ -733,10 +752,14 @@ export class CopilotClient {
conn.kind === "stdio" || conn.kind === "tcp" ? conn.env : undefined;
const effectiveEnv = connEnv ?? options.env ?? process.env;
this.resolvedEnv = effectiveEnv;
- this.resolvedCliPath =
- conn.kind === "stdio" || conn.kind === "tcp"
- ? (conn.path ?? effectiveEnv.COPILOT_CLI_PATH ?? getBundledCliPath())
- : undefined;
+ if (conn.kind === "stdio" || conn.kind === "tcp") {
+ const explicitCliPath = conn.path ?? effectiveEnv.COPILOT_CLI_PATH;
+ if (explicitCliPath) {
+ this.resolvedCliPath = explicitCliPath;
+ } else {
+ this.resolvedCliPath = getBundledRuntimePath();
+ }
+ }
// Collect extra CLI args from the connection variant (if any).
const connArgs: readonly string[] =
@@ -997,6 +1020,7 @@ export class CopilotClient {
this.state = "connected";
} catch (error) {
+ await this.forceStop();
this.state = "error";
throw error;
}
@@ -2715,21 +2739,21 @@ export class CopilotClient {
// Set up a promise that rejects when the process exits (used to race against RPC calls)
this.processExitPromise = new Promise((_, rejectProcessExit) => {
this.cliProcess!.on("exit", (code) => {
- // Give a small delay for stderr to be fully captured
- setTimeout(() => {
- const stderrOutput = this.stderrBuffer.trim();
- if (stderrOutput) {
- rejectProcessExit(
- new Error(
- `CLI server exited with code ${code}\nstderr: ${stderrOutput}`
- )
- );
- } else {
- rejectProcessExit(
- new Error(`CLI server exited unexpectedly with code ${code}`)
- );
- }
- }, 50);
+ if (this.messageWriter) {
+ this.messageWriter.suppressWriteErrors = true;
+ }
+ const stderrOutput = this.stderrBuffer.trim();
+ if (stderrOutput) {
+ rejectProcessExit(
+ new Error(
+ `CLI server exited with code ${code}\nstderr: ${stderrOutput}`
+ )
+ );
+ } else {
+ rejectProcessExit(
+ new Error(`CLI server exited unexpectedly with code ${code}`)
+ );
+ }
});
});
// Prevent unhandled rejection when process exits normally (we only use this in Promise.race)
@@ -2780,7 +2804,15 @@ export class CopilotClient {
/** Starts the in-process FFI runtime with SDK-managed typed options. */
private async startInProcessFfi(): Promise {
- const entrypoint = this.resolveCliPathForFfi();
+ const explicitEntrypoint = this.resolvedEnv.COPILOT_CLI_PATH;
+ const runtimeLibrary = explicitEntrypoint
+ ? join(
+ dirname(resolve(explicitEntrypoint)),
+ "prebuilds",
+ CopilotClient.getNapiPrebuildsFolder(explicitEntrypoint),
+ "runtime.node"
+ )
+ : join(dirname(getBundledRuntimePath()), "runtime.node");
// Load the FFI host lazily so the native `koffi` addon (and its
// platform-specific `koffi.node`) is only loaded on the in-process path;
// out-of-process (stdio/tcp) consumers never touch the native dependency.
@@ -2815,12 +2847,7 @@ export class CopilotClient {
args.push("--remote");
}
- const host = FfiRuntimeHost.create(
- entrypoint,
- CopilotClient.getNapiPrebuildsFolder(entrypoint),
- environment,
- args
- );
+ const host = FfiRuntimeHost.create(runtimeLibrary, explicitEntrypoint, environment, args);
this.ffiHost = host;
await host.start();
}
@@ -2843,20 +2870,6 @@ export class CopilotClient {
this.connection.listen();
}
- /**
- * Resolves the CLI entrypoint used for in-process FFI hosting: `COPILOT_CLI_PATH`
- * when set, otherwise the bundled platform-package entrypoint.
- */
- private resolveCliPathForFfi(): string {
- return this.resolvedEnv.COPILOT_CLI_PATH ?? getBundledCliPath();
- }
-
- /**
- * Returns the napi prebuilds folder name for the current host — the
- * `-` convention (e.g. `win32-x64`, `darwin-arm64`,
- * `linux-x64`, `linuxmusl-x64`) under which the runtime ships
- * `prebuilds//runtime.node`.
- */
private static getNapiPrebuildsFolder(entrypoint: string): string {
const arch = process.arch;
if (arch !== "x64" && arch !== "arm64") {
diff --git a/nodejs/src/ffiRuntimeHost.ts b/nodejs/src/ffiRuntimeHost.ts
index a92aa1589a..4795e325ce 100644
--- a/nodejs/src/ffiRuntimeHost.ts
+++ b/nodejs/src/ffiRuntimeHost.ts
@@ -7,10 +7,8 @@
* and speaking JSON-RPC over its C ABI (FFI) instead of spawning a CLI child process
* and communicating over stdio/TCP.
*
- * The native `host_start` export spawns the CLI worker itself
- * (`node --embedded-host` for a `.js` entrypoint, or `
- * --embedded-host` for a packaged binary), so the SDK never launches the worker
- * directly. LSP `Content-Length:`-framed JSON-RPC bytes are pumped across the ABI:
+ * The native `host_start` export constructs the Rust server synchronously in this
+ * process. LSP `Content-Length:`-framed JSON-RPC bytes are pumped across the ABI:
* writes go to `connection_write`; inbound frames arrive on a native callback that
* feeds {@link FfiRuntimeHost.receiveStream}. The existing `vscode-jsonrpc`
* `StreamMessageReader`/`StreamMessageWriter` handle framing unchanged — this is a
@@ -19,7 +17,7 @@
import { existsSync } from "node:fs";
import koffi from "koffi";
-import { dirname, join, resolve } from "node:path";
+import { resolve } from "node:path";
import { PassThrough, Writable } from "node:stream";
const SYMBOL_PREFIX = "copilot_runtime_";
@@ -97,14 +95,12 @@ function loadLibrary(libraryPath: string): FfiLibrary {
return loadedLibrary;
}
-function buildArgvJson(cliEntrypoint: string, args: readonly string[]): Buffer {
- // A `.js` entrypoint is launched via node; the packaged single-file CLI binary
- // embeds its own Node and is invoked directly. `--no-auto-update` pins the worker
- // to the bundled pkg matching the loaded cdylib, instead of drifting to a newer
- // version installed under the user's `~/.copilot/pkg` (which would cause ABI skew).
- const argv = cliEntrypoint.toLowerCase().endsWith(".js")
- ? ["node", cliEntrypoint, "--embedded-host", "--no-auto-update"]
- : [cliEntrypoint, "--embedded-host", "--no-auto-update"];
+function buildArgvJson(cliEntrypoint: string | undefined, args: readonly string[]): Buffer {
+ const argv = cliEntrypoint
+ ? cliEntrypoint.toLowerCase().endsWith(".js")
+ ? ["node", cliEntrypoint, "--embedded-host", "--no-auto-update"]
+ : [cliEntrypoint, "--embedded-host", "--no-auto-update"]
+ : [];
argv.push(...args);
return Buffer.from(JSON.stringify(argv), "utf8");
}
@@ -140,7 +136,7 @@ export class FfiRuntimeHost {
private constructor(
private readonly libraryPath: string,
- private readonly cliEntrypoint: string,
+ private readonly cliEntrypoint: string | undefined,
private readonly environment: Record | undefined,
private readonly args: readonly string[]
) {
@@ -161,41 +157,38 @@ export class FfiRuntimeHost {
}
/**
- * Resolves the cdylib next to the given CLI entrypoint and prepares the FFI host.
- * The cdylib is resolved as `prebuilds//runtime.node` relative to
- * the entrypoint directory (the napi-rs `-` layout, e.g.
- * `linux-x64`). Throws if it cannot be found.
+ * Loads the runtime cdylib at the given path and prepares the FFI host.
*/
static create(
- cliEntrypoint: string,
- prebuildsFolder: string,
+ libraryPath: string,
+ cliEntrypoint: string | undefined,
environment: Record | undefined,
args: readonly string[]
): FfiRuntimeHost {
- const fullEntrypoint = resolve(cliEntrypoint);
- const distDir = dirname(fullEntrypoint);
- const libraryPath = join(distDir, "prebuilds", prebuildsFolder, "runtime.node");
- if (!existsSync(libraryPath)) {
- throw new Error(`FFI runtime library not found. Looked for '${libraryPath}'.`);
+ const fullLibraryPath = resolve(libraryPath);
+ if (!existsSync(fullLibraryPath)) {
+ throw new Error(`FFI runtime library not found at '${fullLibraryPath}'.`);
}
- return new FfiRuntimeHost(libraryPath, fullEntrypoint, environment, args);
+ return new FfiRuntimeHost(
+ fullLibraryPath,
+ cliEntrypoint ? resolve(cliEntrypoint) : undefined,
+ environment,
+ args
+ );
}
- /**
- * Starts the in-process runtime: spawns the CLI worker via the native host,
- * waits for readiness, and opens the FFI JSON-RPC connection.
- */
+ /** Starts the in-process Rust runtime and opens the FFI JSON-RPC connection. */
async start(): Promise {
const argvJson = buildArgvJson(this.cliEntrypoint, this.args);
const envJson = buildEnvJson(this.environment);
- // The native host spawns the CLI worker itself and has no cwd parameter, so the
- // worker inherits this process's cwd. A custom working directory is intentionally
+ // The native host has no cwd parameter, so it uses this process's cwd. A custom
+ // working directory is intentionally
// unsupported for the in-process transport (rejected by the client constructor)
// rather than mutating the shared process-global cwd here.
- // host_start blocks until the worker connects back and signals readiness
- // (up to ~30s); run it as an async FFI call so the Node event loop isn't blocked.
+ // host_start constructs the native engine synchronously; run it as an async FFI
+ // call so the Node event loop isn't blocked.
this.serverId = await new Promise((resolvePromise, rejectPromise) => {
this.lib.hostStart.async(
argvJson,
@@ -212,9 +205,7 @@ export class FfiRuntimeHost {
);
});
if (!this.serverId) {
- throw new Error(
- `copilot_runtime_host_start failed (library '${this.libraryPath}', entrypoint '${this.cliEntrypoint}').`
- );
+ throw new Error(`copilot_runtime_host_start failed (library '${this.libraryPath}').`);
}
this.outboundCallback = koffi.register(
diff --git a/nodejs/src/runtimeArtifacts.ts b/nodejs/src/runtimeArtifacts.ts
new file mode 100644
index 0000000000..19d9926250
--- /dev/null
+++ b/nodejs/src/runtimeArtifacts.ts
@@ -0,0 +1,183 @@
+import { createHash } from "node:crypto";
+import {
+ chmodSync,
+ copyFileSync,
+ existsSync,
+ lstatSync,
+ mkdirSync,
+ mkdtempSync,
+ readdirSync,
+ renameSync,
+ rmSync,
+ statSync,
+} from "node:fs";
+import { homedir } from "node:os";
+import { dirname, join, relative, sep } from "node:path";
+
+export interface RuntimeArtifactSources {
+ packageRoot: string;
+ platform: string;
+}
+
+const EXCLUDED_TOP_LEVEL = new Set([
+ "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",
+]);
+
+interface RuntimeAsset {
+ source: string;
+ relativePath: string;
+}
+
+function validateFile(path: string, label: string): void {
+ if (!existsSync(path)) {
+ throw new Error(`${label} not found at ${path}.`);
+ }
+ if (statSync(path).size === 0) {
+ throw new Error(`${label} at ${path} is empty.`);
+ }
+}
+
+function validateRuntimeBundle(wrapper: string, runtimeNode: string): void {
+ validateFile(wrapper, "Copilot runtime wrapper");
+ validateFile(runtimeNode, "Copilot runtime.node");
+}
+
+function isExcluded(relativePath: string): boolean {
+ const parts = relativePath.split(sep);
+ const topLevel = parts[0];
+ const fileName = parts.at(-1) ?? "";
+ return (
+ EXCLUDED_TOP_LEVEL.has(topLevel) ||
+ /^tree-sitter.*\.wasm$/.test(topLevel) ||
+ /^voice-.*\.js$/.test(topLevel) ||
+ fileName === "cli-native.node" ||
+ parts.includes("mediaremote-adapter") ||
+ fileName.startsWith("copilot-runtime-bin")
+ );
+}
+
+function collectRuntimeAssets(sources: RuntimeArtifactSources): RuntimeAsset[] {
+ const assets: RuntimeAsset[] = [];
+ const visit = (directory: string): void => {
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
+ const source = join(directory, entry.name);
+ const sourceRelative = relative(sources.packageRoot, source);
+ if (isExcluded(sourceRelative)) {
+ continue;
+ }
+ if (entry.isDirectory()) {
+ visit(source);
+ continue;
+ }
+ if (!entry.isFile() && !entry.isSymbolicLink()) {
+ continue;
+ }
+
+ const parts = sourceRelative.split(sep);
+ let relativePath = sourceRelative;
+ if (parts[0] === "prebuilds") {
+ if (parts[1] !== sources.platform || parts.length < 3) {
+ continue;
+ }
+ relativePath = parts.slice(2).join(sep);
+ }
+ assets.push({ source, relativePath });
+ }
+ };
+ visit(sources.packageRoot);
+ return assets.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
+}
+
+function sourceFingerprint(assets: RuntimeAsset[]): string {
+ const hash = createHash("sha256");
+ for (const asset of assets) {
+ const stat = lstatSync(asset.source);
+ hash.update(asset.relativePath).update("\0");
+ hash.update(`${stat.size}:${stat.mtimeMs}`).update("\0");
+ }
+ return hash.digest("hex").slice(0, 20);
+}
+
+function makeExecutable(path: string): void {
+ if (process.platform === "win32") {
+ return;
+ }
+ const mode = statSync(path).mode;
+ if ((mode & 0o111) === 0) {
+ chmodSync(path, mode | 0o111);
+ }
+}
+
+export function defaultRuntimeCacheRoot(
+ platform = process.platform,
+ home = homedir(),
+ environment: NodeJS.ProcessEnv = process.env
+): string {
+ const cacheDirectory =
+ platform === "win32"
+ ? (environment.LOCALAPPDATA ?? join(home, "AppData", "Local"))
+ : platform === "darwin"
+ ? join(home, "Library", "Caches")
+ : (environment.XDG_CACHE_HOME ?? join(home, ".cache"));
+ return join(cacheDirectory, "github-copilot-sdk", "runtime");
+}
+
+export function materializeRuntimeBundle(
+ sources: RuntimeArtifactSources,
+ cacheRoot = defaultRuntimeCacheRoot()
+): string {
+ const assets = collectRuntimeAssets(sources);
+ const wrapperName = process.platform === "win32" ? "copilot-runtime.exe" : "copilot-runtime";
+ const sourceWrapper = assets.find((asset) => asset.relativePath === wrapperName)?.source;
+ const sourceRuntimeNode = assets.find((asset) => asset.relativePath === "runtime.node")?.source;
+ validateRuntimeBundle(sourceWrapper ?? "", sourceRuntimeNode ?? "");
+
+ const installDir = join(cacheRoot, `${sources.platform}-${sourceFingerprint(assets)}`);
+ const installedWrapper = join(installDir, wrapperName);
+ const installedRuntimeNode = join(installDir, "runtime.node");
+ if (existsSync(installDir)) {
+ validateRuntimeBundle(installedWrapper, installedRuntimeNode);
+ makeExecutable(installedWrapper);
+ return installedWrapper;
+ }
+
+ mkdirSync(cacheRoot, { recursive: true });
+ const stagingDir = mkdtempSync(join(cacheRoot, ".runtime-"));
+ try {
+ for (const asset of assets) {
+ const destination = join(stagingDir, asset.relativePath);
+ mkdirSync(dirname(destination), { recursive: true });
+ copyFileSync(asset.source, destination);
+ }
+ const stagedWrapper = join(stagingDir, wrapperName);
+ makeExecutable(stagedWrapper);
+ renameSync(stagingDir, installDir);
+ } catch (error) {
+ if (!existsSync(installDir)) {
+ throw error;
+ }
+ validateRuntimeBundle(installedWrapper, installedRuntimeNode);
+ } finally {
+ rmSync(stagingDir, { recursive: true, force: true });
+ }
+
+ return installedWrapper;
+}
diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts
index 3ffda2fa71..8f4cfaf1b7 100644
--- a/nodejs/test/client.test.ts
+++ b/nodejs/test/client.test.ts
@@ -60,6 +60,28 @@ describe("approveAll", () => {
});
describe("CopilotClient", () => {
+ it.each([
+ {
+ source: "connection path",
+ connection: RuntimeConnection.forStdio({ path: "/explicit/copilot" }),
+ env: {},
+ expected: "/explicit/copilot",
+ },
+ {
+ source: "COPILOT_CLI_PATH",
+ connection: RuntimeConnection.forStdio(),
+ env: { COPILOT_CLI_PATH: "/environment/copilot" },
+ expected: "/environment/copilot",
+ },
+ ])(
+ "preserves explicit child-process override from $source",
+ ({ connection, env, expected }) => {
+ const client = new CopilotClient({ connection, env });
+
+ expect((client as any).resolvedCliPath).toBe(expected);
+ }
+ );
+
async function startWithMockConnection(
builtinPluginDirectories?: readonly string[]
): Promise> {
diff --git a/nodejs/test/e2e/builtin_tools.e2e.test.ts b/nodejs/test/e2e/builtin_tools.e2e.test.ts
index 36b70ea195..39900bc7d6 100644
--- a/nodejs/test/e2e/builtin_tools.e2e.test.ts
+++ b/nodejs/test/e2e/builtin_tools.e2e.test.ts
@@ -130,6 +130,19 @@ describe("Built-in Tools", async () => {
async () => {
await writeFile(join(workDir, "data.txt"), "apple\nbanana\napricot\ncherry\n");
const session = await client.createSession({ onPermissionRequest: approveAll });
+ let grepToolCallId: string | undefined;
+ let grepCompletedSuccessfully = false;
+ session.on((event) => {
+ if (event.type === "tool.execution_start" && event.data.toolName === "grep") {
+ grepToolCallId = event.data.toolCallId;
+ } else if (
+ event.type === "tool.execution_complete" &&
+ event.data.toolCallId === grepToolCallId &&
+ event.data.success
+ ) {
+ grepCompletedSuccessfully = true;
+ }
+ });
const msg = await session.sendAndWait(
{
prompt: "Search for lines starting with 'ap' in the file 'data.txt'. Tell me which lines matched.",
@@ -138,6 +151,7 @@ describe("Built-in Tools", async () => {
);
expect(msg?.data.content).toContain("apple");
expect(msg?.data.content).toContain("apricot");
+ expect(grepCompletedSuccessfully).toBe(true);
},
TEST_TIMEOUT_MS
);
diff --git a/nodejs/test/e2e/client.e2e.test.ts b/nodejs/test/e2e/client.e2e.test.ts
index 35e7440766..52d2d4593d 100644
--- a/nodejs/test/e2e/client.e2e.test.ts
+++ b/nodejs/test/e2e/client.e2e.test.ts
@@ -184,29 +184,34 @@ describe("Client", () => {
await client.stop();
});
- it("should report error with stderr when CLI fails to start", async () => {
- const client = new CopilotClient({
- connection: RuntimeConnection.forStdio({ args: ["--nonexistent-flag-for-testing"] }),
- });
- onTestFinishedStop(client);
-
- let initialError: Error | undefined;
- try {
- await client.start();
- expect.fail("Expected start() to throw an error");
- } catch (error) {
- initialError = error as Error;
- expect(initialError.message).toContain("stderr");
- expect(initialError.message).toContain("nonexistent");
- }
+ it.skipIf(isInProcessTransport)(
+ "should report error with stderr when CLI fails to start",
+ async () => {
+ const client = new CopilotClient({
+ connection: RuntimeConnection.forStdio({
+ args: ["--nonexistent-flag-for-testing"],
+ }),
+ });
+ onTestFinishedStop(client);
+
+ let initialError: Error | undefined;
+ try {
+ await client.start();
+ expect.fail("Expected start() to throw an error");
+ } catch (error) {
+ initialError = error as Error;
+ expect(initialError.message).toContain("stderr");
+ expect(initialError.message).toContain("nonexistent");
+ }
- // Verify subsequent calls also fail (don't hang)
- try {
- const session = await client.createSession({ onPermissionRequest: approveAll });
- await session.send("test");
- expect.fail("Expected send() to throw an error after CLI exit");
- } catch (error) {
- expect((error as Error).message).toContain("Connection is closed");
+ // Verify subsequent calls also fail (don't hang)
+ try {
+ const session = await client.createSession({ onPermissionRequest: approveAll });
+ await session.send("test");
+ expect.fail("Expected send() to throw an error after CLI exit");
+ } catch (error) {
+ expect(error).toBeInstanceOf(Error);
+ }
}
- });
+ );
});
diff --git a/nodejs/test/e2e/extension_env_access.e2e.test.ts b/nodejs/test/e2e/extension_env_access.e2e.test.ts
index f034db9051..38210a22fb 100644
--- a/nodejs/test/e2e/extension_env_access.e2e.test.ts
+++ b/nodejs/test/e2e/extension_env_access.e2e.test.ts
@@ -14,9 +14,9 @@ import {
StreamMessageReader,
StreamMessageWriter,
} from "vscode-jsonrpc/node.js";
-import { approveAll } from "../../src/index.js";
+import { approveAll, RuntimeConnection } from "../../src/index.js";
import { getSdkProtocolVersion } from "../../src/sdkProtocolVersion.js";
-import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext.js";
+import { createSdkTestContext, getLegacyCliPathForTests } from "./harness/sdkTestContext.js";
import { retry } from "./harness/sdkTestHelper.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -184,56 +184,47 @@ it("ignores a granted variable the extension never requested", async () => {
expect(run.postjoin).toBe("E2E_SDK_TOKEN=granted-token\nE2E_SDK_SMUGGLED=");
});
-const cliObservations = isInProcessTransport
- ? ""
- : mkdtempSync(join(tmpdir(), "copilot-env-access-cli-"));
+const cliObservations = mkdtempSync(join(tmpdir(), "copilot-env-access-cli-"));
const cliResultFile = join(cliObservations, "result");
-const cliContext = isInProcessTransport
- ? undefined
- : await createSdkTestContext({
- copilotClientOptions: {
- env: {
- COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS",
- EXTENSION_ENV_REQUEST: "E2E_SDK_TOKEN",
- EXTENSION_RESULT_FILE: cliResultFile,
- EXTENSION_PREJOIN_FILE: join(cliObservations, "prejoin"),
- EXTENSION_POSTJOIN_FILE: join(cliObservations, "postjoin"),
- },
- },
- });
+const cliContext = await createSdkTestContext({
+ copilotClientOptions: {
+ connection: RuntimeConnection.forStdio({ path: getLegacyCliPathForTests() }),
+ env: {
+ COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS",
+ EXTENSION_ENV_REQUEST: "E2E_SDK_TOKEN",
+ EXTENSION_RESULT_FILE: cliResultFile,
+ EXTENSION_PREJOIN_FILE: join(cliObservations, "prejoin"),
+ EXTENSION_POSTJOIN_FILE: join(cliObservations, "postjoin"),
+ },
+ },
+});
// The released CLI ignores `requestedEnvironmentVariables`, so this covers the
// half a real CLI can prove today: asking for variables does not break the join.
// It becomes the grant test once `@github/copilot` carries the host half.
-it.skipIf(isInProcessTransport)(
- "joins a real CLI that does not support environment requests",
- async () => {
- if (!cliContext) {
- throw new Error("Extension E2E requires an out-of-process transport");
- }
- const { workDir, copilotClient } = cliContext;
- const extensionDir = join(workDir, ".github", "extensions", "env-access");
- await rm(join(workDir, ".github"), { recursive: true, force: true });
- await rm(cliResultFile, { force: true });
- await mkdir(extensionDir, { recursive: true });
- await copyFile(FIXTURE, join(extensionDir, "extension.mjs"));
- execFileSync("git", ["init", "--quiet"], { cwd: workDir });
-
- await using _session = await copilotClient.createSession({
- requestExtensions: true,
- extensionSdkPath: DIST_DIR,
- onPermissionRequest: approveAll,
- });
+it("joins a real CLI that does not support environment requests", async () => {
+ const { workDir, copilotClient } = cliContext;
+ const extensionDir = join(workDir, ".github", "extensions", "env-access");
+ await rm(join(workDir, ".github"), { recursive: true, force: true });
+ await rm(cliResultFile, { force: true });
+ await mkdir(extensionDir, { recursive: true });
+ await copyFile(FIXTURE, join(extensionDir, "extension.mjs"));
+ execFileSync("git", ["init", "--quiet"], { cwd: workDir });
+
+ await using _session = await copilotClient.createSession({
+ requestExtensions: true,
+ extensionSdkPath: DIST_DIR,
+ onPermissionRequest: approveAll,
+ });
- await retry(
- "wait for the env-access extension to join the session",
- async () => {
- expect(existsSync(cliResultFile)).toBe(true);
- },
- 300,
- 100
- );
+ await retry(
+ "wait for the env-access extension to join the session",
+ async () => {
+ expect(existsSync(cliResultFile)).toBe(true);
+ },
+ 300,
+ 100
+ );
- expect(readFileSync(cliResultFile, "utf-8")).toBe("joined");
- }
-);
+ expect(readFileSync(cliResultFile, "utf-8")).toBe("joined");
+});
diff --git a/nodejs/test/e2e/factory.e2e.test.ts b/nodejs/test/e2e/factory.e2e.test.ts
index cddd8e47b0..3f613ebc0d 100644
--- a/nodejs/test/e2e/factory.e2e.test.ts
+++ b/nodejs/test/e2e/factory.e2e.test.ts
@@ -4,30 +4,25 @@ import { copyFile, mkdir, rm } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it, vi } from "vitest";
-import { approveAll, FactoryResumeError } from "../../src/index.js";
+import { approveAll, FactoryResumeError, RuntimeConnection } from "../../src/index.js";
import {
createSdkTestContext,
DEFAULT_GITHUB_TOKEN,
- isInProcessTransport,
+ getLegacyCliPathForTests,
} from "./harness/sdkTestContext.js";
import { retry } from "./harness/sdkTestHelper.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
-const factoryTestContext = isInProcessTransport
- ? undefined
- : await createSdkTestContext({
- copilotClientOptions: {
- env: {
- COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS,AGENT_FACTORIES",
- },
- },
- });
+const factoryTestContext = await createSdkTestContext({
+ copilotClientOptions: {
+ connection: RuntimeConnection.forStdio({ path: getLegacyCliPathForTests() }),
+ env: {
+ COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS,AGENT_FACTORIES",
+ },
+ },
+});
async function setupFactoryExtension(workDir: string, onPermissionRequest = approveAll) {
- if (!factoryTestContext) {
- throw new Error("Factory E2E requires the stdio transport");
- }
-
const { copilotClient, openAiEndpoint } = factoryTestContext;
const extensionDir = join(workDir, ".github", "extensions", "factory-smoke");
const readyFile = join(extensionDir, "ready");
@@ -73,25 +68,19 @@ async function setupFactoryExtension(workDir: string, onPermissionRequest = appr
return session;
}
-it.skipIf(isInProcessTransport)(
- "runs an extension-authored factory across the SDK process boundary",
- async () => {
- if (!factoryTestContext) {
- throw new Error("Factory E2E requires the stdio transport");
- }
- const { workDir } = factoryTestContext;
- await using session = await setupFactoryExtension(workDir);
-
- const result = await session.factory.run("argument-echo", {
- args: { source: "sdk-e2e", count: 11 },
- });
-
- expect(result).toMatchObject({
- status: "completed",
- result: { source: "sdk-e2e", count: 11 },
- });
- }
-);
+it("runs an extension-authored factory across the SDK process boundary", async () => {
+ const { workDir } = factoryTestContext;
+ await using session = await setupFactoryExtension(workDir);
+
+ const result = await session.factory.run("argument-echo", {
+ args: { source: "sdk-e2e", count: 11 },
+ });
+
+ expect(result).toMatchObject({
+ status: "completed",
+ result: { source: "sdk-e2e", count: 11 },
+ });
+});
// TODO(cli-1.0.81-2): the subagent request is rejected downstream under CLI 1.0.81-2, so the
// fixture reports didThrow: true. Re-enable once the runtime fix ships.
@@ -113,199 +102,171 @@ it.skip("forwards every declared subagent option to the runtime", async () => {
});
}, 60_000);
-it.skipIf(isInProcessTransport)(
- "throws FactoryResumeError with not_found for an unknown run",
- async () => {
- if (!factoryTestContext) {
- throw new Error("Factory E2E requires the stdio transport");
- }
- const { workDir } = factoryTestContext;
- await using session = await setupFactoryExtension(workDir);
-
- const error = await session.factory
- .resume("00000000-0000-0000-0000-000000000000")
- .catch((caught: unknown) => caught);
-
- expect(error).toBeInstanceOf(FactoryResumeError);
- expect((error as FactoryResumeError).code).toBe("not_found");
+it("throws FactoryResumeError with not_found for an unknown run", async () => {
+ if (!factoryTestContext) {
+ throw new Error("Factory E2E requires the stdio transport");
}
-);
-
-it.skipIf(isInProcessTransport)(
- "throws FactoryResumeError with non_resumable for a completed run",
- async () => {
- if (!factoryTestContext) {
- throw new Error("Factory E2E requires the stdio transport");
- }
- const { workDir } = factoryTestContext;
- await using session = await setupFactoryExtension(workDir);
-
- const run = await session.factory.run("argument-echo");
- const error = await session.factory.resume(run.runId).catch((caught: unknown) => caught);
-
- expect(error).toBeInstanceOf(FactoryResumeError);
- expect((error as FactoryResumeError).code).toBe("non_resumable");
+ const { workDir } = factoryTestContext;
+ await using session = await setupFactoryExtension(workDir);
+
+ const error = await session.factory
+ .resume("00000000-0000-0000-0000-000000000000")
+ .catch((caught: unknown) => caught);
+
+ expect(error).toBeInstanceOf(FactoryResumeError);
+ expect((error as FactoryResumeError).code).toBe("not_found");
+});
+
+it("throws FactoryResumeError with non_resumable for a completed run", async () => {
+ if (!factoryTestContext) {
+ throw new Error("Factory E2E requires the stdio transport");
+ }
+ const { workDir } = factoryTestContext;
+ await using session = await setupFactoryExtension(workDir);
+
+ const run = await session.factory.run("argument-echo");
+ const error = await session.factory.resume(run.runId).catch((caught: unknown) => caught);
+
+ expect(error).toBeInstanceOf(FactoryResumeError);
+ expect((error as FactoryResumeError).code).toBe("non_resumable");
+});
+
+it("runs a factory when its session denies every permission request", async () => {
+ if (!factoryTestContext) {
+ throw new Error("Factory E2E requires the stdio transport");
}
-);
-
-it.skipIf(isInProcessTransport)(
- "runs a factory when its session denies every permission request",
- async () => {
- if (!factoryTestContext) {
- throw new Error("Factory E2E requires the stdio transport");
- }
- const { workDir } = factoryTestContext;
- const denyPermissions = vi.fn(() => ({ kind: "reject" as const }));
- await using session = await setupFactoryExtension(workDir, denyPermissions);
-
- await expect(session.factory.run("argument-echo")).resolves.toMatchObject({
- status: "completed",
- });
- expect(denyPermissions).not.toHaveBeenCalled();
+ const { workDir } = factoryTestContext;
+ const denyPermissions = vi.fn(() => ({ kind: "reject" as const }));
+ await using session = await setupFactoryExtension(workDir, denyPermissions);
+
+ await expect(session.factory.run("argument-echo")).resolves.toMatchObject({
+ status: "completed",
+ });
+ expect(denyPermissions).not.toHaveBeenCalled();
+});
+
+it("resumes a failed factory when its session denies every permission request", async () => {
+ if (!factoryTestContext) {
+ throw new Error("Factory E2E requires the stdio transport");
}
-);
-
-it.skipIf(isInProcessTransport)(
- "resumes a failed factory when its session denies every permission request",
- async () => {
- if (!factoryTestContext) {
- throw new Error("Factory E2E requires the stdio transport");
- }
- const { workDir } = factoryTestContext;
- const denyPermissions = vi.fn(() => ({ kind: "reject" as const }));
- await using session = await setupFactoryExtension(workDir, denyPermissions);
-
- const failedRun = await session.factory.run("fails-once");
- expect(failedRun).toMatchObject({
- status: "error",
- });
-
- await expect(session.factory.resume(failedRun.runId)).resolves.toMatchObject({
- status: "completed",
- result: "resumed",
- });
- expect(denyPermissions).not.toHaveBeenCalled();
+ const { workDir } = factoryTestContext;
+ const denyPermissions = vi.fn(() => ({ kind: "reject" as const }));
+ await using session = await setupFactoryExtension(workDir, denyPermissions);
+
+ const failedRun = await session.factory.run("fails-once");
+ expect(failedRun).toMatchObject({
+ status: "error",
+ });
+
+ await expect(session.factory.resume(failedRun.runId)).resolves.toMatchObject({
+ status: "completed",
+ result: "resumed",
+ });
+ expect(denyPermissions).not.toHaveBeenCalled();
+});
+
+it("refuses a factory started through the context session from a factory body", async () => {
+ if (!factoryTestContext) {
+ throw new Error("Factory E2E requires the stdio transport");
}
-);
-
-it.skipIf(isInProcessTransport)(
- "refuses a factory started through the context session from a factory body",
- async () => {
- if (!factoryTestContext) {
- throw new Error("Factory E2E requires the stdio transport");
- }
- const { workDir } = factoryTestContext;
- await using session = await setupFactoryExtension(workDir);
-
- const result = await session.factory.run("starts-from-context-session");
-
- expect(result).toMatchObject({
- status: "completed",
- result: expect.stringContaining("factory.run and factory.resume"),
- });
- expect((result as { result: string }).result).toContain("factory body");
+ const { workDir } = factoryTestContext;
+ await using session = await setupFactoryExtension(workDir);
+
+ const result = await session.factory.run("starts-from-context-session");
+
+ expect(result).toMatchObject({
+ status: "completed",
+ result: expect.stringContaining("factory.run and factory.resume"),
+ });
+ expect((result as { result: string }).result).toContain("factory body");
+});
+
+it("refuses a factory started through the module session from a factory body", async () => {
+ if (!factoryTestContext) {
+ throw new Error("Factory E2E requires the stdio transport");
}
-);
-
-it.skipIf(isInProcessTransport)(
- "refuses a factory started through the module session from a factory body",
- async () => {
- if (!factoryTestContext) {
- throw new Error("Factory E2E requires the stdio transport");
- }
- const { workDir } = factoryTestContext;
- await using session = await setupFactoryExtension(workDir);
-
- const result = await session.factory.run("starts-from-module-session");
-
- expect(result).toMatchObject({
- status: "completed",
- result: expect.stringContaining("factory.run and factory.resume"),
- });
- expect((result as { result: string }).result).toContain("factory body");
+ const { workDir } = factoryTestContext;
+ await using session = await setupFactoryExtension(workDir);
+
+ const result = await session.factory.run("starts-from-module-session");
+
+ expect(result).toMatchObject({
+ status: "completed",
+ result: expect.stringContaining("factory.run and factory.resume"),
+ });
+ expect((result as { result: string }).result).toContain("factory body");
+});
+
+it("allows a module-level extension watcher to start a factory while another body is parked", async () => {
+ if (!factoryTestContext) {
+ throw new Error("Factory E2E requires the stdio transport");
}
-);
-
-it.skipIf(isInProcessTransport)(
- "allows a module-level extension watcher to start a factory while another body is parked",
- async () => {
- if (!factoryTestContext) {
- throw new Error("Factory E2E requires the stdio transport");
- }
- const { workDir } = factoryTestContext;
- const extensionDir = join(workDir, ".github", "extensions", "factory-smoke");
- await using session = await setupFactoryExtension(workDir);
-
- const parked = session.factory.run("parked");
- await retry(
- "wait for the parked factory to enter its body",
- async () => {
- expect(existsSync(join(extensionDir, "entered"))).toBe(true);
- },
- 100,
- 100
- );
-
- writeFileSync(join(extensionDir, "start-b"), "start");
- const bResultFile = join(extensionDir, "b-result");
- await retry(
- "wait for the module-level watcher factory run to succeed",
- async () => {
- expect(existsSync(bResultFile)).toBe(true);
- expect(JSON.parse(readFileSync(bResultFile, "utf8"))).toMatchObject({
- status: "success",
- result: {
- status: "completed",
- result: { source: "module-watcher" },
- },
- });
- },
- 100,
- 100
- );
-
- writeFileSync(join(extensionDir, "release"), "release");
- await expect(parked).resolves.toMatchObject({
- status: "completed",
- result: "released",
- });
- },
- 60_000
-);
-
-it.skipIf(isInProcessTransport)(
- "returns an array result from an extension-authored factory",
- async () => {
- if (!factoryTestContext) {
- throw new Error("Factory E2E requires the stdio transport");
- }
- const { workDir } = factoryTestContext;
- await using session = await setupFactoryExtension(workDir);
-
- const result = await session.factory.run("array-result");
-
- expect(result).toMatchObject({
- status: "completed",
- result: [1, "two", false],
- });
+ const { workDir } = factoryTestContext;
+ const extensionDir = join(workDir, ".github", "extensions", "factory-smoke");
+ await using session = await setupFactoryExtension(workDir);
+
+ const parked = session.factory.run("parked");
+ await retry(
+ "wait for the parked factory to enter its body",
+ async () => {
+ expect(existsSync(join(extensionDir, "entered"))).toBe(true);
+ },
+ 100,
+ 100
+ );
+
+ writeFileSync(join(extensionDir, "start-b"), "start");
+ const bResultFile = join(extensionDir, "b-result");
+ await retry(
+ "wait for the module-level watcher factory run to succeed",
+ async () => {
+ expect(existsSync(bResultFile)).toBe(true);
+ expect(JSON.parse(readFileSync(bResultFile, "utf8"))).toMatchObject({
+ status: "success",
+ result: {
+ status: "completed",
+ result: { source: "module-watcher" },
+ },
+ });
+ },
+ 100,
+ 100
+ );
+
+ writeFileSync(join(extensionDir, "release"), "release");
+ await expect(parked).resolves.toMatchObject({
+ status: "completed",
+ result: "released",
+ });
+}, 60_000);
+
+it("returns an array result from an extension-authored factory", async () => {
+ if (!factoryTestContext) {
+ throw new Error("Factory E2E requires the stdio transport");
}
-);
-
-it.skipIf(isInProcessTransport)(
- "passes array factory arguments across the SDK process boundary",
- async () => {
- if (!factoryTestContext) {
- throw new Error("Factory E2E requires the stdio transport");
- }
- const { workDir } = factoryTestContext;
- await using session = await setupFactoryExtension(workDir);
-
- const args = [1, "two", false];
- const result = await session.factory.run("argument-echo", { args });
-
- expect(result).toMatchObject({
- status: "completed",
- result: args,
- });
+ const { workDir } = factoryTestContext;
+ await using session = await setupFactoryExtension(workDir);
+
+ const result = await session.factory.run("array-result");
+
+ expect(result).toMatchObject({
+ status: "completed",
+ result: [1, "two", false],
+ });
+});
+
+it("passes array factory arguments across the SDK process boundary", async () => {
+ if (!factoryTestContext) {
+ throw new Error("Factory E2E requires the stdio transport");
}
-);
+ const { workDir } = factoryTestContext;
+ await using session = await setupFactoryExtension(workDir);
+
+ const args = [1, "two", false];
+ const result = await session.factory.run("argument-echo", { args });
+
+ expect(result).toMatchObject({
+ status: "completed",
+ result: args,
+ });
+});
diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts
index bf62db4826..b65dd8652a 100644
--- a/nodejs/test/e2e/harness/sdkTestContext.ts
+++ b/nodejs/test/e2e/harness/sdkTestContext.ts
@@ -50,6 +50,29 @@ function getCliPathForTests(): string | undefined {
return undefined;
}
+function getCliPlatformPackageNames(): string[] {
+ const variants =
+ process.platform === "linux"
+ ? process.report?.getReport().header.glibcVersionRuntime
+ ? ["linux", "linuxmusl"]
+ : ["linuxmusl", "linux"]
+ : [process.platform];
+ return variants.map((variant) => `@github/copilot-${variant}-${process.arch}`);
+}
+
+/** Resolves the legacy SEA only for tests that explicitly exercise Node-hosted features. */
+export function getLegacyCliPathForTests(): string {
+ const cliName = process.platform === "win32" ? "copilot.exe" : "copilot";
+ const githubModules = resolve(__dirname, "../../../node_modules/@github");
+ for (const packageName of getCliPlatformPackageNames()) {
+ const cliPath = join(githubModules, packageName.slice("@github/".length), cliName);
+ if (fs.existsSync(cliPath)) {
+ return cliPath;
+ }
+ }
+ throw new Error("Legacy Copilot CLI binary not found in the installed platform package.");
+}
+
export async function createSdkTestContext({
logLevel,
useStdio,
diff --git a/nodejs/test/e2e/inprocess_ffi.e2e.test.ts b/nodejs/test/e2e/inprocess_ffi.e2e.test.ts
index af879ea77b..e3b5f75ee4 100644
--- a/nodejs/test/e2e/inprocess_ffi.e2e.test.ts
+++ b/nodejs/test/e2e/inprocess_ffi.e2e.test.ts
@@ -11,9 +11,8 @@ describe("In-process FFI transport", () => {
// exercised by the full E2E suite running under the `inprocess` CI matrix cell,
// not a dedicated test.
it("should start and connect over in-process FFI", async () => {
- // In-process FFI hosting resolves the CLI entrypoint (COPILOT_CLI_PATH or the
- // bundled platform package) and its sibling native runtime library itself. If
- // neither is available, start() throws and the test fails hard.
+ // In-process FFI hosting loads runtime.node directly from the bundled runtime.
+ // If it is unavailable, start() throws and the test fails hard.
const client = new CopilotClient({ connection: RuntimeConnection.forInProcess() });
await client.start();
diff --git a/nodejs/test/runtimeArtifacts.test.ts b/nodejs/test/runtimeArtifacts.test.ts
new file mode 100644
index 0000000000..4a58789e6e
--- /dev/null
+++ b/nodejs/test/runtimeArtifacts.test.ts
@@ -0,0 +1,109 @@
+import { existsSync, mkdtempSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { defaultRuntimeCacheRoot, materializeRuntimeBundle } from "../src/runtimeArtifacts.js";
+
+describe("defaultRuntimeCacheRoot", () => {
+ it.each([
+ [
+ "darwin",
+ "/home/test",
+ {},
+ join("/home/test", "Library", "Caches", "github-copilot-sdk", "runtime"),
+ ],
+ ["linux", "/home/test", {}, join("/home/test", ".cache", "github-copilot-sdk", "runtime")],
+ [
+ "linux",
+ "/home/test",
+ { XDG_CACHE_HOME: "/cache" },
+ join("/cache", "github-copilot-sdk", "runtime"),
+ ],
+ [
+ "win32",
+ "C:\\Users\\test",
+ { LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local" },
+ join("C:\\Users\\test\\AppData\\Local", "github-copilot-sdk", "runtime"),
+ ],
+ ])("uses the %s user cache directory", (platform, home, environment, expected) => {
+ expect(defaultRuntimeCacheRoot(platform, home, environment)).toBe(expected);
+ });
+});
+
+describe("materializeRuntimeBundle", () => {
+ afterEach(() => vi.unstubAllEnvs());
+
+ it("materializes an adjacent pair from an absent cache with a stripped environment", () => {
+ const sourceDir = mkdtempSync(join(tmpdir(), "copilot-runtime-source-"));
+ const cacheRoot = join(sourceDir, "absent-cache");
+ const emptyPath = join(sourceDir, "empty-path");
+ mkdirSync(emptyPath);
+ const wrapperName =
+ process.platform === "win32" ? "copilot-runtime.exe" : "copilot-runtime";
+ const prebuilds = join(sourceDir, "prebuilds", "test-platform");
+ const wrapper = join(prebuilds, wrapperName);
+ const runtimeNode = join(prebuilds, "runtime.node");
+ mkdirSync(prebuilds, { recursive: true });
+ writeFileSync(wrapper, "wrapper");
+ writeFileSync(runtimeNode, "runtime");
+ mkdirSync(join(sourceDir, "ripgrep", "bin", "test-platform"), { recursive: true });
+ writeFileSync(join(sourceDir, "ripgrep", "bin", "test-platform", "rg"), "ripgrep");
+ mkdirSync(join(sourceDir, "definitions"), { recursive: true });
+ writeFileSync(join(sourceDir, "definitions", "future.json"), "{}");
+ writeFileSync(join(sourceDir, "app.js"), "excluded");
+ writeFileSync(join(sourceDir, "copilot"), "excluded");
+ writeFileSync(join(sourceDir, "copilot.exe"), "excluded");
+ writeFileSync(join(sourceDir, "LICENSE.md"), "excluded");
+ writeFileSync(join(sourceDir, "README.md"), "excluded");
+
+ vi.stubEnv("PATH", emptyPath);
+ vi.stubEnv("COPILOT_CLI_PATH", undefined);
+ vi.stubEnv("COPILOT_RUNTIME_HOST_COMMAND", undefined);
+ vi.stubEnv("COPILOT_RUNTIME_PROVIDER_LIB", undefined);
+
+ expect(process.env.COPILOT_CLI_PATH).toBeUndefined();
+ expect(process.env.COPILOT_RUNTIME_HOST_COMMAND).toBeUndefined();
+ expect(process.env.COPILOT_RUNTIME_PROVIDER_LIB).toBeUndefined();
+
+ const installedWrapper = materializeRuntimeBundle(
+ { packageRoot: sourceDir, platform: "test-platform" },
+ cacheRoot
+ );
+ const installDir = dirname(installedWrapper);
+
+ expect(readFileSync(installedWrapper, "utf8")).toBe("wrapper");
+ expect(readFileSync(join(installDir, "runtime.node"), "utf8")).toBe("runtime");
+ expect(
+ readFileSync(join(installDir, "ripgrep", "bin", "test-platform", "rg"), "utf8")
+ ).toBe("ripgrep");
+ expect(existsSync(join(installDir, "app.js"))).toBe(false);
+ expect(existsSync(join(installDir, "copilot"))).toBe(false);
+ expect(existsSync(join(installDir, "copilot.exe"))).toBe(false);
+ expect(existsSync(join(installDir, "LICENSE.md"))).toBe(false);
+ expect(existsSync(join(installDir, "README.md"))).toBe(false);
+ if (process.platform !== "win32") {
+ expect(statSync(installedWrapper).mode & 0o111).not.toBe(0);
+ }
+ });
+
+ it("fails clearly when the package has no runtime.node", () => {
+ const sourceDir = mkdtempSync(join(tmpdir(), "copilot-runtime-missing-node-"));
+ const wrapperName =
+ process.platform === "win32" ? "copilot-runtime.exe" : "copilot-runtime";
+ const prebuilds = join(sourceDir, "prebuilds", "test-platform");
+ const wrapper = join(prebuilds, wrapperName);
+ mkdirSync(prebuilds, { recursive: true });
+ writeFileSync(wrapper, "wrapper");
+
+ expect(() =>
+ materializeRuntimeBundle(
+ {
+ packageRoot: sourceDir,
+ platform: "test-platform",
+ },
+ join(sourceDir, "cache")
+ )
+ ).toThrow(/Copilot runtime\.node not found/);
+ });
+});
diff --git a/python/README.md b/python/README.md
index 61608c16a0..163e9615f0 100644
--- a/python/README.md
+++ b/python/README.md
@@ -29,8 +29,9 @@ runtime:
python -m copilot download-runtime
```
-This caches the runtime binary locally. If you skip this step, the SDK will
-attempt to download it automatically on first use as a fallback.
+This caches `copilot-runtime`, its adjacent `runtime.node`, and the compatible
+`copilot` host locally. If you skip this step, the SDK downloads the bundle
+automatically on first managed stdio/TCP use.
To pre-provision the native library required by the in-process (FFI) transport
(see [In-process (FFI) transport](#in-process-ffi-transport)), pass `--in-process`:
@@ -39,15 +40,15 @@ To pre-provision the native library required by the in-process (FFI) transport
python -m copilot download-runtime --in-process
```
-This additionally fetches the native runtime library into the versioned runtime
-cache. Stdio/TCP users never download it. When omitted, it is downloaded
-lazily on first use of the in-process transport.
+This instead provisions the compatible CLI artifact and native runtime library
+used by in-process hosting. When omitted, they are downloaded lazily on first
+use of the in-process transport.
| Platform | Cache path |
|----------|-----------|
-| Linux | `~/.cache/github-copilot-sdk/cli//copilot` |
-| macOS | `~/Library/Caches/github-copilot-sdk/cli//copilot` |
-| Windows | `%LOCALAPPDATA%\github-copilot-sdk\cli\\copilot.exe` |
+| Linux | `~/.cache/github-copilot-sdk/cli//prebuilds//` |
+| macOS | `~/Library/Caches/github-copilot-sdk/cli//prebuilds//` |
+| Windows | `%LOCALAPPDATA%\github-copilot-sdk\cli\\prebuilds\\` |
### Environment variables
@@ -56,7 +57,8 @@ lazily on first use of the in-process transport.
| `COPILOT_CLI_PATH` | Use this specific binary instead of downloading |
| `COPILOT_CLI_EXTRACT_DIR` | Override the cache directory (binary placed directly here) |
| `COPILOT_SKIP_CLI_DOWNLOAD` | Set to `1` to disable auto-download |
-| `COPILOT_CLI_DOWNLOAD_BASE_URL` | Override the GitHub Releases download URL |
+| `COPILOT_NPM_REGISTRY_URL` | Override the npm registry used for managed out-of-process and in-process runtime downloads |
+| `COPILOT_CLI_DOWNLOAD_BASE_URL` | Override the GitHub Releases download URL used for the root CLI |
## Run the Sample
@@ -223,6 +225,10 @@ All options are kw-only parameters:
- `RuntimeConnection.for_uri(url, connection_token=None)` — connect to an existing CLI server (e.g. `"localhost:8080"`).
- `RuntimeConnection.for_inprocess()` — host the runtime in-process via its native C ABI (FFI). See [In-process (FFI) transport](#in-process-ffi-transport).
+Managed stdio and TCP connections use the downloaded `copilot-runtime`
+executable with adjacent `runtime.node` by default. An explicit connection
+path or `COPILOT_CLI_PATH` overrides the downloaded runtime.
+
Child-process connections (`for_stdio`/`for_tcp`) also expose a per-connection
`env` field for the spawned process. Set it on the returned connection instead of
the client-level `env` — setting both raises:
diff --git a/python/copilot/_cli_download.py b/python/copilot/_cli_download.py
index b831e072ad..4477fcfff3 100644
--- a/python/copilot/_cli_download.py
+++ b/python/copilot/_cli_download.py
@@ -27,7 +27,7 @@
import tempfile
import time
import zipfile
-from pathlib import Path
+from pathlib import Path, PurePosixPath
from urllib.error import HTTPError, URLError
from urllib.request import urlopen
@@ -373,6 +373,164 @@ def _extract_runtime_node(data: bytes, npm_platform: str) -> bytes:
raise RuntimeError(f"'{target}' not found in runtime package for {npm_platform}.")
+def _extract_runtime_wrapper(data: bytes, npm_platform: str) -> bytes:
+ """Extract the SDK out-of-process wrapper from an npm platform tarball."""
+ wrapper_name = "copilot-runtime.exe" if sys.platform == "win32" else "copilot-runtime"
+ target = f"package/prebuilds/{npm_platform}/{wrapper_name}"
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf:
+ for name in tf.getnames():
+ if name == target or name.endswith(f"/prebuilds/{npm_platform}/{wrapper_name}"):
+ member = tf.getmember(name)
+ extracted = tf.extractfile(member)
+ if extracted is not None:
+ return extracted.read()
+ raise RuntimeError(f"'{target}' not found in runtime package for {npm_platform}.")
+
+
+_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, npm_platform: str) -> Path | None:
+ 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] != npm_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 _extract_runtime_bundle(data: bytes, npm_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, npm_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 its retained npm package assets."""
+ ver = version or CLI_VERSION
+ if not ver:
+ raise RuntimeError("No runtime version is pinned.")
+ npm_platform = get_npm_platform()
+ wrapper_name = "copilot-runtime.exe" if sys.platform == "win32" else "copilot-runtime"
+ pair_dir = get_cache_dir(ver) / "prebuilds" / npm_platform
+ wrapper_path = pair_dir / wrapper_name
+ runtime_path = pair_dir / "runtime.node"
+ assets_marker = pair_dir / ".hostless-runtime-assets-v2"
+
+ 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 wrapper_exists and runtime_exists and assets_marker.is_file() 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_url_bytes(get_runtime_lib_url(ver, npm_platform), timeout=600)
+ integrity = _fetch_runtime_integrity(npm_platform, ver)
+ if not integrity:
+ raise RuntimeError(
+ "No Subresource Integrity value available for the Copilot runtime "
+ f"package ({npm_platform}@{ver}); refusing to stage unverified native code."
+ )
+ _verify_integrity(data, integrity)
+ import shutil
+
+ pair_dir.parent.mkdir(parents=True, exist_ok=True)
+ staging_dir = Path(tempfile.mkdtemp(dir=pair_dir.parent, prefix=".runtime-bundle-"))
+ try:
+ _extract_runtime_bundle(data, npm_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``.
@@ -536,7 +694,10 @@ def main() -> None:
print(f"Downloading Copilot runtime v{ver}...")
try:
- path = download_cli(ver, force=args.force)
+ 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...")
diff --git a/python/copilot/_ffi_runtime_host.py b/python/copilot/_ffi_runtime_host.py
index e04d1655e6..98aa776600 100644
--- a/python/copilot/_ffi_runtime_host.py
+++ b/python/copilot/_ffi_runtime_host.py
@@ -3,9 +3,9 @@
Instead of spawning the Copilot CLI as a child process and talking JSON-RPC over
stdio/TCP, the in-process transport loads the runtime's native shared library
(``runtime.node`` — a Rust ``cdylib``) into this process and drives JSON-RPC over
-its C ABI (FFI). The native ``host_start`` export spawns the residual worker
-itself, so the SDK never launches the worker directly; it only pumps opaque LSP
-``Content-Length:``-framed JSON-RPC bytes across the boundary:
+its C ABI (FFI). The native ``host_start`` export constructs the Rust server
+synchronously; the SDK only pumps opaque LSP ``Content-Length:``-framed JSON-RPC
+bytes across the boundary:
- client → server frames go to ``copilot_runtime_connection_write``
- server → client frames arrive on a native callback that feeds a thread-safe
@@ -114,8 +114,8 @@ def _natural_library_name() -> str:
return "libcopilot_runtime.so"
-def resolve_library_path(cli_entrypoint: str) -> str | None:
- """Resolve the native runtime library next to the given CLI entrypoint.
+def resolve_library_path(runtime_entrypoint: str) -> str | None:
+ """Resolve the native runtime library next to the given runtime entrypoint.
Checks, in order:
@@ -125,7 +125,7 @@ def resolve_library_path(cli_entrypoint: str) -> str | None:
Returns the absolute path, or ``None`` when neither exists.
"""
- directory = Path(cli_entrypoint).resolve().parent
+ directory = Path(runtime_entrypoint).resolve().parent
flat = directory / _natural_library_name()
if flat.is_file():
@@ -327,15 +327,15 @@ def wait(self, timeout: float | None = None) -> int: # noqa: ARG002
class FfiRuntimeHost:
"""Hosts the Copilot runtime in-process via its native C ABI.
- Construct with :meth:`create`, then :meth:`start` to spawn the worker and open
- the FFI connection. Expose :attr:`process` to :class:`JsonRpcClient`, and call
- :meth:`dispose` to tear everything down.
+ Construct with :meth:`create`, then :meth:`start` to start the native engine
+ and open the FFI connection. Expose :attr:`process` to
+ :class:`JsonRpcClient`, and call :meth:`dispose` to tear everything down.
"""
def __init__(
self,
library_path: str,
- cli_entrypoint: str,
+ cli_entrypoint: str | None,
environment: dict[str, str] | None = None,
args: Sequence[str] = (),
) -> None:
@@ -367,31 +367,30 @@ def process(self) -> _FfiProcessAdapter:
@staticmethod
def create(
- cli_entrypoint: str,
+ library_path: str,
+ cli_entrypoint: str | None = None,
environment: dict[str, str] | None = None,
args: Sequence[str] = (),
) -> FfiRuntimeHost:
- """Resolve the cdylib next to the CLI entrypoint and prepare the host.
+ """Load the runtime cdylib and prepare the host.
Raises:
RuntimeError: If the native runtime library cannot be found.
"""
- full_entrypoint = str(Path(cli_entrypoint).resolve())
- library_path = resolve_library_path(full_entrypoint)
- if library_path is None:
+ full_library_path = str(Path(library_path).resolve())
+ if not Path(full_library_path).is_file():
raise RuntimeError(
- "In-process FFI runtime library not found next to "
- f"'{full_entrypoint}'. Download it with "
- "`python -m copilot download-runtime --in-process`, or set "
- "COPILOT_CLI_PATH to a runtime package that ships it."
+ f"In-process FFI runtime library not found at '{full_library_path}'."
)
- return FfiRuntimeHost(library_path, full_entrypoint, environment, args)
+ full_entrypoint = (
+ str(Path(cli_entrypoint).resolve()) if cli_entrypoint is not None else None
+ )
+ return FfiRuntimeHost(full_library_path, full_entrypoint, environment, args)
def _build_argv(self) -> bytes:
- # A `.js` entrypoint (dev) is launched via node; the packaged single-file
- # CLI embeds its own Node and is invoked directly. `--no-auto-update`
- # pins the worker to the runtime package matching the loaded cdylib.
- if self._cli_entrypoint.lower().endswith(".js"):
+ if self._cli_entrypoint is None:
+ argv: list[str] = []
+ elif self._cli_entrypoint.lower().endswith(".js"):
argv = ["node", self._cli_entrypoint, "--embedded-host", "--no-auto-update"]
else:
argv = [self._cli_entrypoint, "--embedded-host", "--no-auto-update"]
@@ -407,11 +406,9 @@ def _build_env(self) -> bytes | None:
return json.dumps(obj).encode("utf-8")
def start_blocking(self) -> None:
- """Spawn the worker and open the FFI connection (blocks up to ~30s).
+ """Start the native engine and open the FFI connection.
- Must be run off the event loop (e.g. via :func:`asyncio.to_thread`);
- ``host_start`` blocks until the worker connects back and signals
- readiness.
+ Must be run off the event loop (e.g. via :func:`asyncio.to_thread`).
"""
argv = self._build_argv()
env = self._build_env()
@@ -419,8 +416,7 @@ def start_blocking(self) -> None:
self._server_id = self._lib.host_start(argv, len(argv), env, len(env) if env else 0)
if not self._server_id:
raise RuntimeError(
- f"copilot_runtime_host_start failed (library '{self._library_path}', "
- f"entrypoint '{self._cli_entrypoint}')."
+ f"copilot_runtime_host_start failed (library '{self._library_path}')."
)
self._outbound_callback = _OutboundCallback(self._on_outbound)
diff --git a/python/copilot/client.py b/python/copilot/client.py
index 271fad626c..7e304ced32 100644
--- a/python/copilot/client.py
+++ b/python/copilot/client.py
@@ -28,6 +28,7 @@
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import UTC, datetime
+from pathlib import Path
from types import TracebackType
from typing import Any, ClassVar, Literal, NotRequired, TypedDict, cast, overload
@@ -1354,25 +1355,6 @@ def _session_lifecycle_event_from_dict(data: dict) -> SessionLifecycleEvent:
_CLI_PROCESS_EXIT_TIMEOUT_SECONDS = 5
-def _get_or_download_cli(*, include_runtime_lib: bool = False) -> str | None:
- """Get the cached CLI binary, downloading if necessary.
-
- Returns the path to the CLI binary, or None if unavailable (dev install
- with no pinned version, or auto-download disabled).
-
- When ``include_runtime_lib`` is set, also ensures the native in-process FFI
- runtime is available (downloading it on first use).
- """
- from ._cli_download import get_or_download_cli
-
- cli_path = get_or_download_cli()
- if cli_path and include_runtime_lib:
- from ._cli_download import ensure_runtime_library
-
- ensure_runtime_library(cli_path)
- return cli_path
-
-
def _extract_transform_callbacks(
system_message: SystemMessageConfig | dict[str, Any] | None,
) -> tuple[dict[str, Any] | None, dict[str, SectionTransformFn] | None]:
@@ -1649,6 +1631,7 @@ def __init__(
self._cli_path_source: str | None = None
self._ffi_host: FfiRuntimeHost | None = None
self._inprocess_runtime_path: str | None = None
+ self._inprocess_cli_entrypoint: str | None = None
if isinstance(connection, UriRuntimeConnection):
if connection.connection_token is not None and len(connection.connection_token) == 0:
@@ -1660,9 +1643,7 @@ def __init__(
# In-process (FFI): no child process and no per-connection token.
self._runtime_port = None
self._effective_connection_token = None
- self._inprocess_runtime_path = self._resolve_runtime_entrypoint(
- None, include_runtime_lib=True
- )
+ self._inprocess_runtime_path = self._resolve_inprocess_runtime()
if options.use_logged_in_user is None:
options.use_logged_in_user = not bool(options.github_token)
else:
@@ -1683,7 +1664,7 @@ def __init__(
else:
self._effective_connection_token = None
- # Resolve CLI path: explicit > COPILOT_CLI_PATH env var > downloaded binary.
+ # Resolve runtime path: explicit CLI > COPILOT_CLI_PATH > downloaded runtime.
# Select the environment by identity, not truthiness, so an intentionally
# empty per-connection or client env stays authoritative (the spawned child
# receives that empty mapping) instead of falling back to os.environ and
@@ -1728,52 +1709,47 @@ def _resolve_runtime_entrypoint(
path: str | None,
*,
env: Mapping[str, str] | None = None,
- include_runtime_lib: bool = False,
) -> str:
"""Resolve the runtime executable path (explicit > env > downloaded).
Sets ``self._cli_path_source`` for diagnostics. When
- ``include_runtime_lib`` is set (in-process transport), also ensures the
- native runtime library is downloaded alongside the CLI.
-
Raises:
RuntimeError: If no runtime path can be resolved.
"""
if path is not None:
self._cli_path_source = "explicit"
- return self._ensure_runtime_lib(path) if include_runtime_lib else path
+ return path
lookup = env if env is not None else os.environ
env_cli_path = lookup.get("COPILOT_CLI_PATH")
if env_cli_path:
self._cli_path_source = "environment"
- return self._ensure_runtime_lib(env_cli_path) if include_runtime_lib else env_cli_path
-
- downloaded_path = _get_or_download_cli(include_runtime_lib=include_runtime_lib)
- if downloaded_path:
- self._cli_path_source = "downloaded"
- return downloaded_path
-
- raise RuntimeError(
- "Copilot CLI not found. Install a published wheel (which "
- "auto-downloads the CLI on first use), set COPILOT_CLI_PATH, "
- "or pass an explicit path via "
- "RuntimeConnection.for_stdio(path=...) / "
- "RuntimeConnection.for_tcp(path=...)."
- )
+ return env_cli_path
- @staticmethod
- def _ensure_runtime_lib(cli_path: str) -> str:
- """Ensure the in-process runtime library sits next to a user-supplied CLI.
+ from ._cli_download import ensure_runtime_wrapper
- For explicit/``COPILOT_CLI_PATH`` entrypoints, the native library may
- already be bundled (dev ``prebuilds`` layout); otherwise it is fetched on
- first use. Returns ``cli_path`` unchanged.
- """
- from ._cli_download import ensure_runtime_library
+ self._cli_path_source = "downloaded"
+ return ensure_runtime_wrapper()
- ensure_runtime_library(cli_path)
- return cli_path
+ def _resolve_inprocess_runtime(self) -> str:
+ explicit_cli = os.environ.get("COPILOT_CLI_PATH")
+ if explicit_cli:
+ from ._cli_download import ensure_runtime_library
+
+ runtime_path = ensure_runtime_library(explicit_cli)
+ if runtime_path is None:
+ raise RuntimeError(
+ f"In-process runtime library not found next to '{explicit_cli}'."
+ )
+ self._cli_path_source = "environment"
+ self._inprocess_cli_entrypoint = explicit_cli
+ return runtime_path
+
+ from ._cli_download import ensure_runtime_wrapper
+
+ wrapper_path = Path(ensure_runtime_wrapper())
+ self._cli_path_source = "downloaded"
+ return str(wrapper_path.with_name("runtime.node"))
@property
def rpc(self) -> ServerRpc:
@@ -4286,7 +4262,6 @@ async def _start_cli_server(self) -> None:
env = dict(os.environ)
else:
env = dict(opts.env)
-
# Set auth token in environment if provided
if opts.github_token:
env["COPILOT_SDK_AUTH_TOKEN"] = opts.github_token
@@ -4437,6 +4412,7 @@ async def _start_inprocess_ffi(self) -> None:
host = FfiRuntimeHost.create(
runtime_path,
+ cli_entrypoint=self._inprocess_cli_entrypoint,
environment=environment or None,
args=tuple(args),
)
diff --git a/python/e2e/conftest.py b/python/e2e/conftest.py
index f441097f32..a789b2b567 100644
--- a/python/e2e/conftest.py
+++ b/python/e2e/conftest.py
@@ -1,10 +1,14 @@
"""Shared pytest fixtures for e2e tests."""
+import json
import os
+from pathlib import Path
import pytest
import pytest_asyncio
+import copilot._cli_download as cli_download
+
from .testharness import E2ETestContext, is_inprocess_transport
# Host-side auth resolution ranks HMAC above the GitHub token, so an ambient
@@ -15,9 +19,16 @@
# .NET's InProcessEnvIsolation [ModuleInitializer] and Node's module-init guard.
# Out-of-process children resolve auth in their own process where the token already
# outranks HMAC. See https://github.com/github/copilot-sdk/issues/1934.
+if not cli_download.CLI_VERSION:
+ package_lock = json.loads(
+ (Path(__file__).parents[2] / "nodejs" / "package-lock.json").read_text()
+ )
+ cli_download.CLI_VERSION = package_lock["packages"]["node_modules/@github/copilot"]["version"]
+
if is_inprocess_transport():
os.environ.pop("COPILOT_HMAC_KEY", None)
os.environ.pop("CAPI_HMAC_KEY", None)
+ os.environ.pop("COPILOT_CLI_PATH", None)
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
diff --git a/python/e2e/test_inprocess_ffi_e2e.py b/python/e2e/test_inprocess_ffi_e2e.py
index c119c4ea4e..ea82037b7a 100644
--- a/python/e2e/test_inprocess_ffi_e2e.py
+++ b/python/e2e/test_inprocess_ffi_e2e.py
@@ -15,20 +15,14 @@
from copilot import CopilotClient, RuntimeConnection
from .testharness import E2ETestContext
-from .testharness.context import get_cli_path_for_tests
pytestmark = pytest.mark.asyncio(loop_scope="module")
class TestInProcessFfi:
- async def test_should_start_and_connect_over_in_process_ffi(
- self, ctx: E2ETestContext, monkeypatch: pytest.MonkeyPatch
- ):
- # In-process hosting loads the runtime cdylib next to the resolved CLI
- # entrypoint and lets the native host spawn the worker. ``ping`` is a
- # purely local RPC round-trip, so no auth or replay proxy is involved.
- # If the native library is unavailable, start() raises and the test fails.
- monkeypatch.setenv("COPILOT_CLI_PATH", get_cli_path_for_tests())
+ async def test_should_start_and_connect_over_in_process_ffi(self, ctx: E2ETestContext):
+ # In-process hosting loads runtime.node directly. ``ping`` is a purely local
+ # RPC round-trip, so no auth or replay proxy is involved.
client = CopilotClient(connection=RuntimeConnection.for_inprocess())
await client.start()
diff --git a/python/e2e/testharness/context.py b/python/e2e/testharness/context.py
index 2171e25f2d..f0a63759f1 100644
--- a/python/e2e/testharness/context.py
+++ b/python/e2e/testharness/context.py
@@ -191,7 +191,6 @@ def _apply_inprocess_environment(self) -> None:
{
"GH_TOKEN": DEFAULT_GITHUB_TOKEN,
"GITHUB_TOKEN": DEFAULT_GITHUB_TOKEN,
- "COPILOT_CLI_PATH": self.cli_path,
"COPILOT_HMAC_KEY": "",
"CAPI_HMAC_KEY": "",
}
diff --git a/python/test_cli_download.py b/python/test_cli_download.py
index 36952919df..a5a20dce0d 100644
--- a/python/test_cli_download.py
+++ b/python/test_cli_download.py
@@ -4,6 +4,9 @@
import base64
import hashlib
+import io
+import os
+import tarfile
from unittest.mock import patch
import pytest
@@ -16,6 +19,28 @@ def _integrity(data: bytes, algo: str = "sha512") -> str:
return f"{algo}-{base64.b64encode(digest).decode('ascii')}"
+def _runtime_package(npm_platform: str) -> bytes:
+ wrapper_name = "copilot-runtime.exe" if os.name == "nt" else "copilot-runtime"
+ members = {
+ f"package/prebuilds/{npm_platform}/{wrapper_name}": b"wrapper",
+ f"package/prebuilds/{npm_platform}/runtime.node": b"runtime",
+ "package/copilot": b"excluded",
+ "package/copilot.exe": b"excluded",
+ f"package/ripgrep/bin/{npm_platform}/rg": b"ripgrep",
+ "package/definitions/future.json": b"{}",
+ "package/app.js": b"excluded",
+ "package/LICENSE.md": b"excluded",
+ "package/README.md": b"excluded",
+ }
+ buffer = io.BytesIO()
+ with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
+ for name, content in members.items():
+ info = tarfile.TarInfo(name)
+ info.size = len(content)
+ archive.addfile(info, io.BytesIO(content))
+ return buffer.getvalue()
+
+
class TestVerifyIntegrity:
def test_accepts_matching_checksum(self):
data = b"native-library-bytes"
@@ -51,3 +76,92 @@ def test_raises_when_integrity_unavailable(self, tmp_path):
# The library bytes must never be extracted/written when verification is impossible.
extract.assert_not_called()
+
+
+class TestEnsureRuntimeWrapper:
+ def test_materializes_pair_from_absent_cache_with_stripped_environment(
+ self, tmp_path, monkeypatch
+ ):
+ npm_platform = "win32-x64" if os.name == "nt" else "linux-x64"
+ wrapper_name = "copilot-runtime.exe" if os.name == "nt" else "copilot-runtime"
+ data = _runtime_package(npm_platform)
+ cache_dir = tmp_path / "cache"
+ empty_path = tmp_path / "empty-path"
+ empty_path.mkdir()
+ assert not cache_dir.exists()
+
+ for name in (
+ "COPILOT_CLI_PATH",
+ "COPILOT_RUNTIME_HOST_COMMAND",
+ "COPILOT_RUNTIME_PROVIDER_LIB",
+ ):
+ monkeypatch.delenv(name, raising=False)
+ monkeypatch.setenv("PATH", str(empty_path))
+
+ with (
+ patch.object(_cli_download, "get_cache_dir", return_value=cache_dir),
+ patch.object(_cli_download, "get_npm_platform", return_value=npm_platform),
+ patch.object(_cli_download, "_should_skip_download", return_value=False),
+ patch.object(_cli_download, "_fetch_url_bytes", return_value=data),
+ patch.object(
+ _cli_download,
+ "_fetch_runtime_integrity",
+ return_value=_integrity(data),
+ ),
+ ):
+ wrapper = _cli_download.ensure_runtime_wrapper(version="1.2.3")
+
+ install_dir = cache_dir / "prebuilds" / npm_platform
+ assert wrapper == str(install_dir / wrapper_name)
+ assert (install_dir / wrapper_name).read_bytes() == b"wrapper"
+ assert (install_dir / "runtime.node").read_bytes() == b"runtime"
+ assert (install_dir / "ripgrep" / "bin" / npm_platform / "rg").read_bytes() == b"ripgrep"
+ assert (install_dir / "definitions" / "future.json").read_bytes() == b"{}"
+ assert not (install_dir / "app.js").exists()
+ assert not (install_dir / "copilot").exists()
+ assert not (install_dir / "copilot.exe").exists()
+ assert (install_dir / ".hostless-runtime-assets-v2").is_file()
+ if os.name != "nt":
+ assert (install_dir / wrapper_name).stat().st_mode & 0o111
+
+ def test_rejects_cached_wrapper_without_runtime_node(self, tmp_path):
+ npm_platform = "win32-x64" if os.name == "nt" else "linux-x64"
+ wrapper_name = "copilot-runtime.exe" if os.name == "nt" else "copilot-runtime"
+ cache_dir = tmp_path / "cache"
+ install_dir = cache_dir / "prebuilds" / npm_platform
+ install_dir.mkdir(parents=True)
+ (install_dir / wrapper_name).write_bytes(b"wrapper")
+
+ with (
+ patch.object(_cli_download, "get_cache_dir", return_value=cache_dir),
+ patch.object(_cli_download, "get_npm_platform", return_value=npm_platform),
+ ):
+ with pytest.raises(RuntimeError, match="Incomplete Copilot runtime bundle"):
+ _cli_download.ensure_runtime_wrapper(version="1.2.3")
+
+ def test_upgrades_pair_only_cache_with_retained_assets(self, tmp_path):
+ npm_platform = "win32-x64" if os.name == "nt" else "linux-x64"
+ wrapper_name = "copilot-runtime.exe" if os.name == "nt" else "copilot-runtime"
+ cache_dir = tmp_path / "cache"
+ install_dir = cache_dir / "prebuilds" / npm_platform
+ install_dir.mkdir(parents=True)
+ (install_dir / wrapper_name).write_bytes(b"old-wrapper")
+ (install_dir / "runtime.node").write_bytes(b"old-runtime")
+ (install_dir / "copilot").write_bytes(b"legacy-sea")
+ (install_dir / ".hostless-runtime-assets-v1").write_text("1\n", encoding="ascii")
+ data = _runtime_package(npm_platform)
+
+ with (
+ patch.object(_cli_download, "get_cache_dir", return_value=cache_dir),
+ patch.object(_cli_download, "get_npm_platform", return_value=npm_platform),
+ patch.object(_cli_download, "_should_skip_download", return_value=False),
+ patch.object(_cli_download, "_fetch_url_bytes", return_value=data),
+ patch.object(_cli_download, "_fetch_runtime_integrity", return_value=_integrity(data)),
+ ):
+ wrapper = _cli_download.ensure_runtime_wrapper(version="1.2.3")
+
+ assert wrapper == str(install_dir / wrapper_name)
+ assert (install_dir / wrapper_name).read_bytes() == b"wrapper"
+ assert not (install_dir / "copilot").exists()
+ assert (install_dir / ".hostless-runtime-assets-v2").is_file()
+ assert (install_dir / "ripgrep" / "bin" / npm_platform / "rg").is_file()
diff --git a/python/test_client.py b/python/test_client.py
index a33f0ecd60..69f56dd97e 100644
--- a/python/test_client.py
+++ b/python/test_client.py
@@ -59,6 +59,27 @@ def test_inprocess_connection_has_no_child_process_options():
assert not hasattr(connection, "args")
+def test_explicit_child_process_path_does_not_require_runtime_bundle(tmp_path):
+ explicit = tmp_path / "copilot"
+ connection = RuntimeConnection.for_stdio(path=str(explicit))
+
+ CopilotClient(connection=connection, env={"PATH": str(tmp_path)})
+
+ assert connection.path == str(explicit)
+
+
+def test_copilot_cli_path_does_not_require_runtime_bundle(tmp_path):
+ explicit = tmp_path / "copilot"
+ connection = RuntimeConnection.for_stdio()
+
+ CopilotClient(
+ connection=connection,
+ env={"PATH": str(tmp_path), "COPILOT_CLI_PATH": str(explicit)},
+ )
+
+ assert connection.path == str(explicit)
+
+
class TestBuiltinPluginDirectories:
@staticmethod
async def _start_client(paths=None):
diff --git a/rust/README.md b/rust/README.md
index 323d525d37..e8f007307c 100644
--- a/rust/README.md
+++ b/rust/README.md
@@ -102,7 +102,7 @@ transports.
| `extra_args` | `Vec` | Extra CLI flags |
| `transport` | `Transport` | `Default`, `Stdio`, `InProcess`, `Tcp`, or `External` |
-With the default `CliProgram::Resolve`, `Client::start()` resolves the CLI in this order: an explicit `CliProgram::Path(path)`, the `COPILOT_CLI_PATH` env var, then the bundled CLI that was embedded at build time. There is no PATH scanning — if you've opted out of bundling (`default-features = false`) you must supply either `CliProgram::Path` or `COPILOT_CLI_PATH`.
+With the default `CliProgram::Resolve`, managed stdio and TCP transports resolve an explicit `CliProgram::Path(path)`, `COPILOT_CLI_PATH`, then the bundled `copilot-runtime` wrapper and adjacent `runtime.node`. In-process transport retains its CLI-entrypoint resolution. There is no PATH scanning.
### Session
@@ -832,10 +832,12 @@ none of them are scheduled for removal.
| `router.rs` | Internal per-session event demux |
| `jsonrpc.rs` | Internal Content-Length framed JSON-RPC transport |
-## Embedded CLI
+## Bundled runtime artifacts
The SDK provisions its runtime at build time. By default the `bundled-cli`
-feature embeds the verified child-process runtime in your compiled crate.
+feature embeds the verified `copilot-runtime` wrapper and adjacent
+`runtime.node` in your compiled crate. The compatible CLI artifact remains
+available separately for `install_bundled_cli` and in-process hosting.
Enable `bundled-in-process` to additionally embed the native runtime library
and use `Transport::InProcess`:
@@ -853,15 +855,11 @@ For builds that prefer a smaller artifact, disable the `bundled-cli` feature:
github-copilot-sdk = { version = "0.1", default-features = false }
```
-> **You become responsible for supplying the CLI at runtime.** With
-> `bundled-cli` disabled, the produced binary does not contain the CLI
-> and will not search the system for one. You must point it at a
-> compatible CLI via [`CliProgram::Path`] (on `ClientOptions`) or the
-> `COPILOT_CLI_PATH` environment variable, and you are responsible for
-> guaranteeing the supplied CLI version is compatible with this SDK
-> release. Do **not** assume that whatever CLI happens to be installed
-> on the target system will work — the SDK and CLI are versioned
-> together.
+> **You become responsible for supplying the runtime at deployment.** With
+> `bundled-cli` disabled, the produced binary does not contain these artifacts
+> and will not search the system for them. For managed child-process transports,
+> supply a compatible wrapper pair via an explicit [`CliProgram::Path`].
+> `COPILOT_CLI_PATH` remains a direct program override.
>
> **Convenience on the build machine only.** As a special case,
> `build.rs` downloads and integrity-verifies the compatible CLI version and
@@ -870,8 +868,8 @@ github-copilot-sdk = { version = "0.1", default-features = false }
> makes local development and CI ergonomic, but it does **not** carry
> over when you copy the built binary to another machine — distributed
> builds (release artifacts, signed installers, container images, etc.)
-> must either keep `bundled-cli` enabled or ship the CLI alongside and
-> set `CliProgram::Path` / `COPILOT_CLI_PATH`.
+> must either keep `bundled-cli` enabled or ship the runtime pair and set
+> `CliProgram::Path`.
### How it works
@@ -884,17 +882,17 @@ github-copilot-sdk = { version = "0.1", default-features = false }
2. **Build time:** `build.rs` downloads the platform-specific npm package and
verifies its `sha512` integrity against the lockfile or publish snapshot.
Then:
- - **`bundled-cli` on (default):** creates and embeds a minimal archive containing only the CLI executable.
- - **`bundled-in-process` on:** the minimal archive additionally contains the platform-native runtime library (`.dll`, `.so`, or `.dylib`); no other npm package files are embedded.
- - **`bundled-cli` off:** extracts the binary directly into the platform cache (staging file + atomic rename), idempotent across rebuilds. If the extracted binary is already present at the expected path, the download is skipped entirely — the extracted binary *is* the cache.
+ - **`bundled-cli` on (default):** creates and embeds a minimal archive containing the CLI executable, `copilot-runtime[.exe]`, and `runtime.node`.
+ - **`bundled-in-process` on:** the archive additionally contains the platform-native runtime library (`.dll`, `.so`, or `.dylib`).
+ - **`bundled-cli` off:** extracts the same artifacts directly into the platform cache using staging files and atomic renames.
-3. **Runtime:** in both modes the binary lives at:
+3. **Runtime:** in both modes the artifacts share one versioned directory:
| OS | Path |
|----|------|
- | macOS | `~/Library/Caches/github-copilot-sdk/cli//copilot` |
- | Linux | `${XDG_CACHE_HOME:-~/.cache}/github-copilot-sdk/cli//copilot` |
- | Windows | `%LOCALAPPDATA%\github-copilot-sdk\cli\\copilot.exe` |
+ | macOS | `~/Library/Caches/github-copilot-sdk/cli//` |
+ | Linux | `${XDG_CACHE_HOME:-~/.cache}/github-copilot-sdk/cli//` |
+ | Windows | `%LOCALAPPDATA%\github-copilot-sdk\cli\\` |
Old version directories accumulate in siblings; clean them up at your leisure.
@@ -923,18 +921,20 @@ COPILOT_CLI_EXTRACT_DIR = { value = "vendor/copilot", relative = true, force = t
### Skipping the bundle entirely
-Set `COPILOT_SKIP_CLI_DOWNLOAD=1` at build time to disable the entire download / bundle / cache mechanism — `build.rs` returns immediately without touching the network. Use this when you always supply the CLI at runtime via `ClientOptions::program = CliProgram::Path(...)` or `COPILOT_CLI_PATH`. Works regardless of the `bundled-cli` feature state; runtime resolution falls through to `Error::BinaryNotFound` unless one of those explicit sources resolves.
+Set `COPILOT_SKIP_CLI_DOWNLOAD=1` at build time to disable the entire download / bundle / cache mechanism — `build.rs` returns immediately without touching the network. Use this when you always supply the managed runtime via `ClientOptions::program = CliProgram::Path(...)`. Works regardless of the `bundled-cli` feature state; runtime resolution falls through to `Error::BinaryNotFound` unless an applicable explicit source resolves.
### Resolution priority
-`Client::start` resolves the CLI in this order:
+For managed child-process transports, `Client::start` resolves the program in this order:
1. Explicit `CliProgram::Path(path)` on `ClientOptions::program`.
2. `COPILOT_CLI_PATH` environment variable, if it points at a real file.
-3. **`bundled-cli` on:** the embedded archive, lazily extracted on first call.
-4. **`bundled-cli` off:** the build-time-extracted binary in the per-user cache, located by recomputing the convention from `COPILOT_SDK_CLI_VERSION` + OS + optional `COPILOT_CLI_EXTRACT_DIR`.
+3. **`bundled-cli` on:** the embedded wrapper pair, lazily extracted on first call.
+4. **`bundled-cli` off:** the build-time-extracted wrapper pair in the per-user cache.
-There is no PATH scanning. If none of the above resolves, `Client::start` returns `Error::BinaryNotFound`.
+In-process transport resolves the compatible CLI artifact from
+`COPILOT_CLI_PATH`, the embedded archive, or the build-time cache. There is no
+PATH scanning.
### Reaching the bundled binary without a `Client`
@@ -954,12 +954,24 @@ if HAS_BUNDLED_CLI {
}
```
-This returns the same path `Client::start` would resolve to for
-`CliProgram::Resolve` with no `COPILOT_CLI_PATH` override and no
-`ClientOptions::bundled_cli_extract_dir` configured. It returns `None`
-when `bundled-cli` is off or the target is unsupported, and (unlike the
-full resolver) does not fall back to the build-time-extracted dev-cache
-path.
+This returns the bundled CLI artifact, preserving the public API's original
+meaning. Managed child-process transports resolve `copilot-runtime` instead.
+The function returns `None` when `bundled-cli` is off or the target is
+unsupported and does not fall back to the build-time extraction cache.
+
+Use [`install_bundled_runtime`] when a health check or intermediate launcher
+needs the managed runtime executable:
+
+```rust,no_run
+use github_copilot_sdk::install_bundled_runtime;
+
+if let Some(path) = install_bundled_runtime() {
+ println!("bundled runtime at {}", path.display());
+}
+```
+
+This extracts `copilot-runtime` together with adjacent `runtime.node`, then
+returns the wrapper path.
### Download cache (build-time, embed mode)
@@ -973,8 +985,8 @@ Supported: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64`
| Feature | Default | Description |
| ------- | ------- | ----------- |
-| `bundled-cli` | ✓ | Embeds only the CLI executable. Disable via `default-features = false` when supplying the CLI via `CliProgram::Path` or `COPILOT_CLI_PATH`. |
-| `bundled-in-process` | — | Enables `Transport::InProcess`, implies `bundled-cli`, and additionally embeds only the platform-native runtime library. |
+| `bundled-cli` | ✓ | Embeds the managed wrapper pair and compatible CLI artifact. Disable via `default-features = false` when supplying the runtime explicitly. |
+| `bundled-in-process` | — | Enables `Transport::InProcess`, implies `bundled-cli`, and additionally embeds the platform-native runtime library. |
| `derive` | — | `schema_for::()` for generating JSON Schema from Rust types (adds `schemars`). |
```toml
diff --git a/rust/build.rs b/rust/build.rs
index d04cf2870b..c01464bb4a 100644
--- a/rust/build.rs
+++ b/rust/build.rs
@@ -1,11 +1,6 @@
-#[cfg(feature = "bundled-in-process")]
#[path = "build/in_process.rs"]
mod implementation;
-#[cfg(not(feature = "bundled-in-process"))]
-#[path = "build/out_of_process.rs"]
-mod implementation;
-
fn main() {
implementation::main();
}
diff --git a/rust/build/in_process.rs b/rust/build/in_process.rs
index 5826fbfa76..f1e947b27a 100644
--- a/rust/build/in_process.rs
+++ b/rust/build/in_process.rs
@@ -40,7 +40,7 @@ pub(crate) fn main() {
// path source resolves first.
if std::env::var_os("COPILOT_SKIP_CLI_DOWNLOAD").is_some() {
println!(
- "cargo:warning=COPILOT_SKIP_CLI_DOWNLOAD is set — skipping CLI download/bundle/cache"
+ "cargo:warning=COPILOT_SKIP_CLI_DOWNLOAD is set — skipping runtime download/bundle/cache"
);
return;
}
@@ -95,38 +95,61 @@ pub(crate) fn main() {
if std::env::var_os("CARGO_FEATURE_BUNDLED_CLI").is_some() {
let archive = cached_download(&download_url, &cache_key, &expected_integrity, &cache_dir);
- verify_binary_present_in_archive(&archive, platform.binary_name, &archive_name);
+ verify_runtime_package(&archive, platform, &archive_name);
emit_embedded(out, &archive, platform, include_runtime);
println!("cargo:rustc-cfg=has_bundled_cli");
} else {
- // With `bundled-cli` off the extracted binary *is* the cache.
- // Skip the upstream download entirely when it already exists at
- // the expected path. No two separate caches.
+ // With `bundled-cli` off the extracted runtime pair *is* the cache.
+ // Skip the upstream download entirely when both files already exist.
//
- // Runtime resolution (see `src/resolve.rs::extracted_cli_path`)
+ // Runtime resolution (see `src/resolve.rs::extracted_program`)
// recomputes this same path from `COPILOT_SDK_CLI_VERSION` + the
// OS-derived binary name + optional `COPILOT_CLI_EXTRACT_DIR`,
// so we don't bake an absolute path into the crate.
let install_dir = extracted_install_dir(&version);
- let final_path = install_dir.join(platform.binary_name);
-
- // Invalidate build.rs whenever the cached binary disappears (cache GC,
- // manual rm, OS reset, switching extract dir). Without this, cargo
+ let required_paths = [
+ install_dir.join(platform.runtime_wrapper_name()),
+ install_dir.join("runtime.node"),
+ install_dir.join(".hostless-runtime-assets-v1"),
+ ];
+ let expected_marker = format!("{version}\n{expected_integrity}\n");
+
+ // Invalidate build.rs whenever either cached artifact disappears (cache
+ // GC, manual rm, OS reset, switching extract dir). Without this, cargo
// replays the saved `has_extracted_cli` cfg from its build-script
// output cache even when the file is gone, and runtime resolution
// fails with BinaryNotFound.
- println!("cargo:rerun-if-changed={}", final_path.display());
+ for path in &required_paths {
+ println!("cargo:rerun-if-changed={}", path.display());
+ }
- if !final_path.is_file() {
+ let cache_is_current = required_paths.iter().all(|path| path.is_file())
+ && std::fs::read_to_string(&required_paths[2]).ok().as_deref()
+ == Some(expected_marker.as_str());
+ if !cache_is_current {
+ if install_dir.exists() {
+ std::fs::remove_dir_all(&install_dir).unwrap_or_else(|e| {
+ panic!(
+ "failed to clear stale runtime bundle {}: {e}",
+ install_dir.display()
+ )
+ });
+ }
let archive =
cached_download(&download_url, &cache_key, &expected_integrity, &cache_dir);
- verify_binary_present_in_archive(&archive, platform.binary_name, &archive_name);
- extract_to_cache(&archive, &install_dir, platform);
+ verify_runtime_package(&archive, platform, &archive_name);
+ extract_to_cache(
+ &archive,
+ &install_dir,
+ platform,
+ include_runtime,
+ &expected_marker,
+ );
}
// Re-check after potential download+extract above; not an `else`
// because we need to verify the extraction actually produced the file.
- if final_path.is_file() {
+ if required_paths.iter().all(|path| path.is_file()) {
println!("cargo:rustc-cfg=has_extracted_cli");
}
}
@@ -176,19 +199,8 @@ fn build_embedded_archive(package: &[u8], platform: Platform, include_runtime: b
.mtime(0)
.write(Vec::new(), flate2::Compression::default());
let mut archive = tar::Builder::new(encoder);
- append_archive_file(
- &mut archive,
- platform.binary_name,
- &extract_binary_bytes(package, platform),
- 0o755,
- );
+ let runtime = append_hostless_runtime_tree(&mut archive, package, platform);
if include_runtime {
- let runtime = extract_runtime_library_bytes(package).unwrap_or_else(|| {
- panic!(
- "package `{}` does not contain the native runtime library required by the `bundled-in-process` feature",
- platform.package_name
- )
- });
append_archive_file(
&mut archive,
platform.runtime_library_name(),
@@ -204,6 +216,103 @@ fn build_embedded_archive(package: &[u8], platform: Platform, include_runtime: b
.expect("failed to compress minimal embedded CLI archive")
}
+fn append_hostless_runtime_tree(
+ archive: &mut tar::Builder,
+ package: &[u8],
+ platform: Platform,
+) -> Vec {
+ let decoder = flate2::read::GzDecoder::new(package);
+ let mut source = tar::Archive::new(decoder);
+ let mut runtime = None;
+ for entry in source
+ .entries()
+ .unwrap_or_else(|e| panic!("failed to read npm package entries: {e}"))
+ {
+ let mut entry = entry.unwrap_or_else(|e| panic!("failed to read npm package entry: {e}"));
+ if !entry.header().entry_type().is_file() {
+ continue;
+ }
+ let source_path = entry
+ .path()
+ .unwrap_or_else(|e| panic!("failed to read npm package path: {e}"));
+ let Some(destination) = hostless_runtime_path(&source_path.to_string_lossy(), platform)
+ else {
+ continue;
+ };
+ let mut bytes = Vec::with_capacity(entry.size() as usize);
+ entry
+ .read_to_end(&mut bytes)
+ .unwrap_or_else(|e| panic!("failed to read npm package entry bytes: {e}"));
+ let mode = entry.header().mode().unwrap_or(0o644);
+ if destination == Path::new("runtime.node") {
+ runtime = Some(bytes.clone());
+ }
+ append_archive_file(
+ archive,
+ destination
+ .to_str()
+ .expect("npm package paths are valid UTF-8"),
+ &bytes,
+ mode,
+ );
+ }
+ runtime.unwrap_or_else(|| {
+ panic!(
+ "package `{}` does not contain prebuilds//runtime.node",
+ platform.package_name
+ )
+ })
+}
+
+fn hostless_runtime_path(source: &str, platform: Platform) -> Option {
+ let relative = source.strip_prefix("package/")?;
+ let parts: Vec<&str> = relative.split('/').collect();
+ if parts.iter().any(|part| part.is_empty() || *part == "..") {
+ return None;
+ }
+ let top_level = *parts.first()?;
+ let file_name = *parts.last()?;
+ const EXCLUDED_TOP_LEVEL: &[&str] = &[
+ "app.js",
+ "assets",
+ "changelog.json",
+ "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",
+ ];
+ if EXCLUDED_TOP_LEVEL.contains(&top_level)
+ || (top_level.starts_with("tree-sitter") && top_level.ends_with(".wasm"))
+ || (top_level.starts_with("voice-") && top_level.ends_with(".js"))
+ || file_name == "cli-native.node"
+ || parts.contains(&"mediaremote-adapter")
+ || file_name.starts_with("copilot-runtime-bin")
+ {
+ return None;
+ }
+ if top_level == "prebuilds" {
+ let npm_platform = platform
+ .package_name
+ .strip_prefix("copilot-")
+ .expect("platform package name has copilot- prefix");
+ if parts.get(1) != Some(&npm_platform) || parts.len() < 3 {
+ return None;
+ }
+ return Some(parts[2..].iter().copied().collect());
+ }
+ Some(parts.iter().copied().collect())
+}
+
fn append_archive_file(
archive: &mut tar::Builder,
path: &str,
@@ -315,6 +424,14 @@ struct Platform {
}
impl Platform {
+ fn runtime_wrapper_name(&self) -> &'static str {
+ if self.package_name.contains("win32") {
+ "copilot-runtime.exe"
+ } else {
+ "copilot-runtime"
+ }
+ }
+
fn runtime_library_name(&self) -> &'static str {
if self.package_name.contains("win32") {
"copilot_runtime.dll"
@@ -368,8 +485,8 @@ fn target_platform() -> Option {
}
}
-/// Write the single binary entry from `archive` to
-/// `/` and return the resulting path.
+/// Write the runtime wrapper pair from `archive` to `install_dir` and return
+/// the wrapper path.
/// Idempotent — returns the existing path if a previous build already
/// populated the target.
///
@@ -378,15 +495,13 @@ fn target_platform() -> Option {
/// binary. `fs::rename` for files is atomic on both Unix and Windows
/// (Windows uses `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`); for
/// directories it is not, which is why we stage at file granularity.
-fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> PathBuf {
- let final_path = install_dir.join(platform.binary_name);
-
- // Caller already gated on `final_path.is_file()`; this is a safety
- // net for any future caller that forgets.
- if final_path.is_file() {
- return final_path;
- }
-
+fn extract_to_cache(
+ archive: &[u8],
+ install_dir: &Path,
+ platform: Platform,
+ include_runtime: bool,
+ marker: &str,
+) -> PathBuf {
std::fs::create_dir_all(install_dir).unwrap_or_else(|e| {
panic!(
"failed to create install dir {}: {e}",
@@ -394,8 +509,90 @@ fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> P
)
});
- let bytes = extract_binary_bytes(archive, platform);
+ let decoder = flate2::read::GzDecoder::new(archive);
+ let mut source = tar::Archive::new(decoder);
+ let mut runtime = None;
+ for entry in source
+ .entries()
+ .unwrap_or_else(|e| panic!("failed to read npm package entries: {e}"))
+ {
+ let mut entry = entry.unwrap_or_else(|e| panic!("failed to read npm package entry: {e}"));
+ if !entry.header().entry_type().is_file() {
+ continue;
+ }
+ let source_path = entry
+ .path()
+ .unwrap_or_else(|e| panic!("failed to read npm package path: {e}"));
+ let Some(destination) = hostless_runtime_path(&source_path.to_string_lossy(), platform)
+ else {
+ continue;
+ };
+ if destination == Path::new(platform.binary_name) {
+ continue;
+ }
+ let mut bytes = Vec::with_capacity(entry.size() as usize);
+ entry
+ .read_to_end(&mut bytes)
+ .unwrap_or_else(|e| panic!("failed to read npm package entry bytes: {e}"));
+ let executable = entry.header().mode().unwrap_or(0o644) & 0o111 != 0;
+ if destination == Path::new("runtime.node") {
+ runtime = Some(bytes.clone());
+ }
+ install_cached_file_path(install_dir, &destination, &bytes, executable);
+ }
+ let runtime = runtime.expect("verified runtime.node is present");
+ if include_runtime {
+ install_cached_file(
+ install_dir,
+ platform.runtime_library_name(),
+ &runtime,
+ false,
+ );
+ }
+ install_cached_file(
+ install_dir,
+ ".hostless-runtime-assets-v1",
+ marker.as_bytes(),
+ false,
+ );
+
+ let final_path = install_dir.join(platform.runtime_wrapper_name());
+ println!(
+ "cargo:warning=Extracted Copilot runtime bundle to {}",
+ install_dir.display()
+ );
+ final_path
+}
+
+fn install_cached_file(install_dir: &Path, file_name: &str, bytes: &[u8], executable: bool) {
+ install_cached_file_path(install_dir, Path::new(file_name), bytes, executable);
+}
+fn install_cached_file_path(
+ install_dir: &Path,
+ relative_path: &Path,
+ bytes: &[u8],
+ executable: bool,
+) {
+ assert!(
+ !relative_path.is_absolute()
+ && !relative_path.components().any(|component| {
+ matches!(
+ component,
+ std::path::Component::Prefix(_)
+ | std::path::Component::RootDir
+ | std::path::Component::ParentDir
+ )
+ }),
+ "unsafe runtime package path: {}",
+ relative_path.display()
+ );
+ let final_path = install_dir.join(relative_path);
+ if final_path.is_file() {
+ return;
+ }
+ std::fs::create_dir_all(final_path.parent().expect("runtime asset has parent"))
+ .unwrap_or_else(|e| panic!("failed to create runtime asset directory: {e}"));
// Staging file is a sibling of the final binary so the rename stays
// on the same filesystem (cross-fs rename is not atomic). PID + nanos
// disambiguate concurrent builds racing on the same cache.
@@ -405,7 +602,10 @@ fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> P
.unwrap_or(0);
let staging_path = install_dir.join(format!(
".{}.staging-{}-{nanos}",
- platform.binary_name,
+ relative_path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("runtime-asset"),
std::process::id(),
));
@@ -418,7 +618,7 @@ fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> P
);
});
- if let Err(e) = f.write_all(&bytes) {
+ if let Err(e) = f.write_all(bytes) {
let _ = std::fs::remove_file(&staging_path);
panic!(
"failed to write staging file {}: {e}",
@@ -427,7 +627,7 @@ fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> P
}
#[cfg(unix)]
- {
+ if executable {
use std::os::unix::fs::PermissionsExt;
if let Err(e) = f.set_permissions(std::fs::Permissions::from_mode(0o755)) {
let _ = std::fs::remove_file(&staging_path);
@@ -472,32 +672,6 @@ fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> P
final_path.display()
);
}
-
- // Surface where the binary landed so contributors can find it. Quiet
- // on the hot path: the caller's `is_file()` short-circuit (and the
- // safety net at the top of this function) means this only fires on a
- // true cache miss.
- println!(
- "cargo:warning=Extracted Copilot CLI to {}",
- final_path.display()
- );
-
- final_path
-}
-
-fn extract_runtime_library_bytes(archive: &[u8]) -> Option> {
- let gz = flate2::read::GzDecoder::new(archive);
- let mut tar = tar::Archive::new(gz);
- for entry in tar.entries().ok()? {
- let mut entry = entry.ok()?;
- let name = entry.path().ok()?.to_string_lossy().into_owned();
- if name == "runtime.node" || name.ends_with("/runtime.node") {
- let mut bytes = Vec::with_capacity(entry.size() as usize);
- entry.read_to_end(&mut bytes).ok()?;
- return Some(bytes);
- }
- }
- None
}
/// Replace characters outside `[a-zA-Z0-9._-]` with `_` so the version
@@ -514,37 +688,6 @@ fn sanitize_version(version: &str) -> String {
.collect()
}
-/// Extract the single `binary_name` entry from the npm package archive. Reused
-/// between embed mode's `verify_binary_present_in_archive` and the
-/// `extract_to_cache` path used when `bundled-cli` is off. Panics if the
-/// entry isn't found — callers have already invoked
-/// `verify_binary_present_in_archive`.
-fn extract_binary_bytes(archive: &[u8], platform: Platform) -> Vec {
- let gz = flate2::read::GzDecoder::new(archive);
- let mut tar = tar::Archive::new(gz);
- for entry in tar
- .entries()
- .unwrap_or_else(|e| panic!("failed to read tar entries: {e}"))
- {
- let mut entry = entry.unwrap_or_else(|e| panic!("failed to read tar entry: {e}"));
- let path = entry
- .path()
- .unwrap_or_else(|e| panic!("failed to read tar entry path: {e}"));
- let name = path.to_string_lossy().into_owned();
- if name == platform.binary_name || name.ends_with(&format!("/{}", platform.binary_name)) {
- let mut bytes = Vec::with_capacity(entry.size() as usize);
- entry
- .read_to_end(&mut bytes)
- .unwrap_or_else(|e| panic!("failed to read tar entry bytes: {e}"));
- return bytes;
- }
- }
- panic!(
- "binary `{}` not found in package `{}`",
- platform.binary_name, platform.package_name
- );
-}
-
/// Read a file from the download cache, or download it (with retries) and save
/// to cache. Verifies npm integrity on every path. Evicts stale/corrupt cache entries
/// automatically. Cache I/O failures are treated as cache misses — they never
@@ -682,15 +825,17 @@ fn try_download(url: &str) -> Result, DownloadError> {
}
}
-/// Walks the downloaded archive at build time to confirm an entry matching
-/// `binary_name` exists. Panics with a clear message if not.
-fn verify_binary_present_in_archive(archive: &[u8], binary_name: &str, package_name: &str) {
- let found = archive_contains_tar_entry(archive, binary_name);
- if !found {
+fn verify_runtime_package(archive: &[u8], platform: Platform, package_name: &str) {
+ for file_name in [
+ platform.binary_name,
+ "runtime.node",
+ platform.runtime_wrapper_name(),
+ ] {
+ if archive_contains_tar_entry(archive, file_name) {
+ continue;
+ }
panic!(
- "Copilot CLI package `{package_name}` does not contain an entry named `{binary_name}`. \
- The package layout may have changed; runtime extraction would fail. \
- Update `verify_binary_present_in_archive` in build.rs and the matching `extract_binary` in src/embeddedcli.rs."
+ "Copilot runtime package `{package_name}` does not contain an entry named `{file_name}`"
);
}
}
diff --git a/rust/src/embeddedcli.rs b/rust/src/embeddedcli.rs
index 40900a4d22..3cc527a2e2 100644
--- a/rust/src/embeddedcli.rs
+++ b/rust/src/embeddedcli.rs
@@ -3,10 +3,10 @@
//! feature set).
//!
//! Normal builds embed the platform release archive from GitHub Releases.
-//! Builds with `bundled-in-process` instead embed a minimal archive from the
-//! platform npm package containing the CLI executable and native runtime
-//! library. Extraction to a real on-disk path is deferred until the first call
-//! to [`path`] / [`install_at`].
+//! Builds with `bundled-in-process` instead embed a filtered archive from the
+//! platform npm package containing the CLI executable, runtime wrapper, native
+//! runtime artifacts, and auxiliary runtime assets. Extraction to a real
+//! on-disk path is deferred until the relevant installer is called.
//!
//! The embedded bytes are part of the consumer's signed binary and therefore
//! trusted *as the source of truth* — but the bytes that land on disk are not.
@@ -28,7 +28,7 @@
// off but still needs to exercise them.
#[cfg(any(has_bundled_cli, test))]
use std::fs;
-#[cfg(all(has_bundled_cli, any(feature = "bundled-in-process", not(windows))))]
+#[cfg(has_bundled_cli)]
use std::io::Read;
#[cfg(any(has_bundled_cli, test))]
use std::io::Write;
@@ -65,9 +65,19 @@ const CLI_VERSION: &str = env!("COPILOT_SDK_CLI_VERSION");
const CLI_BINARY_NAME: &str = "copilot.exe";
#[cfg(all(has_bundled_cli, not(windows)))]
const CLI_BINARY_NAME: &str = "copilot";
+#[cfg(all(has_bundled_cli, windows))]
+const RUNTIME_BINARY_NAME: &str = "copilot-runtime.exe";
+#[cfg(all(has_bundled_cli, not(windows)))]
+const RUNTIME_BINARY_NAME: &str = "copilot-runtime";
+#[cfg(has_bundled_cli)]
+const RUNTIME_NODE_NAME: &str = "runtime.node";
+#[cfg(has_bundled_cli)]
+const RUNTIME_VERSION_MARKER: &str = ".copilot-runtime-version";
#[cfg(feature = "bundled-cli")]
static INSTALLED_PATH: OnceLock> = OnceLock::new();
+#[cfg(feature = "bundled-cli")]
+static INSTALLED_RUNTIME_PATH: OnceLock > = OnceLock::new();
/// Returns the path to the installed CLI binary, lazily extracting the
/// embedded archive on first call.
@@ -91,7 +101,7 @@ pub(crate) fn path() -> Option {
#[cfg(has_bundled_cli)]
{
let dir = default_install_dir(CLI_VERSION);
- match install(&dir, build_time::CLI_ARCHIVE) {
+ match install_cli_bundle(&dir, build_time::CLI_ARCHIVE) {
Ok(path) => {
info!(path = %path.display(), version = CLI_VERSION, "embedded CLI installed");
return Some(path);
@@ -119,7 +129,7 @@ pub(crate) fn path() -> Option {
pub(crate) fn install_at(extract_dir: &Path) -> Option {
#[cfg(has_bundled_cli)]
{
- match install(extract_dir, build_time::CLI_ARCHIVE) {
+ match install_cli_bundle(extract_dir, build_time::CLI_ARCHIVE) {
Ok(path) => {
info!(path = %path.display(), version = CLI_VERSION, "embedded CLI installed");
return Some(path);
@@ -136,6 +146,93 @@ pub(crate) fn install_at(extract_dir: &Path) -> Option {
None
}
+/// Returns the path to the bundled runtime wrapper, extracting the wrapper and
+/// adjacent `runtime.node` on first call.
+#[cfg(feature = "bundled-cli")]
+pub(crate) fn runtime_path() -> Option {
+ INSTALLED_RUNTIME_PATH
+ .get_or_init(|| {
+ #[cfg(has_bundled_cli)]
+ {
+ let dir = default_install_dir(CLI_VERSION);
+ match install_runtime(&dir, build_time::CLI_ARCHIVE) {
+ Ok(path) => {
+ info!(path = %path.display(), version = CLI_VERSION, "embedded runtime installed");
+ return Some(path);
+ }
+ Err(e) => {
+ warn!(error = %e, "embedded runtime installation failed");
+ }
+ }
+ }
+ None
+ })
+ .clone()
+}
+
+/// Installs the bundled runtime wrapper and adjacent `runtime.node` into a
+/// caller-specified directory.
+#[cfg(feature = "bundled-cli")]
+pub(crate) fn install_runtime_at(extract_dir: &Path) -> Option {
+ #[cfg(has_bundled_cli)]
+ {
+ let install_dir = match runtime_install_dir(extract_dir, CLI_VERSION) {
+ Ok(dir) => dir,
+ Err(e) => {
+ warn!(error = %e, "embedded runtime install directory selection failed");
+ return None;
+ }
+ };
+ match install_runtime(&install_dir, build_time::CLI_ARCHIVE) {
+ Ok(path) => {
+ info!(path = %path.display(), version = CLI_VERSION, "embedded runtime installed");
+ return Some(path);
+ }
+ Err(e) => {
+ warn!(error = %e, "embedded runtime installation failed");
+ }
+ }
+ }
+ #[cfg(not(has_bundled_cli))]
+ {
+ let _ = extract_dir;
+ }
+ None
+}
+
+#[cfg(has_bundled_cli)]
+fn runtime_install_dir(base_dir: &Path, version: &str) -> Result {
+ fs::create_dir_all(base_dir)
+ .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::CreateDir, e))?;
+ let marker = base_dir.join(RUNTIME_VERSION_MARKER);
+ match fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(&marker)
+ {
+ Ok(mut file) => {
+ if let Err(error) = file
+ .write_all(version.as_bytes())
+ .and_then(|()| file.sync_all())
+ {
+ drop(file);
+ let _ = fs::remove_file(&marker);
+ return Err(EmbeddedCliError::new(EmbeddedCliErrorKind::Io, error));
+ }
+ Ok(base_dir.to_path_buf())
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
+ let installed_version = fs::read_to_string(marker).unwrap_or_default();
+ if installed_version == version {
+ Ok(base_dir.to_path_buf())
+ } else {
+ Ok(base_dir.join(version))
+ }
+ }
+ Err(error) => Err(EmbeddedCliError::new(EmbeddedCliErrorKind::Io, error)),
+ }
+}
+
#[cfg(has_bundled_cli)]
fn default_install_dir(version: &str) -> PathBuf {
let cache = dirs::cache_dir().unwrap_or_else(std::env::temp_dir);
@@ -168,34 +265,151 @@ const RUNTIME_LIBRARY_NAME: &str = "libcopilot_runtime.dylib";
const RUNTIME_LIBRARY_NAME: &str = "libcopilot_runtime.so";
#[cfg(has_bundled_cli)]
-fn install(install_dir: &Path, archive: &[u8]) -> Result {
- let final_path = install_cli(install_dir, archive)?;
+fn install_cli_bundle(install_dir: &Path, archive: &[u8]) -> Result {
+ install_cli(install_dir, archive)?;
+ install_hostless_assets(install_dir, archive)?;
#[cfg(feature = "bundled-in-process")]
{
install_runtime_library(install_dir, archive)?;
}
- Ok(final_path)
+ Ok(install_dir.join(CLI_BINARY_NAME))
+}
+
+#[cfg(has_bundled_cli)]
+fn install_runtime(install_dir: &Path, archive: &[u8]) -> Result {
+ fs::create_dir_all(install_dir)
+ .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::CreateDir, e))?;
+ install_hostless_assets(install_dir, archive)?;
+ install_runtime_pair(install_dir, archive)?;
+ Ok(install_dir.join(RUNTIME_BINARY_NAME))
+}
+
+#[cfg(has_bundled_cli)]
+fn install_hostless_assets(install_dir: &Path, archive: &[u8]) -> Result<(), EmbeddedCliError> {
+ let gz = flate2::read::GzDecoder::new(archive);
+ let mut tar = tar::Archive::new(gz);
+ for entry in tar
+ .entries()
+ .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?
+ {
+ let mut entry =
+ entry.map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?;
+ if !entry.header().entry_type().is_file() {
+ continue;
+ }
+ let path = entry
+ .path()
+ .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?
+ .into_owned();
+ let file_name = path.file_name().and_then(|name| name.to_str());
+ if path == Path::new(CLI_BINARY_NAME)
+ || matches!(
+ file_name,
+ Some("copilot_runtime.dll")
+ | Some("libcopilot_runtime.dylib")
+ | Some("libcopilot_runtime.so")
+ )
+ {
+ continue;
+ }
+ if path.is_absolute()
+ || path.components().any(|component| {
+ matches!(
+ component,
+ std::path::Component::Prefix(_)
+ | std::path::Component::RootDir
+ | std::path::Component::ParentDir
+ )
+ })
+ {
+ return Err(EmbeddedCliError::with_message(
+ EmbeddedCliErrorKind::Archive,
+ format!("unsafe embedded runtime asset path: {}", path.display()),
+ ));
+ }
+ let mut bytes = Vec::with_capacity(entry.size() as usize);
+ entry
+ .read_to_end(&mut bytes)
+ .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?;
+ let target = install_dir.join(&path);
+ if fs::read(&target)
+ .map(|installed| installed == bytes)
+ .unwrap_or(false)
+ {
+ continue;
+ }
+ let parent = target.parent().ok_or_else(|| {
+ EmbeddedCliError::with_message(
+ EmbeddedCliErrorKind::Archive,
+ format!("embedded runtime asset has no parent: {}", path.display()),
+ )
+ })?;
+ fs::create_dir_all(parent)
+ .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::CreateDir, e))?;
+ let tmp = write_temp_file(parent, &bytes)?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mode = entry.header().mode().unwrap_or(0o644) & 0o777;
+ fs::set_permissions(&tmp, fs::Permissions::from_mode(mode))
+ .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e))?;
+ }
+ if let Err(error) = publish(&tmp, &target) {
+ let _ = fs::remove_file(&tmp);
+ return Err(error);
+ }
+ }
+ Ok(())
+}
+
+#[cfg(has_bundled_cli)]
+fn install_runtime_pair(install_dir: &Path, archive: &[u8]) -> Result<(), EmbeddedCliError> {
+ install_adjacent_file(install_dir, archive, RUNTIME_NODE_NAME, "runtime.node")?;
+ install_adjacent_file(
+ install_dir,
+ archive,
+ RUNTIME_BINARY_NAME,
+ "copilot runtime wrapper",
+ )
}
#[cfg(all(has_bundled_cli, feature = "bundled-in-process"))]
fn install_runtime_library(install_dir: &Path, archive: &[u8]) -> Result<(), EmbeddedCliError> {
- let target = install_dir.join(RUNTIME_LIBRARY_NAME);
- if fs::metadata(&target).map(|m| m.len() > 0).unwrap_or(false) {
- return Ok(());
- }
- let bytes = extract_binary(archive, RUNTIME_LIBRARY_NAME)?;
+ install_adjacent_file(
+ install_dir,
+ archive,
+ RUNTIME_LIBRARY_NAME,
+ "in-process FFI runtime library",
+ )
+}
+
+#[cfg(has_bundled_cli)]
+fn install_adjacent_file(
+ install_dir: &Path,
+ archive: &[u8],
+ file_name: &str,
+ label: &str,
+) -> Result<(), EmbeddedCliError> {
+ let target = install_dir.join(file_name);
+ let bytes = extract_binary(archive, file_name)?;
if bytes.is_empty() {
return Err(EmbeddedCliError::with_message(
EmbeddedCliErrorKind::Verification,
- "embedded runtime library is empty",
+ format!("embedded {label} is empty"),
));
}
+ if fs::read(&target)
+ .map(|installed| installed == bytes)
+ .unwrap_or(false)
+ {
+ return Ok(());
+ }
let tmp = write_temp_file(install_dir, &bytes)?;
if let Err(e) = publish(&tmp, &target) {
let _ = fs::remove_file(&tmp);
return Err(e);
}
- tracing::debug!(path = %target.display(), "in-process FFI runtime library installed");
+ tracing::debug!(path = %target.display(), %label, "embedded runtime artifact installed");
Ok(())
}
@@ -480,7 +694,7 @@ fn read_marker_len(marker_path: &Path) -> Option {
.ok()
}
-#[cfg(all(has_bundled_cli, any(feature = "bundled-in-process", not(windows))))]
+#[cfg(has_bundled_cli)]
fn extract_binary(archive: &[u8], binary_name: &str) -> Result, EmbeddedCliError> {
let gz = flate2::read::GzDecoder::new(archive);
let mut tar = tar::Archive::new(gz);
@@ -505,26 +719,6 @@ fn extract_binary(archive: &[u8], binary_name: &str) -> Result, Embedded
Err(EmbeddedCliErrorKind::BinaryNotFoundInArchive.into())
}
-#[cfg(all(has_bundled_cli, not(feature = "bundled-in-process"), windows))]
-fn extract_binary(archive: &[u8], binary_name: &str) -> Result, EmbeddedCliError> {
- let cursor = std::io::Cursor::new(archive);
- let mut zip = zip::ZipArchive::new(cursor)
- .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Zip, e))?;
- for i in 0..zip.len() {
- let mut entry = zip
- .by_index(i)
- .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Zip, e))?;
- let name = entry.name().to_string();
- if name == binary_name || name.ends_with(&format!("/{binary_name}")) {
- let mut bytes = Vec::with_capacity(entry.size() as usize);
- std::io::copy(&mut entry, &mut bytes)
- .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e))?;
- return Ok(bytes);
- }
- }
- Err(EmbeddedCliErrorKind::BinaryNotFoundInArchive.into())
-}
-
#[cfg(has_bundled_cli)]
fn sanitize_version(version: &str) -> String {
version
@@ -541,10 +735,7 @@ fn sanitize_version(version: &str) -> String {
#[allow(dead_code)]
enum EmbeddedCliErrorKind {
CreateDir,
- #[cfg(any(feature = "bundled-in-process", not(windows)))]
Archive,
- #[cfg(all(not(feature = "bundled-in-process"), windows))]
- Zip,
BinaryNotFoundInArchive,
Io,
/// Atomically renaming the staged temp file onto the final path failed.
@@ -561,10 +752,7 @@ impl std::fmt::Display for EmbeddedCliErrorKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EmbeddedCliErrorKind::CreateDir => f.write_str("failed to create install directory"),
- #[cfg(any(feature = "bundled-in-process", not(windows)))]
EmbeddedCliErrorKind::Archive => f.write_str("failed to read archive entry"),
- #[cfg(all(not(feature = "bundled-in-process"), windows))]
- EmbeddedCliErrorKind::Zip => f.write_str("failed to read zip archive"),
EmbeddedCliErrorKind::BinaryNotFoundInArchive => {
f.write_str("CLI binary not found in embedded archive")
}
@@ -670,7 +858,7 @@ mod tests {
#[cfg(all(has_bundled_cli, feature = "bundled-in-process"))]
#[test]
- fn embedded_archive_contains_only_expected_files() {
+ fn embedded_archive_contains_runtime_assets_and_excludes_cli_only_files() {
let gz = flate2::read::GzDecoder::new(build_time::CLI_ARCHIVE);
let mut archive = tar::Archive::new(gz);
let mut names: Vec = archive
@@ -687,12 +875,13 @@ mod tests {
.collect();
names.sort();
- let mut expected = vec![
- CLI_BINARY_NAME.to_string(),
- RUNTIME_LIBRARY_NAME.to_string(),
- ];
- expected.sort();
- assert_eq!(names, expected);
+ assert!(names.contains(&CLI_BINARY_NAME.to_string()));
+ assert!(names.contains(&RUNTIME_LIBRARY_NAME.to_string()));
+ assert!(names.contains(&RUNTIME_BINARY_NAME.to_string()));
+ assert!(names.contains(&RUNTIME_NODE_NAME.to_string()));
+ assert!(names.iter().any(|name| name.starts_with("ripgrep/")));
+ assert!(names.iter().any(|name| name.starts_with("definitions/")));
+ assert!(!names.contains(&"app.js".to_string()));
}
/// Bytes whose header looks like a valid executable image on the host
@@ -841,4 +1030,42 @@ mod tests {
assert_eq!(mode & 0o777, 0o755, "temp binary should be executable");
}
}
+
+ #[cfg(has_bundled_cli)]
+ #[test]
+ fn runtime_install_replaces_stale_pair() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ fs::write(dir.path().join(RUNTIME_NODE_NAME), b"stale runtime").expect("seed runtime");
+ fs::write(dir.path().join(RUNTIME_BINARY_NAME), b"stale wrapper").expect("seed wrapper");
+
+ install_runtime(dir.path(), build_time::CLI_ARCHIVE).expect("install runtime");
+
+ assert_eq!(
+ fs::read(dir.path().join(RUNTIME_NODE_NAME)).expect("read runtime"),
+ extract_binary(build_time::CLI_ARCHIVE, RUNTIME_NODE_NAME).expect("extract runtime")
+ );
+ assert_eq!(
+ fs::read(dir.path().join(RUNTIME_BINARY_NAME)).expect("read wrapper"),
+ extract_binary(build_time::CLI_ARCHIVE, RUNTIME_BINARY_NAME).expect("extract wrapper")
+ );
+ }
+
+ #[cfg(has_bundled_cli)]
+ #[test]
+ fn custom_runtime_install_dir_isolated_by_version() {
+ let dir = tempfile::tempdir().expect("tempdir");
+
+ assert_eq!(
+ runtime_install_dir(dir.path(), "1.0.0").expect("claim directory"),
+ dir.path()
+ );
+ assert_eq!(
+ runtime_install_dir(dir.path(), "1.0.0").expect("reuse directory"),
+ dir.path()
+ );
+ assert_eq!(
+ runtime_install_dir(dir.path(), "2.0.0").expect("isolate directory"),
+ dir.path().join("2.0.0")
+ );
+ }
}
diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs
index f784b1a6d1..a25990062c 100644
--- a/rust/src/ffi.rs
+++ b/rust/src/ffi.rs
@@ -2,12 +2,11 @@
//! library and speaking JSON-RPC over its C ABI,
//! instead of spawning a CLI child process and communicating over stdio/TCP.
//!
-//! The runtime's `host_start` export spawns the residual TypeScript worker
-//! itself — the packaged single-file CLI (`copilot --embedded-host`) or, for
-//! dev, `node dist-cli/index.js --embedded-host`. JSON-RPC frames are pumped
-//! across the ABI: writes go to `connection_write`; inbound frames arrive on a
-//! native callback that feeds an async reader. The framing is unchanged — the
-//! same LSP `Content-Length:` frames the stdio transport uses.
+//! The runtime's `host_start` export constructs the Rust server synchronously in
+//! this process. JSON-RPC frames are pumped across the ABI: writes go to
+//! `connection_write`; inbound frames arrive on a native callback that feeds an
+//! async reader. The framing is unchanged — the same LSP `Content-Length:`
+//! frames the stdio transport uses.
use std::collections::HashMap;
use std::ffi::c_void;
@@ -204,12 +203,11 @@ impl AsyncWrite for FfiWriter {
}
}
-/// Prepared FFI host: the bound cdylib exports plus the spawn arguments needed
-/// to start the runtime worker. The cdylib is loaded process-globally and never
-/// unloaded (see [`load_library`]).
+/// Prepared FFI host. The cdylib is loaded process-globally and never unloaded
+/// (see [`load_library`]).
pub(crate) struct FfiHost {
library_path: PathBuf,
- entrypoint: PathBuf,
+ cli_entrypoint: Option,
environment: Vec<(String, String)>,
args: Vec,
host_start: HostStartFn,
@@ -224,30 +222,34 @@ pub(crate) struct FfiHost {
unsafe impl Send for FfiHost {}
impl FfiHost {
- /// Load the cdylib next to `entrypoint` and bind its exports.
- ///
- /// `entrypoint` is the packaged single-file CLI binary or, for dev, a
- /// `.js` file launched via `node`. The native library is resolved relative
- /// to the entrypoint directory, supporting both packaged and development
- /// layouts.
+ /// Load the cdylib next to `runtime_entrypoint` and bind its exports.
pub(crate) fn create(
- entrypoint: &Path,
+ runtime_entrypoint: &Path,
+ cli_entrypoint: Option<&Path>,
environment: Vec<(String, String)>,
args: Vec,
) -> Result {
- let entrypoint = std::fs::canonicalize(entrypoint)
- .map(path_for_child_process)
+ let runtime_entrypoint = std::fs::canonicalize(runtime_entrypoint).map_err(|e| {
+ Error::with_message(
+ ErrorKind::InvalidConfig,
+ format!(
+ "failed to resolve in-process runtime entrypoint '{}': {e}",
+ runtime_entrypoint.display()
+ ),
+ )
+ })?;
+ let cli_entrypoint = cli_entrypoint
+ .map(std::fs::canonicalize)
+ .transpose()
.map_err(|e| {
Error::with_message(
ErrorKind::InvalidConfig,
- format!(
- "failed to resolve in-process CLI entrypoint '{}': {e}",
- entrypoint.display()
- ),
+ format!("failed to resolve explicit in-process CLI entrypoint: {e}"),
)
- })?;
- let library_path =
- std::fs::canonicalize(resolve_library_path(&entrypoint)?).map_err(|e| {
+ })?
+ .map(path_for_child_process);
+ let library_path = std::fs::canonicalize(resolve_library_path(&runtime_entrypoint)?)
+ .map_err(|e| {
Error::with_message(
ErrorKind::InvalidConfig,
format!("failed to resolve in-process runtime library: {e}"),
@@ -267,7 +269,7 @@ impl FfiHost {
Ok(Self {
library_path,
- entrypoint,
+ cli_entrypoint,
environment,
args,
host_start,
@@ -278,11 +280,7 @@ impl FfiHost {
})
}
- /// Start the runtime worker and open the FFI JSON-RPC connection.
- ///
- /// `host_start` blocks until the worker connects back and signals
- /// readiness (up to ~30s), and must not run on an async executor thread, so
- /// the blocking handshake is offloaded to [`tokio::task::spawn_blocking`].
+ /// Start the native runtime and open the FFI JSON-RPC connection.
pub(crate) async fn start(self) -> Result<(FfiReader, FfiWriter, Arc), Error> {
tokio::task::spawn_blocking(move || self.start_blocking())
.await
@@ -295,7 +293,7 @@ impl FfiHost {
}
fn start_blocking(self) -> Result<(FfiReader, FfiWriter, Arc), Error> {
- let argv = build_argv_json(&self.entrypoint, &self.args);
+ let argv = build_argv_json(self.cli_entrypoint.as_deref(), &self.args);
let env = build_env_json(&self.environment);
let (env_ptr, env_len) = match &env {
@@ -309,9 +307,8 @@ impl FfiHost {
return Err(Error::with_message(
ErrorKind::InvalidConfig,
format!(
- "copilot_runtime_host_start failed (library '{}', entrypoint '{}')",
- self.library_path.display(),
- self.entrypoint.display()
+ "copilot_runtime_host_start failed (library '{}')",
+ self.library_path.display()
),
));
}
@@ -440,6 +437,8 @@ pub(crate) fn prebuilds_folder() -> Option {
"win32"
} else if cfg!(target_os = "macos") {
"darwin"
+ } else if cfg!(all(target_os = "linux", target_env = "musl")) {
+ "linuxmusl"
} else if cfg!(target_os = "linux") {
"linux"
} else {
@@ -472,6 +471,11 @@ fn resolve_library_path(entrypoint: &Path) -> Result {
return Ok(flat);
}
+ let adjacent = dir.join("runtime.node");
+ if adjacent.is_file() {
+ return Ok(adjacent);
+ }
+
// Development package layout.
let prebuilds =
prebuilds_folder().map(|folder| dir.join("prebuilds").join(folder).join("runtime.node"));
@@ -521,28 +525,23 @@ fn path_for_child_process(path: PathBuf) -> PathBuf {
path
}
-fn build_argv_json(entrypoint: &Path, extra_args: &[String]) -> Vec {
- // A `.js` entrypoint (dev / dist-cli) is launched via node; the packaged
- // single-file CLI binary embeds its own Node and is invoked directly.
- let entrypoint_str = entrypoint.to_string_lossy().into_owned();
- let is_js = entrypoint
- .extension()
- .and_then(|ext| ext.to_str())
- .is_some_and(|ext| ext.eq_ignore_ascii_case("js"));
- let mut argv: Vec = if is_js {
- vec![
- "node".to_string(),
- entrypoint_str,
- "--embedded-host".to_string(),
- "--no-auto-update".to_string(),
- ]
- } else {
- vec![
+fn build_argv_json(entrypoint: Option<&Path>, extra_args: &[String]) -> Vec {
+ let mut argv = Vec::new();
+ if let Some(entrypoint) = entrypoint {
+ let entrypoint_str = entrypoint.to_string_lossy().into_owned();
+ if entrypoint
+ .extension()
+ .and_then(|ext| ext.to_str())
+ .is_some_and(|ext| ext.eq_ignore_ascii_case("js"))
+ {
+ argv.push("node".to_string());
+ }
+ argv.extend([
entrypoint_str,
"--embedded-host".to_string(),
"--no-auto-update".to_string(),
- ]
- };
+ ]);
+ }
argv.extend_from_slice(extra_args);
serde_json::to_vec(&argv).expect("argv serializes")
}
@@ -563,29 +562,20 @@ mod tests {
use super::*;
#[test]
- fn argv_pins_worker_and_appends_client_options() {
+ fn argv_without_entrypoint_contains_only_client_options() {
let argv: Vec = serde_json::from_slice(&build_argv_json(
- Path::new("copilot"),
+ None,
&["--log-level".into(), "debug".into()],
))
.unwrap();
- assert_eq!(
- argv,
- [
- "copilot",
- "--embedded-host",
- "--no-auto-update",
- "--log-level",
- "debug"
- ]
- );
+ assert_eq!(argv, ["--log-level", "debug"]);
}
#[test]
- fn javascript_entrypoint_uses_node() {
+ fn explicit_javascript_entrypoint_uses_node() {
let argv: Vec =
- serde_json::from_slice(&build_argv_json(Path::new("index.js"), &[])).unwrap();
+ serde_json::from_slice(&build_argv_json(Some(Path::new("index.js")), &[])).unwrap();
assert_eq!(
argv,
diff --git a/rust/src/lib.rs b/rust/src/lib.rs
index 4a9f73ca4c..5f6651ad11 100644
--- a/rust/src/lib.rs
+++ b/rust/src/lib.rs
@@ -179,8 +179,10 @@ pub enum Transport {
/// How the SDK locates the GitHub Copilot CLI binary.
#[derive(Debug, Clone, Default)]
pub enum CliProgram {
- /// Auto-resolve: `COPILOT_CLI_PATH` → embedded CLI → dev cache.
- /// This is the default.
+ /// Auto-resolve the transport's program. Managed child-process transports
+ /// select `COPILOT_CLI_PATH`, then the bundled runtime wrapper. In-process
+ /// transport loads the wrapper's adjacent runtime library directly unless
+ /// `COPILOT_CLI_PATH` explicitly selects a legacy embedded host.
#[default]
Resolve,
/// Use an explicit binary path (skips resolution).
@@ -204,12 +206,9 @@ pub const HAS_BUNDLED_CLI: bool = cfg!(has_bundled_cli);
/// Returns the path to the bundled Copilot CLI, extracting it from the
/// embedded archive on first call.
///
-/// This is the same path [`Client::start`] resolves to when
-/// [`ClientOptions::program`] is [`CliProgram::Resolve`], no
-/// `COPILOT_CLI_PATH` override is set, and no
-/// [`ClientOptions::bundled_cli_extract_dir`] is configured — exposing
-/// it directly so callers (health checks, diagnostics, version probes)
-/// can reach the bundled binary without spinning up a full [`Client`].
+/// This exposes the CLI artifact directly for callers such as health checks,
+/// diagnostics, version probes, and in-process hosting. Managed child-process
+/// transports resolve the bundled `copilot-runtime` wrapper instead.
///
/// Subsequent calls return the cached result. Extraction is skipped when
/// an already-published binary passes a cheap integrity re-check; a
@@ -235,12 +234,35 @@ pub fn install_bundled_cli() -> Option {
}
}
+/// Returns the path to the bundled `copilot-runtime` executable, extracting it
+/// with adjacent `runtime.node` on first call.
+///
+/// This is intended for health checks and intermediate launchers that need the
+/// concrete managed runtime path before [`Client::start`]. Subsequent calls
+/// return the cached result.
+///
+/// Returns `None` when the `bundled-cli` feature is off, the target platform
+/// isn't supported, or extraction failed. It does not fall back to the
+/// build-time extraction cache.
+pub fn install_bundled_runtime() -> Option {
+ #[cfg(feature = "bundled-cli")]
+ {
+ embeddedcli::runtime_path()
+ }
+ #[cfg(not(feature = "bundled-cli"))]
+ {
+ None
+ }
+}
+
/// Options for starting a [`Client`].
///
/// When `program` is [`CliProgram::Resolve`] (the default), [`Client::start`]
-/// uses `COPILOT_CLI_PATH` when set to a real file. Otherwise it uses the
-/// bundled Copilot CLI when the default `bundled-cli` cargo feature is enabled,
-/// or the build-time extracted dev-cache CLI when that feature is disabled.
+/// uses `COPILOT_CLI_PATH` when set to a real file. Managed child-process
+/// transports next use the bundled `copilot-runtime` wrapper. In-process
+/// transport loads the wrapper's adjacent runtime library. With `bundled-cli`
+/// disabled, the corresponding artifact is resolved from the build-time
+/// extraction cache.
///
/// Set `program` to [`CliProgram::Path`] to use an explicit binary instead.
/// This skips auto-resolution entirely.
@@ -859,8 +881,8 @@ impl ClientOptions {
self
}
- /// Override the directory where the bundled CLI binary is extracted on
- /// first use. See [`Self::bundled_cli_extract_dir`].
+ /// Override the directory where bundled CLI and runtime artifacts are
+ /// extracted on first use. See [`Self::bundled_cli_extract_dir`].
///
/// Only applies when the `bundled-cli` cargo feature is on. With
/// `bundled-cli` disabled (`default-features = false`), set
@@ -1195,6 +1217,7 @@ impl Client {
let resolve_start = Instant::now();
let resolved = resolve::copilot_binary_with_extract_dir(
options.bundled_cli_extract_dir.as_deref(),
+ true,
)?;
let resolve_elapsed = resolve_start.elapsed();
timings.program_resolve_ms = Some(StartupTimings::millis(resolve_elapsed));
@@ -1202,7 +1225,7 @@ impl Client {
elapsed_ms = resolve_elapsed.as_millis(),
"Client::start CLI program resolution complete"
);
- info!(path = %resolved.display(), "resolved copilot CLI");
+ info!(path = %resolved.display(), "resolved copilot runtime");
#[cfg(windows)]
{
if let Some(ext) = resolved.extension().and_then(|e| e.to_str()).filter(|ext| {
@@ -1353,7 +1376,15 @@ impl Client {
if !use_logged_in_user {
args.push("--no-auto-login".to_string());
}
- let host = crate::ffi::FfiHost::create(&program, environment, args)?;
+ let explicit_cli = std::env::var_os("COPILOT_CLI_PATH")
+ .map(PathBuf::from)
+ .filter(|path| path.is_file());
+ let host = crate::ffi::FfiHost::create(
+ &program,
+ explicit_cli.as_deref(),
+ environment,
+ args,
+ )?;
let (reader, writer, shared) = host.start().await?;
let client = Self::from_transport(
reader,
diff --git a/rust/src/resolve.rs b/rust/src/resolve.rs
index 1c88283a27..d8b996a11a 100644
--- a/rust/src/resolve.rs
+++ b/rust/src/resolve.rs
@@ -5,9 +5,9 @@
//! 1. An explicit path supplied by the application via
//! [`CliProgram::Path`](crate::CliProgram::Path).
//! 2. The `COPILOT_CLI_PATH` environment variable.
-//! 3. The bundled CLI embedded in this crate at build time (when the
+//! 3. The bundled program embedded in this crate at build time (when the
//! `bundled-cli` cargo feature is on, the default).
-//! 4. The build-time-extracted CLI in the per-user cache (when
+//! 4. The build-time-extracted program in the per-user cache (when
//! `bundled-cli` is off).
//!
//! There is no PATH scanning and no walking of standard install locations.
@@ -35,6 +35,7 @@ use crate::{Error, ErrorKind};
/// under it.
pub(crate) fn copilot_binary_with_extract_dir(
extract_dir: Option<&Path>,
+ use_runtime_wrapper: bool,
) -> Result {
if let Ok(value) = env::var("COPILOT_CLI_PATH") {
let candidate = PathBuf::from(&value);
@@ -49,11 +50,21 @@ pub(crate) fn copilot_binary_with_extract_dir(
#[cfg(feature = "bundled-cli")]
{
- let bundled = match extract_dir {
- Some(dir) => crate::embeddedcli::install_at(dir),
- None => crate::embeddedcli::path(),
+ let bundled = if use_runtime_wrapper {
+ match extract_dir {
+ Some(dir) => crate::embeddedcli::install_runtime_at(dir),
+ None => crate::embeddedcli::runtime_path(),
+ }
+ } else {
+ match extract_dir {
+ Some(dir) => crate::embeddedcli::install_at(dir),
+ None => crate::embeddedcli::path(),
+ }
};
if let Some(path) = bundled {
+ if use_runtime_wrapper {
+ validate_runtime_pair(&path)?;
+ }
return Ok(path);
}
}
@@ -61,16 +72,21 @@ pub(crate) fn copilot_binary_with_extract_dir(
#[cfg(not(feature = "bundled-cli"))]
{
let _ = extract_dir;
- if let Some(path) = extracted_cli_path() {
- return Ok(path);
+ if let Some(program) = extracted_program(use_runtime_wrapper) {
+ return Ok(program);
}
}
+ let binary_name = if use_runtime_wrapper {
+ runtime_binary_name()
+ } else {
+ cli_binary_name()
+ };
Err(ErrorKind::BinaryNotFound {
- name: "copilot".into(),
+ name: binary_name.into(),
hint: Some(
"the Copilot CLI is not bundled in this build of github-copilot-sdk and \
- COPILOT_CLI_PATH is not set. Either keep the default `bundled-cli` cargo \
+ no applicable path override is set. Either keep the default `bundled-cli` cargo \
feature enabled, set COPILOT_CLI_PATH, or supply an explicit path via \
`CliProgram::Path(...)` on `ClientOptions::program`."
.into(),
@@ -79,7 +95,7 @@ pub(crate) fn copilot_binary_with_extract_dir(
.into())
}
-/// Path to the CLI extracted into the per-user cache by `build.rs` when
+/// Path to the program extracted into the per-user cache by `build.rs` when
/// `bundled-cli` is disabled. Returns `None` if the cached file is missing
/// (e.g. the user deleted the cache after building, or built with
/// `COPILOT_SKIP_CLI_DOWNLOAD`).
@@ -93,14 +109,8 @@ pub(crate) fn copilot_binary_with_extract_dir(
/// `$HOME` / `$LOCALAPPDATA` into the artifact, breaks sccache across
/// machines, and prevents copying `target/` between hosts.
#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))]
-fn extracted_cli_path() -> Option {
+fn extracted_program(use_runtime_wrapper: bool) -> Option {
let version = env!("COPILOT_SDK_CLI_VERSION");
- let binary = if cfg!(windows) {
- "copilot.exe"
- } else {
- "copilot"
- };
-
let dir = match env::var_os("COPILOT_CLI_EXTRACT_DIR") {
Some(custom) => PathBuf::from(custom),
None => dirs::cache_dir()
@@ -110,8 +120,16 @@ fn extracted_cli_path() -> Option {
.join(sanitize_version(version)),
};
- let path = dir.join(binary);
- if path.is_file() {
+ let path = dir.join(if use_runtime_wrapper {
+ runtime_binary_name()
+ } else {
+ cli_binary_name()
+ });
+ if use_runtime_wrapper {
+ if validate_runtime_pair(&path).is_ok() {
+ return Some(path);
+ }
+ } else if path.is_file() {
return Some(path);
}
warn!(
@@ -125,10 +143,83 @@ fn extracted_cli_path() -> Option {
/// build opted out via `COPILOT_SKIP_CLI_DOWNLOAD`. In both cases there's
/// no binary to look up, so the resolver returns `None` immediately.
#[cfg(all(not(feature = "bundled-cli"), not(has_extracted_cli)))]
-fn extracted_cli_path() -> Option {
+fn extracted_program(_use_runtime_wrapper: bool) -> Option {
None
}
+fn validate_runtime_pair(wrapper: &Path) -> Result<(), Error> {
+ let wrapper_valid = wrapper
+ .metadata()
+ .map(|metadata| metadata.is_file() && metadata.len() > 0)
+ .unwrap_or(false);
+ let runtime_node = wrapper
+ .parent()
+ .map(|parent| parent.join("runtime.node"))
+ .unwrap_or_else(|| PathBuf::from("runtime.node"));
+ let runtime_valid = runtime_node
+ .metadata()
+ .map(|metadata| metadata.is_file() && metadata.len() > 0)
+ .unwrap_or(false);
+ if wrapper_valid && runtime_valid {
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+
+ let metadata = wrapper.metadata().map_err(|e| {
+ Error::with_message(
+ ErrorKind::InvalidConfig,
+ format!(
+ "failed to inspect Copilot runtime wrapper permissions at '{}': {e}",
+ wrapper.display()
+ ),
+ )
+ })?;
+ if metadata.permissions().mode() & 0o111 == 0 {
+ let mut permissions = metadata.permissions();
+ permissions.set_mode(permissions.mode() | 0o111);
+ std::fs::set_permissions(wrapper, permissions).map_err(|e| {
+ Error::with_message(
+ ErrorKind::InvalidConfig,
+ format!(
+ "failed to make Copilot runtime wrapper executable at '{}': {e}",
+ wrapper.display()
+ ),
+ )
+ })?;
+ }
+ }
+ return Ok(());
+ }
+ let detail = format!(
+ "The runtime wrapper and its adjacent runtime.node must both be non-empty files; checked '{}' and '{}'",
+ wrapper.display(),
+ runtime_node.display()
+ );
+ Err(Error::with_message(
+ ErrorKind::BinaryNotFound {
+ name: runtime_binary_name().into(),
+ hint: Some(detail.clone()),
+ },
+ detail,
+ ))
+}
+
+fn cli_binary_name() -> &'static str {
+ if cfg!(windows) {
+ "copilot.exe"
+ } else {
+ "copilot"
+ }
+}
+
+fn runtime_binary_name() -> &'static str {
+ if cfg!(windows) {
+ "copilot-runtime.exe"
+ } else {
+ "copilot-runtime"
+ }
+}
+
/// Replace characters outside `[a-zA-Z0-9._-]` with `_`. Kept in sync
/// with `build.rs::sanitize_version` and `embeddedcli::sanitize_version`
/// so all three resolve to the same cache directory for any given
@@ -143,3 +234,29 @@ fn sanitize_version(version: &str) -> String {
})
.collect()
}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ use tempfile::tempdir;
+
+ use super::validate_runtime_pair;
+
+ #[test]
+ fn runtime_pair_requires_adjacent_nonempty_runtime_node() {
+ let dir = tempdir().expect("temp dir");
+ let wrapper = dir.path().join(if cfg!(windows) {
+ "copilot-runtime.exe"
+ } else {
+ "copilot-runtime"
+ });
+ fs::write(&wrapper, b"wrapper").expect("write wrapper");
+
+ let error = validate_runtime_pair(&wrapper).expect_err("runtime.node is required");
+ assert!(error.to_string().contains("runtime.node"));
+
+ fs::write(dir.path().join("runtime.node"), b"runtime").expect("write runtime.node");
+ validate_runtime_pair(&wrapper).expect("complete pair is valid");
+ }
+}
diff --git a/rust/src/startup_timings.rs b/rust/src/startup_timings.rs
index 7938a462b7..7784a6ab3b 100644
--- a/rust/src/startup_timings.rs
+++ b/rust/src/startup_timings.rs
@@ -38,7 +38,7 @@ use std::time::Duration;
#[non_exhaustive]
pub struct StartupTimings {
/// Time spent in `resolve::copilot_binary_with_extract_dir` locating (and,
- /// for a bundled CLI, extracting) the copilot binary. `None` when the
+ /// for bundled artifacts, extracting) the Copilot program. `None` when the
/// caller passes an explicit [`CliProgram::Path`](crate::CliProgram::Path).
pub program_resolve_ms: Option,
/// Time spent spawning the CLI subprocess (`command.spawn()`). `None` for
diff --git a/rust/tests/cli_resolution_test.rs b/rust/tests/cli_resolution_test.rs
index 9e4927e676..75773a0c0d 100644
--- a/rust/tests/cli_resolution_test.rs
+++ b/rust/tests/cli_resolution_test.rs
@@ -10,7 +10,10 @@ use std::path::PathBuf;
use github_copilot_sdk::{
CliProgram, Client, ClientOptions, ErrorKind, HAS_BUNDLED_CLI, install_bundled_cli,
+ install_bundled_runtime,
};
+#[cfg(all(feature = "bundled-cli", has_bundled_cli))]
+use github_copilot_sdk::{SessionConfig, Transport};
use serial_test::serial;
fn unset_env(key: &str) {
@@ -95,7 +98,7 @@ async fn stale_env_override_falls_through() {
}
}
-/// With `bundled-cli` off, `build.rs` extracts the binary into the
+/// With `bundled-cli` off, `build.rs` extracts the runtime wrapper into the
/// per-user cache and the runtime resolver recomputes its location from
/// `COPILOT_SDK_CLI_VERSION` + the OS-derived binary name. This test
/// mirrors that convention and asserts the file is on disk where the
@@ -105,9 +108,9 @@ async fn stale_env_override_falls_through() {
fn extracted_binary_present_at_conventional_path() {
let version = env!("COPILOT_SDK_CLI_VERSION");
let binary = if cfg!(windows) {
- "copilot.exe"
+ "copilot-runtime.exe"
} else {
- "copilot"
+ "copilot-runtime"
};
let sanitized = sanitize_version_for_test(version);
let path = dirs::cache_dir()
@@ -158,21 +161,19 @@ async fn unbundled_resolver_finds_extracted_binary() {
/// With `bundled-cli` off, `COPILOT_CLI_EXTRACT_DIR` set at runtime
/// redirects the resolver to look directly under the named directory
/// (no per-version subdir, matching the build-time write semantics).
-/// We place a fake `copilot[.exe]` there and assert the resolver picks
-/// it up — failing here means the build-time / runtime convention has
-/// drifted.
#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))]
#[tokio::test(flavor = "current_thread")]
#[serial(copilot_cli_path)]
async fn extract_dir_runtime_override_is_honored() {
let tmp = tempfile::tempdir().expect("create tempdir");
let binary = if cfg!(windows) {
- "copilot.exe"
+ "copilot-runtime.exe"
} else {
- "copilot"
+ "copilot-runtime"
};
let fake = tmp.path().join(binary);
- std::fs::write(&fake, b"").expect("write fake binary");
+ std::fs::write(&fake, b"runtime").expect("write fake binary");
+ std::fs::write(tmp.path().join("runtime.node"), b"runtime").expect("write runtime.node");
unset_env("COPILOT_CLI_PATH");
set_env(
@@ -265,6 +266,14 @@ fn install_bundled_cli_returns_extracted_path() {
"install_bundled_cli returned a path that is not a file: {}",
first.display()
);
+ assert_eq!(
+ first.file_name().and_then(|name| name.to_str()),
+ Some(if cfg!(windows) {
+ "copilot.exe"
+ } else {
+ "copilot"
+ })
+ );
let second = install_bundled_cli().expect("second call should also succeed");
assert_eq!(
@@ -293,30 +302,6 @@ fn install_bundled_cli_returns_extracted_path() {
}
}
-/// `install_bundled_cli` returns the same path the runtime resolver
-/// hands to `Client::start` for `CliProgram::Resolve` with no
-/// `COPILOT_CLI_PATH` override. Observed indirectly: the binary the
-/// public API points at must exist, and `Client::start` must not
-/// report `BinaryNotFound` under the same env conditions.
-#[cfg(all(feature = "bundled-cli", has_bundled_cli))]
-#[tokio::test(flavor = "current_thread")]
-#[serial(copilot_cli_path)]
-async fn install_bundled_cli_matches_resolver() {
- unset_env("COPILOT_CLI_PATH");
- unset_env("COPILOT_CLI_EXTRACT_DIR");
-
- let direct = install_bundled_cli().expect("bundled CLI should install");
- assert!(direct.is_file());
-
- let opts = ClientOptions::default().with_program(CliProgram::Resolve);
- if let Err(e) = Client::start(opts).await {
- assert!(
- !matches!(e.kind(), ErrorKind::BinaryNotFound { .. }),
- "resolver returned BinaryNotFound while install_bundled_cli succeeded: {e}"
- );
- }
-}
-
/// With `bundled-cli` off (or the target unsupported), the public API
/// reports no bundled CLI and does not fall back to the
/// build-time-extracted dev-cache path that `CliProgram::Resolve` uses.
@@ -329,3 +314,97 @@ fn install_bundled_cli_is_none_without_embed() {
"install_bundled_cli must not fall back to the dev-cache path"
);
}
+
+#[cfg(all(feature = "bundled-cli", has_bundled_cli))]
+#[test]
+fn install_bundled_runtime_returns_wrapper_bundle() {
+ let first = install_bundled_runtime().expect("bundled runtime should install");
+ assert_eq!(
+ first.file_name().and_then(|name| name.to_str()),
+ Some(if cfg!(windows) {
+ "copilot-runtime.exe"
+ } else {
+ "copilot-runtime"
+ })
+ );
+ let runtime_node = first
+ .parent()
+ .expect("install directory")
+ .join("runtime.node");
+ assert!(
+ runtime_node.is_file(),
+ "runtime.node was not installed: {}",
+ runtime_node.display()
+ );
+ let second = install_bundled_runtime().expect("second call should also succeed");
+ assert_eq!(first, second);
+}
+
+#[cfg(all(feature = "bundled-cli", has_bundled_cli))]
+#[tokio::test(flavor = "current_thread")]
+#[serial(copilot_cli_path)]
+async fn bundled_runtime_clean_extract_starts_without_cli_host() {
+ let temp = tempfile::tempdir().expect("create tempdir");
+ let extract_dir = temp.path().join("runtime");
+ let empty_path = temp.path().join("empty-path");
+ let working_dir = temp.path().join("work");
+ std::fs::create_dir(&empty_path).expect("create empty PATH directory");
+ std::fs::create_dir(&working_dir).expect("create working directory");
+ assert!(!extract_dir.exists());
+
+ let options = ClientOptions::new()
+ .with_bundled_cli_extract_dir(&extract_dir)
+ .with_cwd(&working_dir)
+ .with_env([("PATH", empty_path.as_os_str())])
+ .with_env_remove([
+ "COPILOT_RUNTIME_HOST_COMMAND",
+ "COPILOT_CLI_PATH",
+ "COPILOT_RUNTIME_PROVIDER_LIB",
+ ])
+ .with_transport(Transport::Stdio)
+ .with_use_logged_in_user(false);
+ let client = Client::start(options)
+ .await
+ .expect("start bundled runtime from clean extraction");
+ let response = client
+ .ping(Some("hostless runtime"))
+ .await
+ .expect("ping bundled runtime");
+ assert_eq!(response.message, "pong: hostless runtime");
+
+ let session = client
+ .create_session(SessionConfig::default())
+ .await
+ .expect("create session");
+ session.disconnect().await.expect("disconnect session");
+ client.stop().await.expect("stop bundled runtime");
+
+ assert!(extract_dir.join("runtime.node").is_file());
+ assert!(
+ extract_dir
+ .join(if cfg!(windows) {
+ "copilot-runtime.exe"
+ } else {
+ "copilot-runtime"
+ })
+ .is_file()
+ );
+ assert!(
+ !extract_dir
+ .join(if cfg!(windows) {
+ "copilot.exe"
+ } else {
+ "copilot"
+ })
+ .exists()
+ );
+}
+
+#[cfg(not(all(feature = "bundled-cli", has_bundled_cli)))]
+#[test]
+fn install_bundled_runtime_is_none_without_embed() {
+ assert!(
+ install_bundled_runtime().is_none(),
+ "install_bundled_runtime must not fall back to the dev-cache path"
+ );
+}
diff --git a/rust/tests/e2e/support.rs b/rust/tests/e2e/support.rs
index 76f9a21c8f..8a4161efef 100644
--- a/rust/tests/e2e/support.rs
+++ b/rust/tests/e2e/support.rs
@@ -510,12 +510,8 @@ impl E2eContext {
.expect("start E2E client")
}
- /// Start a client that hosts the runtime in-process over FFI
- /// ([`Transport::InProcess`]). Unlike the stdio harness, the CLI
- /// entrypoint is passed as the program directly (the FFI host builds the
- /// `node --embedded-host` argv itself and loads the sibling
- /// runtime cdylib), so a `.js` entrypoint is not split into node +
- /// prefix_args here.
+ /// Start a client that hosts the bundled runtime directly in-process over
+ /// FFI ([`Transport::InProcess`]).
#[cfg_attr(not(feature = "bundled-in-process"), allow(dead_code))]
pub async fn start_inprocess_client(&self) -> Client {
let options = ClientOptions::new().with_transport(Transport::InProcess);
@@ -1070,7 +1066,8 @@ impl InProcessEnvGuard {
pairs.push(("COPILOT_SDK_AUTH_TOKEN".into(), "".into()));
pairs.push((
"COPILOT_CLI_PATH".into(),
- ctx.cli_path.clone().into_os_string(),
+ std::env::var_os("COPILOT_CLI_PATH")
+ .unwrap_or_else(|| ctx.cli_path.clone().into_os_string()),
));
// Some tests opt into gated runtime APIs via per-client `options.env`, which the
// in-process transport does not pass to the shared native runtime (see issue #1934).