diff --git a/dotnet/README.md b/dotnet/README.md
index 461ff0cf94..e76c1e4621 100644
--- a/dotnet/README.md
+++ b/dotnet/README.md
@@ -101,6 +101,11 @@ new CopilotClient(CopilotClientOptions? options = null)
- `RuntimeConnection.ForTcp(port = 0, connectionToken?, path?, args?)` — spawns the runtime as a child process listening on a TCP port. `port = 0` auto-allocates; if a non-zero port is already in use, startup fails (no fallback). Use `CopilotClient.RuntimePort` after `StartAsync` to read the assigned port. `connectionToken` is required if other clients will connect via `RuntimeConnection.ForUri(...)`.
- `RuntimeConnection.ForUri(url, connectionToken?)` — connects to an already-running runtime at `url` (e.g., `"localhost:8080"`). Does not spawn a process.
+Managed stdio and TCP connections use the bundled `copilot-runtime[.exe]` and
+adjacent `runtime.node` by default. An explicit connection path or
+`COPILOT_CLI_PATH` overrides the bundled runtime.
+Managed launch fails if the bundled wrapper pair is unavailable.
+
#### Methods
##### `StartAsync(): Task`
diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs
index f2da0a48f7..f5077f9623 100644
--- a/dotnet/src/Client.cs
+++ b/dotnet/src/Client.cs
@@ -55,6 +55,7 @@ namespace GitHub.Copilot;
///
public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
{
+ private const string ExplicitBundledCliMarker = ".copilot-explicit-cli";
///
/// Minimum protocol version this SDK can communicate with.
///
@@ -416,9 +417,19 @@ async Task StartCoreAsync(CancellationToken ct)
ffiArgs.Add("--remote");
}
+ var explicitCliPath = System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH");
+ if (string.IsNullOrEmpty(explicitCliPath))
+ {
+ explicitCliPath = null;
+ }
+ var ffiRuntimePath = explicitCliPath is null
+ ? GetBundledNativePath(FfiRuntimeHost.GetRuntimeLibraryFileName(), out var searchedRuntime)
+ ?? throw new InvalidOperationException(
+ $"In-process FFI runtime library not found at '{searchedRuntime}'.")
+ : ResolveRuntimePathForExplicitCli(explicitCliPath);
var ffiHost = FfiRuntimeHost.Create(
- ResolveCliPathForFfi(),
- GetNapiPrebuildsFolderOrThrow(),
+ ffiRuntimePath,
+ explicitCliPath,
ffiEnvironment,
ffiArgs,
_logger);
@@ -2215,17 +2226,19 @@ private static void ApplyTelemetryEnvironment(IDictionary envir
var tcpConnection = _connection as TcpRuntimeConnection;
var useStdio = _connection is StdioRuntimeConnection;
- // Use explicit path, COPILOT_CLI_PATH env var (from the connection's
- // Environment, options.Environment, or process env), or bundled runtime - no PATH fallback
- var envCliPath =
- (childProcessConnection.Environment is not null && childProcessConnection.Environment.TryGetValue("COPILOT_CLI_PATH", out var connEnvValue) ? connEnvValue : null)
- ?? (options.Environment is not null && options.Environment.TryGetValue("COPILOT_CLI_PATH", out var envValue) ? envValue : null)
- ?? System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH");
- var cliPath = childProcessConnection.Path
- ?? envCliPath
- ?? GetBundledCliPath(out var searchedPath)
- ?? throw new InvalidOperationException($"Copilot runtime not found at '{searchedPath}'. Ensure the SDK NuGet package was restored correctly or provide an explicit RuntimeConnection.ForStdio(path: ...) / RuntimeConnection.ForTcp(path: ...).");
- var cliPathSource = childProcessConnection.Path is not null ? "Options" : envCliPath is not null ? "Environment" : "Bundled";
+ // Explicit CLI paths preserve the legacy launch contract. Otherwise use
+ // the bundled native runtime pair.
+ var configuredEnvironment = childProcessConnection.Environment ?? options.Environment;
+ var envCliPath = configuredEnvironment is not null
+ ? configuredEnvironment.TryGetValue("COPILOT_CLI_PATH", out var configuredCliPath) ? configuredCliPath : null
+ : System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH");
+ var launch = childProcessConnection.Path is not null
+ ? new RuntimeLaunch(childProcessConnection.Path, "Options")
+ : envCliPath is not null
+ ? new RuntimeLaunch(envCliPath, "Environment")
+ : GetBundledRuntimeLaunch();
+ var cliPath = launch.Executable;
+ var cliPathSource = launch.Source;
var args = new List();
if (childProcessConnection.Args != null)
@@ -2407,7 +2420,11 @@ private static void ApplyTelemetryEnvironment(IDictionary envir
private static string? GetBundledCliPath(out string searchedPath)
{
- var binaryName = OperatingSystem.IsWindows() ? "copilot.exe" : "copilot";
+ return GetBundledNativePath(OperatingSystem.IsWindows() ? "copilot.exe" : "copilot", out searchedPath);
+ }
+
+ private static string? GetBundledNativePath(string binaryName, out string searchedPath)
+ {
// Always use portable RID (e.g., linux-x64) to match the build-time placement,
// since distro-specific RIDs (e.g., ubuntu.24.04-x64) are normalized at build time.
var rid = GetPortableRid()
@@ -2416,6 +2433,57 @@ private static void ApplyTelemetryEnvironment(IDictionary envir
return File.Exists(searchedPath) ? searchedPath : null;
}
+ private static RuntimeLaunch GetBundledRuntimeLaunch()
+ {
+ _ = GetBundledNativePath(
+ OperatingSystem.IsWindows() ? "copilot-runtime.exe" : "copilot-runtime",
+ out var searchedWrapper);
+ var directory = Path.GetDirectoryName(searchedWrapper)!;
+ var runtimeNode = Path.Combine(directory, "runtime.node");
+ var explicitCliMarker = Path.Combine(directory, ExplicitBundledCliMarker);
+ if (!File.Exists(searchedWrapper)
+ && !File.Exists(runtimeNode)
+ && File.Exists(explicitCliMarker)
+ && GetBundledCliPath(out _) is { } explicitCli)
+ {
+ return new RuntimeLaunch(explicitCli, "Bundled explicit CLI");
+ }
+ return ValidateRuntimePair(searchedWrapper, "Bundled runtime");
+ }
+
+ private static RuntimeLaunch ValidateRuntimePair(string wrapper, string source)
+ {
+ var runtimeNode = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(wrapper))!, "runtime.node");
+ if (!File.Exists(wrapper))
+ {
+ throw new InvalidOperationException($"Copilot runtime wrapper not found at '{wrapper}'.");
+ }
+ if (!File.Exists(runtimeNode))
+ {
+ throw new InvalidOperationException(
+ $"Copilot runtime wrapper at '{wrapper}' is missing its adjacent runtime.node at '{runtimeNode}'.");
+ }
+ if (new FileInfo(wrapper).Length == 0 || new FileInfo(runtimeNode).Length == 0)
+ {
+ throw new InvalidOperationException("Copilot runtime wrapper and adjacent runtime.node must both be non-empty.");
+ }
+#if NET8_0_OR_GREATER
+ if (!OperatingSystem.IsWindows())
+ {
+ var mode = File.GetUnixFileMode(wrapper);
+ const UnixFileMode executeBits =
+ UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
+ if ((mode & executeBits) == 0)
+ {
+ File.SetUnixFileMode(wrapper, mode | executeBits);
+ }
+ }
+#endif
+ return new RuntimeLaunch(wrapper, source);
+ }
+
+ private sealed record RuntimeLaunch(string Executable, string Source);
+
private static string? GetPortableRid()
{
string os;
@@ -2439,26 +2507,22 @@ private static void ApplyTelemetryEnvironment(IDictionary envir
return arch != null ? $"{os}-{arch}" : null;
}
- private string ResolveCliPathForFfi()
+ private static string ResolveRuntimePathForExplicitCli(string cliPath)
{
- var envCliPath = _options.Environment is not null && _options.Environment.TryGetValue("COPILOT_CLI_PATH", out var envValue)
- ? envValue
- : System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH");
- if (!string.IsNullOrEmpty(envCliPath))
+ var fullEntrypoint = Path.GetFullPath(cliPath);
+ var directory = Path.GetDirectoryName(fullEntrypoint)
+ ?? throw new InvalidOperationException($"Could not determine directory for '{cliPath}'.");
+ var flatLibraryPath = Path.Combine(directory, FfiRuntimeHost.GetRuntimeLibraryFileName());
+ if (File.Exists(flatLibraryPath))
{
- return envCliPath;
+ return flatLibraryPath;
}
-
- // Fall back to the bundled single-file CLI the same way stdio discovers it.
- // It embeds its own Node and is spawned directly as `copilot --embedded-host`,
- // with the sibling cdylib loaded in-process (FfiRuntimeHost.Create prefers the
- // flat `libcopilot_runtime.so`/`copilot_runtime.dll` next to the CLI, falling
- // back to the dev `prebuilds//runtime.node` layout).
- var bundled = GetBundledCliPath(out var searchedPath);
- return bundled
- ?? throw new InvalidOperationException(
- "In-process FFI hosting requires the Copilot CLI. Set the COPILOT_CLI_PATH "
- + $"environment variable, or ensure the bundled CLI is present (looked in '{searchedPath}').");
+ var prebuildsLibraryPath = Path.Combine(
+ directory, "prebuilds", GetNapiPrebuildsFolderOrThrow(), "runtime.node");
+ return File.Exists(prebuildsLibraryPath)
+ ? prebuildsLibraryPath
+ : throw new InvalidOperationException(
+ $"FFI runtime library not found. Looked for '{flatLibraryPath}' and '{prebuildsLibraryPath}'.");
}
///
diff --git a/dotnet/src/FfiRuntimeHost.cs b/dotnet/src/FfiRuntimeHost.cs
index a838b9fd17..cc3bccae92 100644
--- a/dotnet/src/FfiRuntimeHost.cs
+++ b/dotnet/src/FfiRuntimeHost.cs
@@ -17,11 +17,9 @@ namespace GitHub.Copilot;
/// and communicating over stdio/TCP.
///
///
-/// The Rust host_start export spawns the residual TypeScript worker itself —
-/// typically the packaged single-file CLI (copilot --embedded-host, which embeds
-/// its own Node) or, for dev, node dist-cli/index.js --embedded-host — so the .NET
-/// host never launches Node directly. JSON-RPC frames are pumped across the ABI: writes go
-/// to connection_write; inbound frames arrive on a native callback that feeds
+/// The Rust host_start export constructs the 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
/// .
///
/// The native interop layer has two implementations selected by target framework. On
@@ -41,7 +39,7 @@ internal sealed partial class FfiRuntimeHost : IDisposable
private const string LibraryName = "copilot_runtime";
private readonly ILogger _logger;
- private readonly string _cliEntrypoint;
+ private readonly string? _cliEntrypoint;
private readonly string _libraryPath;
private readonly IReadOnlyDictionary? _environment;
private readonly IReadOnlyList _args;
@@ -53,7 +51,7 @@ internal sealed partial class FfiRuntimeHost : IDisposable
private uint _connectionId;
private bool _disposed;
- private FfiRuntimeHost(string libraryPath, string cliEntrypoint, IReadOnlyDictionary? environment, IReadOnlyList args, ILogger logger)
+ private FfiRuntimeHost(string libraryPath, string? cliEntrypoint, IReadOnlyDictionary? environment, IReadOnlyList args, ILogger logger)
{
_libraryPath = libraryPath;
_cliEntrypoint = cliEntrypoint;
@@ -70,35 +68,22 @@ private FfiRuntimeHost(string libraryPath, string cliEntrypoint, IReadOnlyDictio
?? throw new InvalidOperationException("FfiRuntimeHost has not been started.");
///
- /// Loads the cdylib next to the given CLI entrypoint and prepares the FFI host.
- /// The entrypoint is either the packaged single-file CLI binary (e.g.
- /// runtimes/<rid>/native/copilot) or, for dev, a .js file (e.g.
- /// dist-cli/index.js) launched via node. The cdylib is resolved
- /// relative to the entrypoint directory, preferring the flat, natural
- /// shared-library name the .NET build emits (e.g. libcopilot_runtime.so)
- /// and falling back to the dev tarball layout
- /// prebuilds/<prebuildsFolder>/runtime.node, where
- /// is the napi-rs
- /// <node-platform>-<arch> folder name (e.g. win32-x64).
+ /// Loads the runtime cdylib and prepares the FFI host.
///
- public static FfiRuntimeHost Create(string cliEntrypoint, string prebuildsFolder, IReadOnlyDictionary? environment, IReadOnlyList args, ILogger logger)
+ public static FfiRuntimeHost Create(string libraryPath, string? cliEntrypoint, IReadOnlyDictionary? environment, IReadOnlyList args, ILogger logger)
{
- var fullEntrypoint = Path.GetFullPath(cliEntrypoint);
- var distDir = Path.GetDirectoryName(fullEntrypoint)
- ?? throw new InvalidOperationException($"Could not determine directory for '{cliEntrypoint}'.");
-
- // Bundled .NET layout: flat, natural shared-library name next to the CLI.
- var flatLibraryPath = Path.Combine(distDir, GetRuntimeLibraryFileName());
- // Dev/tarball layout: dist-cli/prebuilds/-/runtime.node.
- var prebuildsLibraryPath = Path.Combine(distDir, "prebuilds", prebuildsFolder, "runtime.node");
-
- var libraryPath = File.Exists(flatLibraryPath) ? flatLibraryPath
- : File.Exists(prebuildsLibraryPath) ? prebuildsLibraryPath
- : throw new InvalidOperationException(
- $"FFI runtime library not found. Looked for '{flatLibraryPath}' and '{prebuildsLibraryPath}'.");
-
- PrepareNativeLibrary(libraryPath);
- return new FfiRuntimeHost(libraryPath, fullEntrypoint, environment, args, logger);
+ var fullLibraryPath = Path.GetFullPath(libraryPath);
+ if (!File.Exists(fullLibraryPath))
+ {
+ throw new InvalidOperationException($"FFI runtime library not found at '{fullLibraryPath}'.");
+ }
+ PrepareNativeLibrary(fullLibraryPath);
+ return new FfiRuntimeHost(
+ fullLibraryPath,
+ cliEntrypoint is null ? null : Path.GetFullPath(cliEntrypoint),
+ environment,
+ args,
+ logger);
}
///
@@ -106,7 +91,7 @@ public static FfiRuntimeHost Create(string cliEntrypoint, string prebuildsFolder
/// emitted by the .NET build (the .node file renamed to what the Rust cdylib
/// would be called on this OS).
///
- private static string GetRuntimeLibraryFileName()
+ internal static string GetRuntimeLibraryFileName()
{
if (OperatingSystem.IsWindows()) return "copilot_runtime.dll";
if (OperatingSystem.IsMacOS()) return "libcopilot_runtime.dylib";
@@ -114,14 +99,11 @@ private static string GetRuntimeLibraryFileName()
}
///
- /// Starts the in-process runtime: spawns the CLI worker via the Rust host,
- /// waits for readiness, and opens the FFI JSON-RPC connection.
+ /// Starts the in-process Rust runtime and opens the FFI JSON-RPC connection.
///
public async Task StartAsync(CancellationToken cancellationToken)
{
- // host_start blocks until the worker connects back and signals readiness
- // (up to ~30s), and connection_open must run outside any async runtime, so
- // perform the blocking FFI handshake on a background thread.
+ // Keep synchronous native startup off the caller's async context.
await Task.Run(() =>
{
var argvJson = BuildArgvJson(_cliEntrypoint, _args);
@@ -131,7 +113,7 @@ await Task.Run(() =>
if (_serverId == 0)
{
throw new InvalidOperationException(
- $"copilot_runtime_host_start failed (library '{_libraryPath}', entrypoint '{_cliEntrypoint}').");
+ $"copilot_runtime_host_start failed (library '{_libraryPath}').");
}
_connectionId = NativeOpenConnection(_serverId);
@@ -154,24 +136,22 @@ await Task.Run(() =>
}
}
- private static byte[] BuildArgvJson(string cliEntrypoint, IReadOnlyList args)
+ private static byte[] BuildArgvJson(string? cliEntrypoint, IReadOnlyList args)
{
- // A .js entrypoint (dev / dist-cli) is launched via node; the packaged
- // single-file CLI binary embeds its own Node and is invoked directly.
- var isJsFile = cliEntrypoint.EndsWith(".js", StringComparison.OrdinalIgnoreCase);
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
writer.WriteStartArray();
- if (isJsFile)
+ if (cliEntrypoint is not null)
{
- writer.WriteStringValue("node");
+ if (cliEntrypoint.EndsWith(".js", StringComparison.OrdinalIgnoreCase))
+ {
+ writer.WriteStringValue("node");
+ }
+ writer.WriteStringValue(cliEntrypoint);
+ writer.WriteStringValue("--embedded-host");
+ writer.WriteStringValue("--no-auto-update");
}
- writer.WriteStringValue(cliEntrypoint);
- writer.WriteStringValue("--embedded-host");
- // Pin the worker to the bundled pkg matching the loaded cdylib, instead of
- // drifting to a newer version under the user's ~/.copilot/pkg (ABI skew).
- writer.WriteStringValue("--no-auto-update");
foreach (var arg in args)
{
writer.WriteStringValue(arg);
diff --git a/dotnet/src/build/GitHub.Copilot.SDK.targets b/dotnet/src/build/GitHub.Copilot.SDK.targets
index 5f7944b2c4..95770dba8e 100644
--- a/dotnet/src/build/GitHub.Copilot.SDK.targets
+++ b/dotnet/src/build/GitHub.Copilot.SDK.targets
@@ -38,6 +38,8 @@
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'osx-arm64'">darwin-arm64
<_CopilotBinary Condition="$(_CopilotRid.StartsWith('win-'))">copilot.exe
<_CopilotBinary Condition="'$(_CopilotBinary)' == ''">copilot
+ <_CopilotRuntimeWrapper Condition="$(_CopilotRid.StartsWith('win-'))">copilot-runtime.exe
+ <_CopilotRuntimeWrapper Condition="'$(_CopilotRuntimeWrapper)' == ''">copilot-runtime
<_CopilotRuntimeNodePath>$(_CopilotCacheDir)\prebuilds\$(_CopilotPlatform)\runtime.node
+ <_CopilotRuntimeWrapperPath Condition="'$(_CopilotRuntimeWrapperPath)' == ''">$(_CopilotCacheDir)\prebuilds\$(_CopilotPlatform)\$(_CopilotRuntimeWrapper)
+ <_CopilotRuntimeAssetManifest>$(_CopilotOutputDir)\.copilot-runtime-assets
+ <_CopilotExplicitCliMarker>$(_CopilotOutputDir)\.copilot-explicit-cli
+
+
+
+
+
+ <_CopilotSafePreviousRuntimeAsset Include="@(_CopilotPreviousRuntimeAsset)"
+ Condition="!$([System.IO.Path]::IsPathRooted('%(Identity)')) And !$([System.String]::Copy('%(Identity)').Contains('..'))" />
+
+
+
+
+ <_CopilotRuntimeRootAsset Include="$(_CopilotCacheDir)\**\*"
+ Exclude="$(_CopilotCacheDir)\app.js;$(_CopilotCacheDir)\assets\**\*;$(_CopilotCacheDir)\changelog.json;$(_CopilotCacheDir)\copilot;$(_CopilotCacheDir)\copilot.exe;$(_CopilotCacheDir)\copilot-sdk\**\*;$(_CopilotCacheDir)\copilot.tgz;$(_CopilotCacheDir)\foundry-local-sdk\**\*;$(_CopilotCacheDir)\index.js;$(_CopilotCacheDir)\LICENSE.md;$(_CopilotCacheDir)\napi-oop-runtime\**\*;$(_CopilotCacheDir)\npm-loader.js;$(_CopilotCacheDir)\package.json;$(_CopilotCacheDir)\prebuilds\**\*;$(_CopilotCacheDir)\preloads\**\*;$(_CopilotCacheDir)\pvrecorder\**\*;$(_CopilotCacheDir)\queries\**\*;$(_CopilotCacheDir)\README.md;$(_CopilotCacheDir)\sdk\**\*;$(_CopilotCacheDir)\sea-loader.js;$(_CopilotCacheDir)\tree-sitter*.wasm;$(_CopilotCacheDir)\voice-*.js;$(_CopilotCacheDir)\webview\**\*" />
+ <_CopilotRuntimePrebuildAsset Include="$(_CopilotCacheDir)\prebuilds\$(_CopilotPlatform)\**\*"
+ Exclude="$(_CopilotCacheDir)\prebuilds\$(_CopilotPlatform)\cli-native.node;$(_CopilotCacheDir)\prebuilds\$(_CopilotPlatform)\mediaremote-adapter\**\*;$(_CopilotCacheDir)\prebuilds\$(_CopilotPlatform)\copilot-runtime-bin*" />
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/test/E2E/BuiltinToolsE2ETests.cs b/dotnet/test/E2E/BuiltinToolsE2ETests.cs
index 37067ecc60..143487dac4 100644
--- a/dotnet/test/E2E/BuiltinToolsE2ETests.cs
+++ b/dotnet/test/E2E/BuiltinToolsE2ETests.cs
@@ -112,21 +112,9 @@ public async Task Should_Create_A_New_File()
Assert.Contains("Created by test", msg?.Data.Content ?? string.Empty);
}
- // TODO(cli-1.0.81-2): the grep and glob built-in tools shell out to the CLI's
- // bundled ripgrep, which the runtime cannot locate when it is loaded in-process
- // over FFI ("Failed to execute ripgrep: No such file or directory"). The tool
- // then returns an error the recorded snapshots do not cover. Re-enable once the
- // in-process runtime resolves its bundled binaries.
- private static bool RipgrepUnavailable => E2ETestContext.UsesInProcessTransport;
-
[Fact]
public async Task Should_Search_For_Patterns_In_Files()
{
- if (RipgrepUnavailable)
- {
- return;
- }
-
await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "data.txt"), "apple\nbanana\napricot\ncherry\n");
var session = await CreateSessionAsync();
var msg = await session.SendAndWaitAsync(new MessageOptions
@@ -141,11 +129,6 @@ public async Task Should_Search_For_Patterns_In_Files()
[Fact]
public async Task Should_Find_Files_By_Pattern()
{
- if (RipgrepUnavailable)
- {
- return;
- }
-
Directory.CreateDirectory(Path.Join(Ctx.WorkDir, "src"));
await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "src", "index.ts"), "export const index = 1;");
await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "README.md"), "# Readme");
diff --git a/dotnet/test/E2E/ClientE2ETests.cs b/dotnet/test/E2E/ClientE2ETests.cs
index b6bdfd90fd..b8427dde61 100644
--- a/dotnet/test/E2E/ClientE2ETests.cs
+++ b/dotnet/test/E2E/ClientE2ETests.cs
@@ -41,9 +41,8 @@ public async Task Should_Start_And_Connect_To_Server(bool useStdio)
[Fact]
public async Task Should_Start_And_Connect_Over_InProcess_Ffi()
{
- // In-process FFI hosting resolves the CLI entrypoint (COPILOT_CLI_PATH or the
- // bundled CLI binary) and its sibling native runtime library itself; if neither
- // is available, StartAsync throws and the test fails hard.
+ // In-process FFI hosting loads the bundled runtime library directly; if it is
+ // unavailable, StartAsync throws and the test fails hard.
using var client = new CopilotClient(new CopilotClientOptions
{
Connection = RuntimeConnection.ForInProcess(),
diff --git a/dotnet/test/Unit/MSBuildTargetsTests.cs b/dotnet/test/Unit/MSBuildTargetsTests.cs
index a7d9cc0256..cab91e568f 100644
--- a/dotnet/test/Unit/MSBuildTargetsTests.cs
+++ b/dotnet/test/Unit/MSBuildTargetsTests.cs
@@ -48,6 +48,7 @@ public async Task PreinstalledCliBinaryPath_IsHonored_DownloadSkipped_AndCopiedT
var outputPath = sandbox.ExpectedOutputBinary();
Assert.True(File.Exists(outputPath), $"Expected CLI to be copied to '{outputPath}'.\n{result.FailureMessage()}");
Assert.Equal(File.ReadAllText(preinstalled), File.ReadAllText(outputPath));
+ Assert.True(File.Exists(Path.Combine(Path.GetDirectoryName(outputPath)!, ".copilot-explicit-cli")));
}
[Fact]
@@ -105,6 +106,35 @@ public async Task PreinstalledCliBinaryPath_WithSkipCliDownload_StillCopiesToOut
Assert.True(File.Exists(sandbox.ExpectedOutputBinary()), result.FailureMessage());
}
+ [Fact]
+ public async Task RuntimePackageAssets_AreFilteredAndCopiedToOutput()
+ {
+ using var sandbox = MSBuildSandbox.Create();
+ var preinstalled = sandbox.WritePreinstalledBinary("fake-cli-contents");
+ sandbox.WriteRuntimeCacheAsset("prebuilds", GetNpmPlatform(), "runtime.node", "runtime");
+ sandbox.WriteRuntimeCacheAsset("prebuilds", GetNpmPlatform(),
+ OperatingSystem.IsWindows() ? "copilot-runtime.exe" : "copilot-runtime", "wrapper");
+ sandbox.WriteRuntimeCacheAsset("ripgrep", "bin", GetNpmPlatform(), "rg", "ripgrep");
+ sandbox.WriteRuntimeCacheAsset("definitions", "future.json", "{}");
+ sandbox.WriteRuntimeCacheAsset("app.js", "excluded");
+ sandbox.WriteRuntimeCacheAsset("LICENSE.md", "excluded");
+ sandbox.WriteRuntimeCacheAsset("README.md", "excluded");
+ sandbox.WriteStaleOutputRuntimeAsset("obsolete", "tool", "stale");
+
+ var result = await sandbox.BuildAsync(new Dictionary
+ {
+ ["CopilotCliBinaryPath"] = preinstalled,
+ });
+
+ Assert.True(result.Succeeded, result.FailureMessage());
+ Assert.Equal("ripgrep", File.ReadAllText(sandbox.ExpectedRuntimeAsset("ripgrep", "bin", GetNpmPlatform(), "rg")));
+ Assert.Equal("{}", File.ReadAllText(sandbox.ExpectedRuntimeAsset("definitions", "future.json")));
+ Assert.False(File.Exists(sandbox.ExpectedRuntimeAsset("app.js")));
+ Assert.False(File.Exists(sandbox.ExpectedRuntimeAsset("LICENSE.md")));
+ Assert.False(File.Exists(sandbox.ExpectedRuntimeAsset("README.md")));
+ Assert.False(File.Exists(sandbox.ExpectedRuntimeAsset("obsolete", "tool")));
+ }
+
[Fact]
public async Task PreinstalledCliBinaryPath_NonExistentFile_FailsWithActionableError()
{
@@ -150,6 +180,17 @@ private static string FindTargetsFile([CallerFilePath] string? thisFile = null)
"Could not locate GitHub.Copilot.SDK.targets relative to test assembly or source file.");
}
+ private static string GetNpmPlatform()
+ {
+ var arch = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture
+ == System.Runtime.InteropServices.Architecture.Arm64
+ ? "arm64"
+ : "x64";
+ if (OperatingSystem.IsWindows()) return $"win32-{arch}";
+ if (OperatingSystem.IsMacOS()) return $"darwin-{arch}";
+ return $"linux-{arch}";
+ }
+
///
/// A throwaway directory containing a minimal csproj that imports the SDK targets
/// file. Disposing removes the directory tree.
@@ -203,6 +244,42 @@ public string ExpectedOutputBinary()
return Path.Combine(ProjectDir, "bin", "Debug", "net8.0", "runtimes", rid, "native", BinaryName);
}
+ public void WriteRuntimeCacheAsset(params string[] pathAndContents)
+ {
+ var pathParts = pathAndContents.Take(pathAndContents.Length - 1).ToArray();
+ var path = Path.Combine(ProjectDir, "obj", "Debug", "net8.0", "copilot-cli", "0.0.0-test",
+ GetNpmPlatform());
+ foreach (var part in pathParts)
+ {
+ path = Path.Combine(path, part);
+ }
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, pathAndContents[^1]);
+ }
+
+ public string ExpectedRuntimeAsset(params string[] pathParts)
+ {
+ var path = Path.Combine(ProjectDir, "bin", "Debug", "net8.0", "runtimes", GetPortableRid(), "native");
+ foreach (var part in pathParts)
+ {
+ path = Path.Combine(path, part);
+ }
+ return path;
+ }
+
+ public void WriteStaleOutputRuntimeAsset(params string[] pathAndContents)
+ {
+ var relativeParts = pathAndContents.Take(pathAndContents.Length - 1).ToArray();
+ var path = ExpectedRuntimeAsset(relativeParts);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, pathAndContents[^1]);
+ var manifest = ExpectedRuntimeAsset(".copilot-runtime-assets");
+ Directory.CreateDirectory(Path.GetDirectoryName(manifest)!);
+ File.WriteAllText(
+ manifest,
+ string.Join(Path.DirectorySeparatorChar.ToString(), relativeParts) + Environment.NewLine);
+ }
+
public async Task BuildAsync(IDictionary properties)
{
var args = new StringBuilder("build --nologo -clp:NoSummary");
diff --git a/dotnet/test/Unit/RuntimeWrapperTests.cs b/dotnet/test/Unit/RuntimeWrapperTests.cs
new file mode 100644
index 0000000000..9a9dacb7d3
--- /dev/null
+++ b/dotnet/test/Unit/RuntimeWrapperTests.cs
@@ -0,0 +1,134 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------------------------------------------*/
+
+using GitHub.Copilot.Rpc;
+using Xunit;
+
+namespace GitHub.Copilot.Test.Unit;
+
+[CollectionDefinition(Name, DisableParallelization = true)]
+public sealed class RuntimeWrapperIsolationCollection
+{
+ public const string Name = "Runtime wrapper isolation";
+}
+
+[Collection(RuntimeWrapperIsolationCollection.Name)]
+public sealed class RuntimeWrapperTests
+{
+#if !NETFRAMEWORK
+ [Fact]
+ public async Task Managed_Launch_Fails_When_Bundled_Runtime_Pair_Is_Missing()
+ {
+ var originalBaseDirectory = AppContext.GetData("APP_CONTEXT_BASE_DIRECTORY");
+ var emptyBaseDirectory = Path.Combine(
+ Path.GetTempPath(),
+ $"missing-copilot-runtime-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(emptyBaseDirectory);
+
+ try
+ {
+ AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", emptyBaseDirectory);
+ await using var client = new CopilotClient(new CopilotClientOptions
+ {
+ Connection = RuntimeConnection.ForStdio(),
+ Environment = new Dictionary(),
+ });
+
+ var exception = await Assert.ThrowsAsync(() => client.StartAsync());
+
+ Assert.Contains("runtime wrapper not found", exception.Message, StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", originalBaseDirectory);
+ Directory.Delete(emptyBaseDirectory);
+ }
+ }
+#endif
+
+ [Fact]
+ public async Task Explicit_Path_Does_Not_Require_Adjacent_Runtime_Node()
+ {
+ var explicitPath = Path.Combine(
+ Path.GetTempPath(),
+ $"missing-explicit-copilot-{Guid.NewGuid():N}");
+ await using var client = new CopilotClient(new CopilotClientOptions
+ {
+ Connection = RuntimeConnection.ForStdio(path: explicitPath),
+ Environment = new Dictionary(),
+ });
+
+ var exception = await Assert.ThrowsAnyAsync(() => client.StartAsync());
+
+ Assert.DoesNotContain("runtime.node", exception.ToString(), StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Copilot_Cli_Path_Does_Not_Require_Adjacent_Runtime_Node()
+ {
+ var explicitPath = Path.Combine(
+ Path.GetTempPath(),
+ $"missing-environment-copilot-{Guid.NewGuid():N}");
+ await using var client = new CopilotClient(new CopilotClientOptions
+ {
+ Connection = RuntimeConnection.ForStdio(),
+ Environment = new Dictionary { ["COPILOT_CLI_PATH"] = explicitPath },
+ });
+
+ var exception = await Assert.ThrowsAnyAsync(() => client.StartAsync());
+
+ Assert.DoesNotContain("runtime.node", exception.ToString(), StringComparison.OrdinalIgnoreCase);
+ }
+
+#if !NETFRAMEWORK
+ [Fact]
+ public async Task Marked_Bundled_Explicit_Cli_Does_Not_Require_Runtime_Pair()
+ {
+ var originalBaseDirectory = AppContext.GetData("APP_CONTEXT_BASE_DIRECTORY");
+ var baseDirectory = Path.Combine(
+ Path.GetTempPath(),
+ $"explicit-bundled-copilot-{Guid.NewGuid():N}");
+ var rid = GetPortableRid();
+ var nativeDirectory = Path.Combine(baseDirectory, "runtimes", rid, "native");
+ Directory.CreateDirectory(nativeDirectory);
+ var cliPath = Path.Combine(nativeDirectory, OperatingSystem.IsWindows() ? "copilot.exe" : "copilot");
+ await File.WriteAllTextAsync(cliPath, "not an executable");
+ await File.WriteAllTextAsync(Path.Combine(nativeDirectory, ".copilot-explicit-cli"), "explicit");
+
+ try
+ {
+ AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", baseDirectory);
+ await using var client = new CopilotClient(new CopilotClientOptions
+ {
+ Connection = RuntimeConnection.ForStdio(),
+ Environment = new Dictionary(),
+ });
+
+ var exception = await Assert.ThrowsAnyAsync(() => client.StartAsync());
+
+ Assert.DoesNotContain("runtime wrapper", exception.ToString(), StringComparison.OrdinalIgnoreCase);
+ Assert.Contains(cliPath, exception.ToString(), StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", originalBaseDirectory);
+ Directory.Delete(baseDirectory, recursive: true);
+ }
+ }
+#endif
+
+ private static string GetPortableRid()
+ {
+ var os = OperatingSystem.IsWindows() ? "win"
+ : OperatingSystem.IsMacOS() ? "osx"
+ : "linux";
+ var architecture = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
+ {
+ System.Runtime.InteropServices.Architecture.X64 => "x64",
+ System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
+ _ => throw new PlatformNotSupportedException(),
+ };
+ return $"{os}-{architecture}";
+ }
+}
diff --git a/go/README.md b/go/README.md
index ddd74b91aa..4d2a8424d6 100644
--- a/go/README.md
+++ b/go/README.md
@@ -104,7 +104,7 @@ Follow these steps to embed the CLI:
1. Run `go get -tool github.com/github/copilot-sdk/go/cmd/bundler`. This is a one-time setup step per project.
2. Run `go tool bundler` in your build environment just before building your application.
-That's it! When your application calls `copilot.NewClient` without a `Connection` field (or with an empty `StdioConnection{}`) and no `COPILOT_CLI_PATH` environment variable, the SDK will automatically install the embedded CLI to a cache directory and use it for all operations.
+That's it! When your application calls `copilot.NewClient` without a `Connection` field (or with an empty `StdioConnection{}`), the SDK automatically installs the embedded `copilot-runtime` executable and adjacent `runtime.node` to a cache directory for managed child-process connections.
The bundler prepares the native runtime library required by the [in-process transport](#in-process-transport-experimental). It is included in the application only when building with the `copilot_inprocess` build tag.
@@ -138,6 +138,9 @@ Resolution and requirements:
always takes precedence.
- Set `COPILOT_CLI_PATH` only when using an externally provisioned compatible runtime package; otherwise the bundled runtime is used. No `PATH` lookup is performed.
- Embedded runtime versions are isolated in separate cache directories. Start fails loudly if the native runtime is unavailable.
+- Managed child-process start fails if the embedded `copilot-runtime` and
+ `runtime.node` pair is unavailable; explicit paths and `COPILOT_CLI_PATH`
+ remain direct overrides.
- Linux in-process bundles include both glibc and musl runtime packages and select the matching package automatically at startup.
- Only one native runtime version may be loaded per process.
@@ -195,7 +198,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec
- `URIConnection{URL, ConnectionToken}` — connect to an already-running runtime (no process spawned)
- `InProcessConnection{}` — **Experimental.** Host the runtime in-process via the native FFI library instead of spawning a child process. See [In-process transport](#in-process-transport-experimental) below.
- When `Path` is empty for stdio/tcp, the SDK uses the bundled CLI (or `COPILOT_CLI_PATH` env var).
+ When `Path` is empty for stdio/tcp, the SDK uses `COPILOT_CLI_PATH` when set, then the bundled `copilot-runtime` and adjacent `runtime.node`.
`StdioConnection` and `TCPConnection` accept an optional connection-level `Env`. Set environment variables via **either** the client-level `Env` option or the connection's `Env`, not both (setting both panics); prefer the connection-level `Env`.
- `WorkingDirectory` (string): Working directory for the runtime process (default: current process working directory)
diff --git a/go/client.go b/go/client.go
index 4e44696a55..8058106507 100644
--- a/go/client.go
+++ b/go/client.go
@@ -341,6 +341,18 @@ func NewClient(options *ClientOptions) *Client {
return client
}
+func resolveRuntimeExecutable(explicitPath, bundledRuntimePath string) (string, error) {
+ if explicitPath != "" {
+ return explicitPath, nil
+ }
+ if bundledRuntimePath == "" {
+ return "", errors.New(
+ "managed Copilot runtime unavailable: the embedded bundle does not contain copilot-runtime and adjacent runtime.node; regenerate the bundle, provide an explicit path, or set COPILOT_CLI_PATH",
+ )
+ }
+ return bundledRuntimePath, nil
+}
+
const defaultConnectionEnvVar = "COPILOT_SDK_DEFAULT_CONNECTION"
// resolveDefaultConnection selects the transport when no explicit connection
@@ -1972,14 +1984,13 @@ func (c *Client) startCLIServer(ctx context.Context) error {
return c.startInProcess(ctx)
}
- cliPath := c.cliPath
- if cliPath == "" {
- // If no CLI path is provided, attempt to use the embedded CLI if available
- cliPath = embeddedcli.Path()
+ bundledRuntimePath := ""
+ if c.cliPath == "" {
+ bundledRuntimePath = embeddedcli.RuntimePath()
}
- if cliPath == "" {
- // Default to "copilot" in PATH if no embedded CLI is available and no custom path is set
- cliPath = "copilot"
+ cliPath, err := resolveRuntimeExecutable(c.cliPath, bundledRuntimePath)
+ if err != nil {
+ return err
}
// Start with user-provided CLIArgs, then add SDK-managed args
@@ -2180,24 +2191,21 @@ func (c *Client) startInProcess(ctx context.Context) error {
return errors.New("in-process transport unavailable: rebuild with -tags copilot_inprocess on a supported platform")
}
- runtimePath := c.cliPath
- if runtimePath == "" {
- // The in-process transport does not resolve a bare command name from PATH
- // (unlike the child-process transport).
- if p := getEnvValue(c.options.Env, "COPILOT_CLI_PATH"); p != "" {
- runtimePath = p
- }
+ cliEntrypoint := c.cliPath
+ if cliEntrypoint == "" {
+ cliEntrypoint = getEnvValue(c.options.Env, "COPILOT_CLI_PATH")
}
+ runtimePath := cliEntrypoint
if runtimePath == "" {
- runtimePath = embeddedcli.Path()
+ runtimePath = embeddedcli.RuntimePath()
}
if runtimePath == "" {
- return errors.New("in-process runtime unavailable: set COPILOT_CLI_PATH to a compatible runtime package or build with the bundled embedded runtime")
+ return errors.New("in-process runtime unavailable: build with the bundled embedded runtime or set COPILOT_CLI_PATH to a compatible runtime package")
}
config := c.inProcessHostConfig()
- host, err := createInProcessHost(runtimePath, config)
+ host, err := createInProcessHost(runtimePath, cliEntrypoint, config)
if err != nil {
return err
}
diff --git a/go/client_test.go b/go/client_test.go
index c6ab0808cb..959c3dbe29 100644
--- a/go/client_test.go
+++ b/go/client_test.go
@@ -24,6 +24,35 @@ import (
// This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.go instead
+func TestResolveRuntimeExecutable(t *testing.T) {
+ t.Run("managed launch requires bundled runtime pair", func(t *testing.T) {
+ _, err := resolveRuntimeExecutable("", "")
+ if err == nil || !strings.Contains(err.Error(), "copilot-runtime and adjacent runtime.node") {
+ t.Fatalf("expected missing managed runtime error, got %v", err)
+ }
+ })
+
+ t.Run("explicit path does not require bundled runtime pair", func(t *testing.T) {
+ path, err := resolveRuntimeExecutable("/explicit/copilot", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != "/explicit/copilot" {
+ t.Fatalf("resolveRuntimeExecutable() = %q", path)
+ }
+ })
+
+ t.Run("managed launch selects bundled wrapper", func(t *testing.T) {
+ path, err := resolveRuntimeExecutable("", "/bundle/copilot-runtime")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != "/bundle/copilot-runtime" {
+ t.Fatalf("resolveRuntimeExecutable() = %q", path)
+ }
+ })
+}
+
func TestClient_URLParsing(t *testing.T) {
t.Run("should parse port-only URL format", func(t *testing.T) {
client := NewClient(&ClientOptions{
diff --git a/go/cmd/bundler/main.go b/go/cmd/bundler/main.go
index e63d1fde66..fb11ff8699 100644
--- a/go/cmd/bundler/main.go
+++ b/go/cmd/bundler/main.go
@@ -91,21 +91,20 @@ func main() {
fmt.Printf("Building bundle for %s (CLI version %s)\n", *platform, version)
- binaryPath, sha256Hash, runtimeArtifactPath, runtimeHash, err := buildBundle(info, version, outputPath, goos)
+ bundle, err := buildBundle(info, version, outputPath, goos)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
- var muslBinaryPath, muslRuntimeArtifactPath string
- var muslBinaryHash, muslRuntimeHash []byte
+ var muslBundle bundleArtifacts
if goos == "linux" {
muslInfo := platformInfo{
npmPlatform: strings.Replace(info.npmPlatform, "linux-", "linuxmusl-", 1),
binaryName: info.binaryName,
}
muslOutputPath := filepath.Join(*output, defaultOutputFileName(version, "linuxmusl", goarch, info.binaryName))
- muslBinaryPath, muslBinaryHash, muslRuntimeArtifactPath, muslRuntimeHash, err = buildBundle(
+ muslBundle, err = buildBundle(
muslInfo,
version,
muslOutputPath,
@@ -121,15 +120,23 @@ func main() {
if err := generateGoFile(
goos,
goarch,
- binaryPath,
+ bundle.binaryPath,
version,
- sha256Hash,
- runtimeArtifactPath,
- runtimeHash,
- muslBinaryPath,
- muslBinaryHash,
- muslRuntimeArtifactPath,
- muslRuntimeHash,
+ bundle.binaryHash,
+ bundle.runtimeArtifactPath,
+ bundle.runtimeHash,
+ bundle.wrapperArtifactPath,
+ bundle.wrapperHash,
+ bundle.assetsArtifactPath,
+ bundle.assetsHash,
+ muslBundle.binaryPath,
+ muslBundle.binaryHash,
+ muslBundle.runtimeArtifactPath,
+ muslBundle.runtimeHash,
+ muslBundle.wrapperArtifactPath,
+ muslBundle.wrapperHash,
+ muslBundle.assetsArtifactPath,
+ muslBundle.assetsHash,
"main",
); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
@@ -286,96 +293,137 @@ func isHex(s string) bool {
return true
}
-// buildBundle downloads the CLI binary (and, when the CLI package ships it, the
-// native in-process runtime library) and writes them to outputPath's directory.
-// It returns the CLI bundle path and hash, plus the runtime-library artifact path
-// and hash (both empty when the package does not ship the runtime library).
-func buildBundle(info platformInfo, cliVersion, outputPath, goos string) (string, []byte, string, []byte, error) {
+type bundleArtifacts struct {
+ binaryPath string
+ binaryHash []byte
+ runtimeArtifactPath string
+ runtimeHash []byte
+ wrapperArtifactPath string
+ wrapperHash []byte
+ assetsArtifactPath string
+ assetsHash []byte
+}
+
+// buildBundle downloads the CLI and native runtime artifacts from one platform package.
+func buildBundle(info platformInfo, cliVersion, outputPath, goos string) (bundleArtifacts, error) {
outputDir := filepath.Dir(outputPath)
if outputDir == "" {
outputDir = "."
}
runtimeArtifactPath := filepath.Join(outputDir, runtimeLibArtifactName(cliVersion, info.npmPlatform, goos))
+ wrapperArtifactPath := filepath.Join(outputDir, runtimeWrapperArtifactName(cliVersion, info.npmPlatform, info.binaryName))
+ assetsArtifactPath := filepath.Join(outputDir, runtimeAssetsArtifactName(cliVersion, info.npmPlatform))
- // Check if output already exists
- if _, err := os.Stat(outputPath); err == nil {
+ if filesExist(outputPath, runtimeArtifactPath, wrapperArtifactPath, assetsArtifactPath) {
// Idempotent output avoids re-downloading in CI or local rebuilds.
- fmt.Printf("Output %s already exists, skipping download\n", outputPath)
- sha256Hash, err := sha256FileFromCompressed(outputPath)
+ fmt.Printf("Output runtime bundle for %s already exists, skipping download\n", info.npmPlatform)
+ binaryHash, err := sha256FileFromCompressed(outputPath)
if err != nil {
- return "", nil, "", nil, fmt.Errorf("failed to hash existing output: %w", err)
+ return bundleArtifacts{}, fmt.Errorf("failed to hash existing output: %w", err)
}
if err := downloadCLILicense(cliVersion, outputPath); err != nil {
- return "", nil, "", nil, fmt.Errorf("failed to download CLI license: %w", err)
+ return bundleArtifacts{}, fmt.Errorf("failed to download CLI license: %w", err)
}
- // Reuse an existing runtime-library artifact if present.
- if _, err := os.Stat(runtimeArtifactPath); err == nil {
- runtimeHash, err := sha256FileFromCompressed(runtimeArtifactPath)
- if err != nil {
- return "", nil, "", nil, fmt.Errorf("failed to hash existing runtime library: %w", err)
- }
- return outputPath, sha256Hash, runtimeArtifactPath, runtimeHash, nil
+ runtimeHash, err := sha256FileFromCompressed(runtimeArtifactPath)
+ if err != nil {
+ return bundleArtifacts{}, fmt.Errorf("failed to hash existing runtime.node: %w", err)
}
- return outputPath, sha256Hash, "", nil, nil
+ wrapperHash, err := sha256FileFromCompressed(wrapperArtifactPath)
+ if err != nil {
+ return bundleArtifacts{}, fmt.Errorf("failed to hash existing runtime wrapper: %w", err)
+ }
+ assetsHash, err := sha256File(assetsArtifactPath)
+ if err != nil {
+ return bundleArtifacts{}, fmt.Errorf("failed to hash existing runtime assets: %w", err)
+ }
+ return bundleArtifacts{outputPath, binaryHash, runtimeArtifactPath, runtimeHash, wrapperArtifactPath, wrapperHash, assetsArtifactPath, assetsHash}, nil
}
+
// Create temp directory for download
tempDir, err := os.MkdirTemp("", "copilot-bundler-*")
if err != nil {
- return "", nil, "", nil, fmt.Errorf("failed to create temp dir: %w", err)
+ return bundleArtifacts{}, fmt.Errorf("failed to create temp dir: %w", err)
}
defer os.RemoveAll(tempDir)
- // Download the binary
binaryPath, tarballPath, err := downloadCLIBinary(info.npmPlatform, info.binaryName, cliVersion, tempDir)
if err != nil {
- return "", nil, "", nil, fmt.Errorf("failed to download CLI binary: %w", err)
+ return bundleArtifacts{}, fmt.Errorf("failed to download CLI binary: %w", err)
}
- // Create output directory if needed
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
- return "", nil, "", nil, fmt.Errorf("failed to create output directory: %w", err)
+ return bundleArtifacts{}, fmt.Errorf("failed to create output directory: %w", err)
}
}
- sha256Hash, err := sha256File(binaryPath)
+ binaryHash, err := sha256File(binaryPath)
if err != nil {
- return "", nil, "", nil, fmt.Errorf("failed to hash output binary: %w", err)
+ return bundleArtifacts{}, fmt.Errorf("failed to hash output binary: %w", err)
}
if err := compressZstdFile(binaryPath, outputPath); err != nil {
- return "", nil, "", nil, fmt.Errorf("failed to write output binary: %w", err)
+ return bundleArtifacts{}, fmt.Errorf("failed to write output binary: %w", err)
}
if err := downloadCLILicense(cliVersion, outputPath); err != nil {
- return "", nil, "", nil, fmt.Errorf("failed to download CLI license: %w", err)
+ return bundleArtifacts{}, fmt.Errorf("failed to download CLI license: %w", err)
}
- // Extract the native in-process runtime library from the same tarball, if the
- // package ships it (older CLI versions do not). Missing is not an error — the
- // generated file simply omits the runtime embed for that platform.
rawLibPath := filepath.Join(tempDir, "runtime.node")
- found, err := extractOptionalFileFromTarball(tarballPath, tempDir,
- "package/prebuilds/"+info.npmPlatform+"/runtime.node", "runtime.node")
+ if err := extractFileFromTarball(
+ tarballPath,
+ tempDir,
+ "package/prebuilds/"+info.npmPlatform+"/runtime.node",
+ "runtime.node",
+ ); err != nil {
+ return bundleArtifacts{}, fmt.Errorf("runtime package is missing prebuilds/%s/runtime.node: %w", info.npmPlatform, err)
+ }
+ runtimeHash, err := sha256File(rawLibPath)
if err != nil {
- return "", nil, "", nil, fmt.Errorf("failed to extract runtime library: %w", err)
+ return bundleArtifacts{}, fmt.Errorf("failed to hash runtime.node: %w", err)
}
- var runtimeHash []byte
- returnedRuntimeArtifact := ""
- if found {
- runtimeHash, err = sha256File(rawLibPath)
- if err != nil {
- return "", nil, "", nil, fmt.Errorf("failed to hash runtime library: %w", err)
- }
- if err := compressZstdFile(rawLibPath, runtimeArtifactPath); err != nil {
- return "", nil, "", nil, fmt.Errorf("failed to write runtime library: %w", err)
- }
- returnedRuntimeArtifact = runtimeArtifactPath
- fmt.Printf("Successfully created %s\n", runtimeArtifactPath)
- } else {
- fmt.Printf("Package %s does not ship a runtime library; in-process transport unavailable for this platform bundle\n", info.npmPlatform)
+ if err := compressZstdFile(rawLibPath, runtimeArtifactPath); err != nil {
+ return bundleArtifacts{}, fmt.Errorf("failed to write runtime.node: %w", err)
+ }
+
+ wrapperName := runtimeWrapperName(info.binaryName)
+ rawWrapperPath := filepath.Join(tempDir, wrapperName)
+ if err := extractFileFromTarball(
+ tarballPath,
+ tempDir,
+ "package/prebuilds/"+info.npmPlatform+"/"+wrapperName,
+ wrapperName,
+ ); err != nil {
+ return bundleArtifacts{}, fmt.Errorf("runtime package is missing prebuilds/%s/%s: %w", info.npmPlatform, wrapperName, err)
+ }
+ wrapperHash, err := sha256File(rawWrapperPath)
+ if err != nil {
+ return bundleArtifacts{}, fmt.Errorf("failed to hash runtime wrapper: %w", err)
+ }
+ if err := compressZstdFile(rawWrapperPath, wrapperArtifactPath); err != nil {
+ return bundleArtifacts{}, fmt.Errorf("failed to write runtime wrapper: %w", err)
+ }
+ if err := createRuntimeAssetsArchive(tarballPath, assetsArtifactPath, info); err != nil {
+ return bundleArtifacts{}, fmt.Errorf("failed to write runtime assets: %w", err)
+ }
+ assetsHash, err := sha256File(assetsArtifactPath)
+ if err != nil {
+ return bundleArtifacts{}, fmt.Errorf("failed to hash runtime assets: %w", err)
}
fmt.Printf("Successfully created %s\n", outputPath)
- return outputPath, sha256Hash, returnedRuntimeArtifact, runtimeHash, nil
+ fmt.Printf("Successfully created %s\n", runtimeArtifactPath)
+ fmt.Printf("Successfully created %s\n", wrapperArtifactPath)
+ fmt.Printf("Successfully created %s\n", assetsArtifactPath)
+ return bundleArtifacts{outputPath, binaryHash, runtimeArtifactPath, runtimeHash, wrapperArtifactPath, wrapperHash, assetsArtifactPath, assetsHash}, nil
+}
+
+func filesExist(paths ...string) bool {
+ for _, path := range paths {
+ if _, err := os.Stat(path); err != nil {
+ return false
+ }
+ }
+ return true
}
// runtimeLibArtifactName builds the compressed runtime-library artifact filename.
@@ -383,6 +431,117 @@ func runtimeLibArtifactName(version, npmPlatform, goos string) string {
return fmt.Sprintf("zcopilotruntime_%s_%s.%s.zst", version, npmPlatform, runtimeLibExt(goos))
}
+func runtimeWrapperArtifactName(version, npmPlatform, binaryName string) string {
+ return fmt.Sprintf("zcopilotruntimewrapper_%s_%s_%s.zst", version, npmPlatform, runtimeWrapperName(binaryName))
+}
+
+func runtimeAssetsArtifactName(version, npmPlatform string) string {
+ return fmt.Sprintf("zcopilotruntimeassets_%s_%s.tgz", version, npmPlatform)
+}
+
+func runtimeWrapperName(binaryName string) string {
+ if filepath.Ext(binaryName) == ".exe" {
+ return "copilot-runtime.exe"
+ }
+ return "copilot-runtime"
+}
+
+var hostlessExcludedTopLevel = map[string]bool{
+ "app.js": true, "assets": true, "changelog.json": true, "copilot": true, "copilot.exe": true,
+ "copilot-sdk": true, "foundry-local-sdk": true, "index.js": true, "napi-oop-runtime": true,
+ "LICENSE.md": true, "npm-loader.js": true, "package.json": true, "preloads": true, "pvrecorder": true,
+ "queries": true, "README.md": true, "sdk": true, "sea-loader.js": true, "webview": true,
+}
+
+func hostlessRuntimePath(name, npmPlatform, wrapperName string) (string, bool) {
+ relative, ok := strings.CutPrefix(name, "package/")
+ if !ok {
+ return "", false
+ }
+ parts := strings.Split(relative, "/")
+ topLevel := parts[0]
+ fileName := parts[len(parts)-1]
+ if hostlessExcludedTopLevel[topLevel] ||
+ (strings.HasPrefix(topLevel, "tree-sitter") && strings.HasSuffix(topLevel, ".wasm")) ||
+ (strings.HasPrefix(topLevel, "voice-") && strings.HasSuffix(topLevel, ".js")) ||
+ fileName == "cli-native.node" || fileName == "runtime.node" || fileName == wrapperName ||
+ strings.HasPrefix(fileName, "copilot-runtime-bin") {
+ return "", false
+ }
+ for _, part := range parts {
+ if part == "mediaremote-adapter" {
+ return "", false
+ }
+ }
+ if topLevel == "prebuilds" {
+ if len(parts) < 3 || parts[1] != npmPlatform {
+ return "", false
+ }
+ return strings.Join(parts[2:], "/"), true
+ }
+ return relative, true
+}
+
+func createRuntimeAssetsArchive(tarballPath, outputPath string, info platformInfo) error {
+ sourceFile, err := os.Open(tarballPath)
+ if err != nil {
+ return err
+ }
+ defer sourceFile.Close()
+ gzipReader, err := gzip.NewReader(sourceFile)
+ if err != nil {
+ return err
+ }
+ defer gzipReader.Close()
+ outputFile, err := os.Create(outputPath)
+ if err != nil {
+ return err
+ }
+ defer outputFile.Close()
+ gzipWriter := gzip.NewWriter(outputFile)
+ tarWriter := tar.NewWriter(gzipWriter)
+ count := 0
+ sourceTar := tar.NewReader(gzipReader)
+ for {
+ header, err := sourceTar.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return err
+ }
+ if header.Typeflag != tar.TypeReg {
+ continue
+ }
+ destination, include := hostlessRuntimePath(
+ header.Name,
+ info.npmPlatform,
+ runtimeWrapperName(info.binaryName),
+ )
+ if !include {
+ continue
+ }
+ outputHeader := &tar.Header{
+ Name: destination, Mode: header.Mode, Size: header.Size, Typeflag: tar.TypeReg,
+ Uid: 0, Gid: 0,
+ }
+ if err := tarWriter.WriteHeader(outputHeader); err != nil {
+ return err
+ }
+ if _, err := io.Copy(tarWriter, sourceTar); err != nil {
+ return err
+ }
+ count++
+ }
+ if count == 0 {
+ return fmt.Errorf("runtime package contains no retained assets")
+ }
+ if err := tarWriter.Close(); err != nil {
+ return err
+ }
+ return gzipWriter.Close()
+}
+
// runtimeLibExt returns the shared-library extension for the target OS.
func runtimeLibExt(goos string) string {
switch goos {
@@ -406,10 +565,18 @@ func generateGoFile(
sha256Hash []byte,
runtimeArtifactPath string,
runtimeHash []byte,
+ wrapperArtifactPath string,
+ wrapperHash []byte,
+ assetsArtifactPath string,
+ assetsHash []byte,
muslBinaryPath string,
muslBinaryHash []byte,
muslRuntimeArtifactPath string,
muslRuntimeHash []byte,
+ muslWrapperArtifactPath string,
+ muslWrapperHash []byte,
+ muslAssetsArtifactPath string,
+ muslAssetsHash []byte,
pkgName string,
) error {
binaryName := filepath.Base(binaryPath)
@@ -428,12 +595,20 @@ func generateGoFile(
licenseName,
cliVersion,
hashBase64,
- "",
- nil,
- "",
- nil,
- "",
- nil,
+ runtimeArtifactPath,
+ runtimeHash,
+ wrapperArtifactPath,
+ wrapperHash,
+ assetsArtifactPath,
+ assetsHash,
+ muslBinaryPath,
+ muslBinaryHash,
+ muslRuntimeArtifactPath,
+ muslRuntimeHash,
+ muslWrapperArtifactPath,
+ muslWrapperHash,
+ muslAssetsArtifactPath,
+ muslAssetsHash,
)
if err := os.WriteFile(defaultPath, []byte(defaultContent), 0644); err != nil {
return err
@@ -449,10 +624,18 @@ func generateGoFile(
hashBase64,
runtimeArtifactPath,
runtimeHash,
+ wrapperArtifactPath,
+ wrapperHash,
+ assetsArtifactPath,
+ assetsHash,
muslBinaryPath,
muslBinaryHash,
muslRuntimeArtifactPath,
muslRuntimeHash,
+ muslWrapperArtifactPath,
+ muslWrapperHash,
+ muslAssetsArtifactPath,
+ muslAssetsHash,
)
if err := os.WriteFile(inProcessPath, []byte(inProcessContent), 0644); err != nil {
return err
@@ -472,24 +655,48 @@ func generatedGoFileContent(
hashBase64,
runtimeArtifactPath string,
runtimeHash []byte,
+ wrapperArtifactPath string,
+ wrapperHash []byte,
+ assetsArtifactPath string,
+ assetsHash []byte,
muslBinaryPath string,
muslBinaryHash []byte,
muslRuntimeArtifactPath string,
muslRuntimeHash []byte,
+ muslWrapperArtifactPath string,
+ muslWrapperHash []byte,
+ muslAssetsArtifactPath string,
+ muslAssetsHash []byte,
) string {
runtimeEmbed := ""
runtimeConfig := ""
runtimeReader := ""
- if runtimeArtifactPath != "" {
+ if runtimeArtifactPath != "" && wrapperArtifactPath != "" && assetsArtifactPath != "" {
runtimeArtifactName := filepath.Base(runtimeArtifactPath)
runtimeHashBase64 := base64.StdEncoding.EncodeToString(runtimeHash)
+ wrapperArtifactName := filepath.Base(wrapperArtifactPath)
+ wrapperHashBase64 := base64.StdEncoding.EncodeToString(wrapperHash)
+ assetsArtifactName := filepath.Base(assetsArtifactPath)
+ assetsHashBase64 := base64.StdEncoding.EncodeToString(assetsHash)
runtimeEmbed = fmt.Sprintf(`
//go:embed %s
var localEmbeddedCopilotRuntimeLib []byte
-`, runtimeArtifactName)
+
+//go:embed %s
+var localEmbeddedCopilotRuntimeExecutable []byte
+
+//go:embed %s
+var localEmbeddedCopilotRuntimeAssets []byte
+`, runtimeArtifactName, wrapperArtifactName, assetsArtifactName)
runtimeConfig = fmt.Sprintf(`
- RuntimeLib: runtimeLibReader(),
- RuntimeLibHash: mustDecodeBase64(%q),`, runtimeHashBase64)
+ RuntimeLib: runtimeLibReader(),
+ RuntimeLibHash: mustDecodeBase64(%q),
+ RuntimeNode: runtimeLibReader(),
+ RuntimeNodeHash: mustDecodeBase64(%q),
+ RuntimeExecutable: runtimeExecutableReader(),
+ RuntimeExecutableHash: mustDecodeBase64(%q),
+ RuntimeAssets: bytes.NewReader(localEmbeddedCopilotRuntimeAssets),
+ RuntimeAssetsHash: mustDecodeBase64(%q),`, runtimeHashBase64, runtimeHashBase64, wrapperHashBase64, assetsHashBase64)
runtimeReader = `
func runtimeLibReader() io.Reader {
r, err := zstd.NewReader(bytes.NewReader(localEmbeddedCopilotRuntimeLib))
@@ -498,29 +705,53 @@ func runtimeLibReader() io.Reader {
}
return r
}
+
+func runtimeExecutableReader() io.Reader {
+ r, err := zstd.NewReader(bytes.NewReader(localEmbeddedCopilotRuntimeExecutable))
+ if err != nil {
+ panic("failed to create zstd reader: " + err.Error())
+ }
+ return r
+}
`
}
muslEmbed := ""
muslConfig := ""
muslReaders := ""
- if muslBinaryPath != "" && muslRuntimeArtifactPath != "" {
+ if muslBinaryPath != "" && muslRuntimeArtifactPath != "" && muslWrapperArtifactPath != "" && muslAssetsArtifactPath != "" {
muslBinaryName := filepath.Base(muslBinaryPath)
muslBinaryHashBase64 := base64.StdEncoding.EncodeToString(muslBinaryHash)
muslRuntimeName := filepath.Base(muslRuntimeArtifactPath)
muslRuntimeHashBase64 := base64.StdEncoding.EncodeToString(muslRuntimeHash)
+ muslWrapperName := filepath.Base(muslWrapperArtifactPath)
+ muslWrapperHashBase64 := base64.StdEncoding.EncodeToString(muslWrapperHash)
+ muslAssetsName := filepath.Base(muslAssetsArtifactPath)
+ muslAssetsHashBase64 := base64.StdEncoding.EncodeToString(muslAssetsHash)
muslEmbed = fmt.Sprintf(`
//go:embed %s
var localEmbeddedCopilotCLILinuxMusl []byte
//go:embed %s
var localEmbeddedCopilotRuntimeLibLinuxMusl []byte
-`, muslBinaryName, muslRuntimeName)
+
+//go:embed %s
+var localEmbeddedCopilotRuntimeExecutableLinuxMusl []byte
+
+//go:embed %s
+var localEmbeddedCopilotRuntimeAssetsLinuxMusl []byte
+`, muslBinaryName, muslRuntimeName, muslWrapperName, muslAssetsName)
muslConfig = fmt.Sprintf(`
- LinuxMuslCli: linuxMuslCLIReader(),
- LinuxMuslCliHash: mustDecodeBase64(%q),
- LinuxMuslRuntimeLib: linuxMuslRuntimeLibReader(),
- LinuxMuslRuntimeLibHash: mustDecodeBase64(%q),`, muslBinaryHashBase64, muslRuntimeHashBase64)
+ LinuxMuslCli: linuxMuslCLIReader(),
+ LinuxMuslCliHash: mustDecodeBase64(%q),
+ LinuxMuslRuntimeLib: linuxMuslRuntimeLibReader(),
+ LinuxMuslRuntimeLibHash: mustDecodeBase64(%q),
+ LinuxMuslRuntimeNode: linuxMuslRuntimeLibReader(),
+ LinuxMuslRuntimeNodeHash: mustDecodeBase64(%q),
+ LinuxMuslRuntimeExecutable: linuxMuslRuntimeExecutableReader(),
+ LinuxMuslRuntimeExecutableHash: mustDecodeBase64(%q),
+ LinuxMuslRuntimeAssets: bytes.NewReader(localEmbeddedCopilotRuntimeAssetsLinuxMusl),
+ LinuxMuslRuntimeAssetsHash: mustDecodeBase64(%q),`, muslBinaryHashBase64, muslRuntimeHashBase64, muslRuntimeHashBase64, muslWrapperHashBase64, muslAssetsHashBase64)
muslReaders = `
func linuxMuslCLIReader() io.Reader {
r, err := zstd.NewReader(bytes.NewReader(localEmbeddedCopilotCLILinuxMusl))
@@ -537,6 +768,14 @@ func linuxMuslRuntimeLibReader() io.Reader {
}
return r
}
+
+func linuxMuslRuntimeExecutableReader() io.Reader {
+ r, err := zstd.NewReader(bytes.NewReader(localEmbeddedCopilotRuntimeExecutableLinuxMusl))
+ if err != nil {
+ panic("failed to create zstd reader: " + err.Error())
+ }
+ return r
+}
`
}
diff --git a/go/cmd/bundler/main_test.go b/go/cmd/bundler/main_test.go
index badc791359..0a7f1c3876 100644
--- a/go/cmd/bundler/main_test.go
+++ b/go/cmd/bundler/main_test.go
@@ -1,26 +1,126 @@
package main
import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
"go/parser"
"go/token"
+ "io"
"os"
"path/filepath"
"strings"
"testing"
)
-func TestGenerateGoFileGatesRuntimeEmbed(t *testing.T) {
+func TestCreateRuntimeAssetsArchiveRetainsUnknownAssetsAndFiltersCLIContent(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "package.tgz")
+ output := filepath.Join(dir, "assets.tgz")
+ writeTarGz(t, source, map[string]string{
+ "package/prebuilds/linux-x64/runtime.node": "runtime",
+ "package/prebuilds/linux-x64/copilot-runtime": "wrapper",
+ "package/ripgrep/bin/linux-x64/rg": "ripgrep",
+ "package/definitions/future.json": "{}",
+ "package/app.js": "excluded",
+ "package/LICENSE.md": "excluded",
+ "package/README.md": "excluded",
+ })
+
+ if err := createRuntimeAssetsArchive(source, output, platformInfo{
+ npmPlatform: "linux-x64",
+ binaryName: "copilot",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ files := readTarGz(t, output)
+ if files["ripgrep/bin/linux-x64/rg"] != "ripgrep" || files["definitions/future.json"] != "{}" {
+ t.Fatalf("retained assets = %#v", files)
+ }
+ for _, excluded := range []string{
+ "runtime.node", "copilot-runtime", "app.js", "LICENSE.md", "README.md",
+ } {
+ if _, ok := files[excluded]; ok {
+ t.Fatalf("excluded asset %q was retained", excluded)
+ }
+ }
+}
+
+func writeTarGz(t *testing.T, path string, files map[string]string) {
+ t.Helper()
+ var buffer bytes.Buffer
+ gzipWriter := gzip.NewWriter(&buffer)
+ tarWriter := tar.NewWriter(gzipWriter)
+ for name, content := range files {
+ header := &tar.Header{Name: name, Mode: 0755, Size: int64(len(content)), Typeflag: tar.TypeReg}
+ if err := tarWriter.WriteHeader(header); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tarWriter.Write([]byte(content)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := tarWriter.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := gzipWriter.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, buffer.Bytes(), 0644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func readTarGz(t *testing.T, path string) map[string]string {
+ t.Helper()
+ file, err := os.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer file.Close()
+ gzipReader, err := gzip.NewReader(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ files := map[string]string{}
+ tarReader := tar.NewReader(gzipReader)
+ for {
+ header, err := tarReader.Next()
+ if err == io.EOF {
+ return files
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ content, err := io.ReadAll(tarReader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ files[header.Name] = string(content)
+ }
+}
+
+func TestGenerateGoFileEmbedsRuntimeWrapperPair(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "copilot.zst")
runtimePath := filepath.Join(dir, "runtime.node.zst")
+ wrapperPath := filepath.Join(dir, "copilot-runtime.zst")
+ assetsPath := filepath.Join(dir, "runtime-assets.tgz")
muslBinaryPath := filepath.Join(dir, "copilot-musl.zst")
muslRuntimePath := filepath.Join(dir, "runtime-musl.node.zst")
+ muslWrapperPath := filepath.Join(dir, "copilot-runtime-musl.zst")
+ muslAssetsPath := filepath.Join(dir, "runtime-assets-musl.tgz")
for _, path := range []string{
binaryPath,
licensePathForOutput(binaryPath),
runtimePath,
+ wrapperPath,
+ assetsPath,
muslBinaryPath,
muslRuntimePath,
+ muslWrapperPath,
+ muslAssetsPath,
} {
if err := os.WriteFile(path, []byte("test"), 0644); err != nil {
t.Fatal(err)
@@ -36,10 +136,18 @@ func TestGenerateGoFileGatesRuntimeEmbed(t *testing.T) {
hash,
runtimePath,
hash,
+ wrapperPath,
+ hash,
+ assetsPath,
+ hash,
muslBinaryPath,
hash,
muslRuntimePath,
hash,
+ muslWrapperPath,
+ hash,
+ muslAssetsPath,
+ hash,
"main",
); err != nil {
t.Fatal(err)
@@ -52,8 +160,20 @@ func TestGenerateGoFileGatesRuntimeEmbed(t *testing.T) {
if !strings.Contains(string(defaultSource), "//go:build !copilot_inprocess") {
t.Fatal("default embed file does not exclude copilot_inprocess builds")
}
- if strings.Contains(string(defaultSource), "localEmbeddedCopilotRuntimeLib") {
- t.Fatal("default embed file includes the native runtime")
+ if !strings.Contains(string(defaultSource), "localEmbeddedCopilotRuntimeExecutable") {
+ t.Fatal("default embed file does not include the runtime wrapper")
+ }
+ if !strings.Contains(string(defaultSource), "RuntimeNode:") {
+ t.Fatal("default embed file does not configure runtime.node")
+ }
+ if !strings.Contains(string(defaultSource), "RuntimeAssets:") {
+ t.Fatal("default embed file does not configure retained runtime assets")
+ }
+ if !strings.Contains(string(defaultSource), "localEmbeddedCopilotCLILinuxMusl") {
+ t.Fatal("default embed file does not include the Linux musl CLI")
+ }
+ if !strings.Contains(string(defaultSource), "localEmbeddedCopilotRuntimeLibLinuxMusl") {
+ t.Fatal("default embed file does not include the Linux musl runtime")
}
if _, err := parser.ParseFile(token.NewFileSet(), "zcopilot_linux_amd64.go", defaultSource, parser.AllErrors); err != nil {
t.Fatalf("default generated source is invalid: %v", err)
diff --git a/go/inprocess_disabled.go b/go/inprocess_disabled.go
index d0626a74d3..c100e3db6a 100644
--- a/go/inprocess_disabled.go
+++ b/go/inprocess_disabled.go
@@ -8,6 +8,6 @@ const inProcessAvailable = false
var errInProcessUnavailable = errors.New("in-process transport unavailable")
-func createInProcessHost(string, inProcessHostConfig) (inProcessHost, error) {
+func createInProcessHost(string, string, inProcessHostConfig) (inProcessHost, error) {
return nil, errInProcessUnavailable
}
diff --git a/go/inprocess_enabled.go b/go/inprocess_enabled.go
index c20013d8ab..2c30d5f863 100644
--- a/go/inprocess_enabled.go
+++ b/go/inprocess_enabled.go
@@ -6,6 +6,6 @@ import "github.com/github/copilot-sdk/go/internal/ffihost"
const inProcessAvailable = true
-func createInProcessHost(runtimePath string, config inProcessHostConfig) (inProcessHost, error) {
- return ffihost.Create(runtimePath, config.Environment, config.Args)
+func createInProcessHost(runtimePath, cliEntrypoint string, config inProcessHostConfig) (inProcessHost, error) {
+ return ffihost.Create(runtimePath, cliEntrypoint, config.Environment, config.Args)
}
diff --git a/go/internal/e2e/inprocess_ffi_e2e_test.go b/go/internal/e2e/inprocess_ffi_e2e_test.go
index 6923384a28..7f7dcc3f20 100644
--- a/go/internal/e2e/inprocess_ffi_e2e_test.go
+++ b/go/internal/e2e/inprocess_ffi_e2e_test.go
@@ -8,8 +8,8 @@ import (
)
// TestInProcessFfiE2E is a smoke test for the in-process (FFI) transport. It
-// starts a client that loads the native runtime cdylib next to the resolved CLI
-// entrypoint, lets the native host spawn the worker, performs a purely local
+// starts a client that loads the native runtime cdylib directly, lets the native
+// host construct the server, performs a purely local
// "ping" round-trip through the runtime, and stops cleanly. No auth or replay
// proxy is involved, so it needs no snapshot.
//
diff --git a/go/internal/embeddedcli/embeddedcli.go b/go/internal/embeddedcli/embeddedcli.go
index cd0be21895..217f803110 100644
--- a/go/internal/embeddedcli/embeddedcli.go
+++ b/go/internal/embeddedcli/embeddedcli.go
@@ -1,7 +1,9 @@
package embeddedcli
import (
+ "archive/tar"
"bytes"
+ "compress/gzip"
"crypto/sha256"
"fmt"
"io"
@@ -23,10 +25,10 @@ import (
// version-specific child directory so multiple versions can coexist. License,
// when provided, is written next to the installed binary.
//
-// RuntimeLib and RuntimeLibHash are optional: when set, the native in-process
-// runtime library (cdylib) is installed next to the CLI binary so the in-process
-// (FFI) transport can load it. They are omitted for CLI packages that do not
-// ship the native runtime.
+// RuntimeExecutable and RuntimeNode form the adjacent out-of-process runtime
+// pair. RuntimeAssets is a filtered npm package archive containing auxiliary
+// binaries and resources. RuntimeLib is the same cdylib bytes installed under
+// the natural platform name for the optional in-process transport.
type Config struct {
Cli io.Reader
CliHash []byte
@@ -36,12 +38,25 @@ type Config struct {
RuntimeLib io.Reader
RuntimeLibHash []byte
+ RuntimeExecutable io.Reader
+ RuntimeExecutableHash []byte
+ RuntimeNode io.Reader
+ RuntimeNodeHash []byte
+ RuntimeAssets io.Reader
+ RuntimeAssetsHash []byte
+
// LinuxMuslCli and LinuxMuslRuntimeLib are optional alternatives selected
// automatically when the application runs on a musl-based Linux system.
- LinuxMuslCli io.Reader
- LinuxMuslCliHash []byte
- LinuxMuslRuntimeLib io.Reader
- LinuxMuslRuntimeLibHash []byte
+ LinuxMuslCli io.Reader
+ LinuxMuslCliHash []byte
+ LinuxMuslRuntimeLib io.Reader
+ LinuxMuslRuntimeLibHash []byte
+ LinuxMuslRuntimeExecutable io.Reader
+ LinuxMuslRuntimeExecutableHash []byte
+ LinuxMuslRuntimeNode io.Reader
+ LinuxMuslRuntimeNodeHash []byte
+ LinuxMuslRuntimeAssets io.Reader
+ LinuxMuslRuntimeAssetsHash []byte
Dir string
Version string
@@ -60,6 +75,10 @@ func Setup(cfg Config) {
if cfg.LinuxMuslRuntimeLib != nil && len(cfg.LinuxMuslRuntimeLibHash) != sha256.Size {
panic(fmt.Sprintf("LinuxMuslRuntimeLibHash must be a SHA-256 hash (%d bytes), got %d bytes", sha256.Size, len(cfg.LinuxMuslRuntimeLibHash)))
}
+ validateRuntimePairConfig(cfg.RuntimeExecutable, cfg.RuntimeExecutableHash, cfg.RuntimeNode, cfg.RuntimeNodeHash, "")
+ validateRuntimePairConfig(cfg.LinuxMuslRuntimeExecutable, cfg.LinuxMuslRuntimeExecutableHash, cfg.LinuxMuslRuntimeNode, cfg.LinuxMuslRuntimeNodeHash, "LinuxMusl")
+ validateOptionalHash(cfg.RuntimeAssets, cfg.RuntimeAssetsHash, "RuntimeAssetsHash")
+ validateOptionalHash(cfg.LinuxMuslRuntimeAssets, cfg.LinuxMuslRuntimeAssetsHash, "LinuxMuslRuntimeAssetsHash")
setupMu.Lock()
defer setupMu.Unlock()
if setupDone {
@@ -93,13 +112,34 @@ func RuntimeLibPath() string {
return runtimeLibPath
}
+// RuntimePath returns the installed copilot-runtime executable, or "" when the
+// application bundle predates the out-of-process runtime pair.
+func RuntimePath() string {
+ setupMu.Lock()
+ defer setupMu.Unlock()
+ if !setupDone {
+ return ""
+ }
+ pathInitialized = true
+ selectLinuxMuslBundle()
+ if config.RuntimeExecutable == nil {
+ return ""
+ }
+ if runtimePath == "" {
+ runtimePath = installRuntime()
+ }
+ return runtimePath
+}
+
var (
- config Config
- setupMu sync.Mutex
- setupDone bool
- pathInitialized bool
- runtimeLibPath string
- linuxMuslBundle bool
+ config Config
+ setupMu sync.Mutex
+ setupDone bool
+ pathInitialized bool
+ runtimeLibPath string
+ runtimePath string
+ runtimeAssetsInstalled bool
+ linuxMuslBundle bool
)
func install() (path string) {
@@ -118,6 +158,38 @@ func install() (path string) {
fmt.Printf("installing embedded CLI at %s installation took %s\n", path, duration)
}()
}
+ installDir := configuredInstallDir()
+ path, err := installAt(installDir)
+ if err != nil {
+ logError("installing in configured directory", err)
+ return ""
+ }
+ return path
+}
+
+func installRuntime() (path string) {
+ verbose := os.Getenv("COPILOT_CLI_INSTALL_VERBOSE") == "1"
+ logError := func(msg string, err error) {
+ if verbose {
+ fmt.Printf("embedded runtime installation error: %s: %v\n", msg, err)
+ }
+ }
+ if verbose {
+ start := time.Now()
+ defer func() {
+ fmt.Printf("installing embedded runtime at %s took %s\n", path, time.Since(start))
+ }()
+ }
+
+ path, err := installRuntimeAt(configuredInstallDir())
+ if err != nil {
+ logError("installing in configured directory", err)
+ return ""
+ }
+ return path
+}
+
+func configuredInstallDir() string {
installDir := config.Dir
if installDir == "" {
if copilotHome := os.Getenv("COPILOT_HOME"); copilotHome != "" {
@@ -131,12 +203,7 @@ func install() (path string) {
installDir = filepath.Join(installDir, "copilot-sdk")
}
}
- path, err := installAt(installDir)
- if err != nil {
- logError("installing in configured directory", err)
- return ""
- }
- return path
+ return installDir
}
func selectLinuxMuslBundle() {
@@ -152,6 +219,12 @@ func linuxMuslConfig(cfg Config) Config {
cfg.CliHash = cfg.LinuxMuslCliHash
cfg.RuntimeLib = cfg.LinuxMuslRuntimeLib
cfg.RuntimeLibHash = cfg.LinuxMuslRuntimeLibHash
+ cfg.RuntimeExecutable = cfg.LinuxMuslRuntimeExecutable
+ cfg.RuntimeExecutableHash = cfg.LinuxMuslRuntimeExecutableHash
+ cfg.RuntimeNode = cfg.LinuxMuslRuntimeNode
+ cfg.RuntimeNodeHash = cfg.LinuxMuslRuntimeNodeHash
+ cfg.RuntimeAssets = cfg.LinuxMuslRuntimeAssets
+ cfg.RuntimeAssetsHash = cfg.LinuxMuslRuntimeAssetsHash
return cfg
}
@@ -198,6 +271,9 @@ func installAt(installDir string) (string, error) {
}
runtimeLibPath = libPath
}
+ if err := installRuntimeAssets(installDir); err != nil {
+ return "", err
+ }
return finalPath, nil
}
@@ -229,12 +305,188 @@ func installAt(installDir string) (string, error) {
if err != nil {
return "", err
}
+ if err := installRuntimeAssets(installDir); err != nil {
+ return "", err
+ }
runtimeLibPath = libPath
}
return finalPath, nil
}
+func installRuntimeAt(installDir string) (string, error) {
+ version := sanitizeVersion(config.Version)
+ if version != "" {
+ installDir = filepath.Join(installDir, version)
+ }
+ if linuxMuslBundle {
+ installDir = filepath.Join(installDir, "linuxmusl")
+ }
+ if err := os.MkdirAll(installDir, 0755); err != nil {
+ return "", fmt.Errorf("creating install directory: %w", err)
+ }
+
+ if release, _ := flock.Acquire(filepath.Join(installDir, ".copilot-cli.lock")); release != nil {
+ defer release()
+ }
+ path, err := installRuntimePair(installDir)
+ if err != nil {
+ return "", err
+ }
+ if err := installRuntimeAssets(installDir); err != nil {
+ return "", err
+ }
+ return path, nil
+}
+
+func validateOptionalHash(reader io.Reader, hash []byte, name string) {
+ if reader != nil && len(hash) != sha256.Size {
+ panic(fmt.Sprintf("%s must be a SHA-256 hash (%d bytes), got %d bytes", name, sha256.Size, len(hash)))
+ }
+}
+
+func installRuntimeAssets(installDir string) error {
+ if config.RuntimeAssets == nil || runtimeAssetsInstalled {
+ return nil
+ }
+ archiveBytes, err := io.ReadAll(config.RuntimeAssets)
+ if closer, ok := config.RuntimeAssets.(io.Closer); ok {
+ closer.Close()
+ }
+ if err != nil {
+ return fmt.Errorf("reading runtime assets: %w", err)
+ }
+ actual := sha256.Sum256(archiveBytes)
+ if !bytes.Equal(actual[:], config.RuntimeAssetsHash) {
+ return fmt.Errorf("runtime assets hash mismatch")
+ }
+ gzipReader, err := gzip.NewReader(bytes.NewReader(archiveBytes))
+ if err != nil {
+ return fmt.Errorf("opening runtime assets: %w", err)
+ }
+ defer gzipReader.Close()
+ tarReader := tar.NewReader(gzipReader)
+ for {
+ header, err := tarReader.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return fmt.Errorf("reading runtime assets: %w", err)
+ }
+ if header.Typeflag != tar.TypeReg {
+ continue
+ }
+ clean := filepath.Clean(filepath.FromSlash(header.Name))
+ if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
+ return fmt.Errorf("unsafe runtime asset path %q", header.Name)
+ }
+ content, err := io.ReadAll(tarReader)
+ if err != nil {
+ return fmt.Errorf("reading runtime asset %q: %w", header.Name, err)
+ }
+ path := filepath.Join(installDir, clean)
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ return fmt.Errorf("creating runtime asset directory: %w", err)
+ }
+ hash := sha256.Sum256(content)
+ mode := os.FileMode(header.Mode & 0777)
+ if err := installVerifiedFile(path, bytes.NewReader(content), hash[:], mode, "runtime asset"); err != nil {
+ return err
+ }
+ }
+ runtimeAssetsInstalled = true
+ return nil
+}
+
+func validateRuntimePairConfig(wrapper io.Reader, wrapperHash []byte, node io.Reader, nodeHash []byte, prefix string) {
+ if (wrapper == nil) != (node == nil) {
+ panic(prefix + "RuntimeExecutable and " + prefix + "RuntimeNode must be provided together")
+ }
+ if wrapper == nil {
+ return
+ }
+ if len(wrapperHash) != sha256.Size {
+ panic(fmt.Sprintf("%sRuntimeExecutableHash must be a SHA-256 hash (%d bytes), got %d bytes", prefix, sha256.Size, len(wrapperHash)))
+ }
+ if len(nodeHash) != sha256.Size {
+ panic(fmt.Sprintf("%sRuntimeNodeHash must be a SHA-256 hash (%d bytes), got %d bytes", prefix, sha256.Size, len(nodeHash)))
+ }
+}
+
+func installRuntimePair(installDir string) (string, error) {
+ nodePath := filepath.Join(installDir, "runtime.node")
+ if err := installVerifiedFile(nodePath, config.RuntimeNode, config.RuntimeNodeHash, 0644, "runtime.node"); err != nil {
+ return "", err
+ }
+ wrapperPath := filepath.Join(installDir, runtimeExecutableName())
+ if err := installVerifiedFile(wrapperPath, config.RuntimeExecutable, config.RuntimeExecutableHash, 0755, "runtime wrapper"); err != nil {
+ return "", err
+ }
+ return wrapperPath, nil
+}
+
+func installVerifiedFile(path string, reader io.Reader, expectedHash []byte, mode os.FileMode, label string) error {
+ if _, err := os.Stat(path); err == nil {
+ existingHash, err := hashFile(path)
+ if err != nil {
+ return fmt.Errorf("hashing existing %s: %w", label, err)
+ }
+ if !bytes.Equal(existingHash, expectedHash) {
+ return fmt.Errorf("existing %s hash mismatch", label)
+ }
+ if runtime.GOOS != "windows" && mode.Perm()&0111 != 0 {
+ info, err := os.Stat(path)
+ if err != nil {
+ return fmt.Errorf("checking existing %s permissions: %w", label, err)
+ }
+ if info.Mode().Perm()&0111 == 0 {
+ if err := os.Chmod(path, info.Mode().Perm()|mode.Perm()&0111); err != nil {
+ return fmt.Errorf("restoring existing %s permissions: %w", label, err)
+ }
+ }
+ }
+ return nil
+ }
+
+ tmp, err := os.CreateTemp(filepath.Dir(path), ".copilot-runtime-pair-*.tmp")
+ if err != nil {
+ return fmt.Errorf("creating temporary %s: %w", label, err)
+ }
+ tmpPath := tmp.Name()
+ h := sha256.New()
+ _, err = io.Copy(io.MultiWriter(tmp, h), reader)
+ if err1 := tmp.Chmod(mode); err1 != nil && err == nil {
+ err = err1
+ }
+ if err1 := tmp.Close(); err1 != nil && err == nil {
+ err = err1
+ }
+ if closer, ok := reader.(io.Closer); ok {
+ closer.Close()
+ }
+ if err != nil {
+ os.Remove(tmpPath)
+ return fmt.Errorf("writing %s: %w", label, err)
+ }
+ if !bytes.Equal(h.Sum(nil), expectedHash) {
+ os.Remove(tmpPath)
+ return fmt.Errorf("%s hash mismatch", label)
+ }
+ if err := os.Rename(tmpPath, path); err != nil {
+ os.Remove(tmpPath)
+ return fmt.Errorf("installing %s: %w", label, err)
+ }
+ return nil
+}
+
+func runtimeExecutableName() string {
+ if runtime.GOOS == "windows" {
+ return "copilot-runtime.exe"
+ }
+ return "copilot-runtime"
+}
+
// installRuntimeLib writes the embedded runtime cdylib into installDir under its
// natural platform file name, verifying its SHA-256. It is idempotent: an
// existing file with a matching hash is reused; a mismatch is a hard error.
diff --git a/go/internal/embeddedcli/embeddedcli_test.go b/go/internal/embeddedcli/embeddedcli_test.go
index b0394e0f69..159b6e1505 100644
--- a/go/internal/embeddedcli/embeddedcli_test.go
+++ b/go/internal/embeddedcli/embeddedcli_test.go
@@ -1,7 +1,9 @@
package embeddedcli
import (
+ "archive/tar"
"bytes"
+ "compress/gzip"
"crypto/sha256"
"os"
"path/filepath"
@@ -17,9 +19,143 @@ func resetGlobals() {
setupDone = false
pathInitialized = false
runtimeLibPath = ""
+ runtimePath = ""
+ runtimeAssetsInstalled = false
linuxMuslBundle = false
}
+func TestInstallRuntimeWritesRetainedAssets(t *testing.T) {
+ resetGlobals()
+ tempDir := t.TempDir()
+ cli := []byte("cli")
+ wrapper := []byte("wrapper")
+ node := []byte("runtime")
+ assets := runtimeAssetsArchive(t, map[string]assetFixture{
+ "ripgrep/bin/test-platform/rg": {content: []byte("ripgrep"), mode: 0755},
+ "definitions/future.json": {content: []byte("{}"), mode: 0644},
+ })
+ cliHash := sha256.Sum256(cli)
+ wrapperHash := sha256.Sum256(wrapper)
+ nodeHash := sha256.Sum256(node)
+ assetsHash := sha256.Sum256(assets)
+ Setup(Config{
+ Cli: bytes.NewReader(cli),
+ CliHash: cliHash[:],
+ RuntimeExecutable: bytes.NewReader(wrapper),
+ RuntimeExecutableHash: wrapperHash[:],
+ RuntimeNode: bytes.NewReader(node),
+ RuntimeNodeHash: nodeHash[:],
+ RuntimeAssets: bytes.NewReader(assets),
+ RuntimeAssetsHash: assetsHash[:],
+ Version: "1.2.3",
+ Dir: tempDir,
+ })
+
+ gotWrapper, err := installRuntimeAt(tempDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ installDir := filepath.Dir(gotWrapper)
+ if got, err := os.ReadFile(filepath.Join(installDir, "ripgrep", "bin", "test-platform", "rg")); err != nil || string(got) != "ripgrep" {
+ t.Fatalf("ripgrep content=%q err=%v", got, err)
+ }
+ if got, err := os.ReadFile(filepath.Join(installDir, "definitions", "future.json")); err != nil || string(got) != "{}" {
+ t.Fatalf("definition content=%q err=%v", got, err)
+ }
+}
+
+type assetFixture struct {
+ content []byte
+ mode int64
+}
+
+func runtimeAssetsArchive(t *testing.T, files map[string]assetFixture) []byte {
+ t.Helper()
+ var buffer bytes.Buffer
+ gzipWriter := gzip.NewWriter(&buffer)
+ tarWriter := tar.NewWriter(gzipWriter)
+ for name, fixture := range files {
+ header := &tar.Header{Name: name, Mode: fixture.mode, Size: int64(len(fixture.content)), Typeflag: tar.TypeReg}
+ if err := tarWriter.WriteHeader(header); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tarWriter.Write(fixture.content); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := tarWriter.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := gzipWriter.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return buffer.Bytes()
+}
+
+func TestInstallRuntimeWritesAdjacentPairWithoutCLI(t *testing.T) {
+ resetGlobals()
+ tempDir := t.TempDir()
+ cli := []byte("cli")
+ wrapper := []byte("wrapper")
+ node := []byte("runtime")
+ cliHash := sha256.Sum256(cli)
+ wrapperHash := sha256.Sum256(wrapper)
+ nodeHash := sha256.Sum256(node)
+ Setup(Config{
+ Cli: bytes.NewReader(cli),
+ CliHash: cliHash[:],
+ RuntimeExecutable: bytes.NewReader(wrapper),
+ RuntimeExecutableHash: wrapperHash[:],
+ RuntimeNode: bytes.NewReader(node),
+ RuntimeNodeHash: nodeHash[:],
+ Version: "1.2.3",
+ Dir: tempDir,
+ })
+
+ gotWrapper, err := installRuntimeAt(tempDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if gotWrapper != filepath.Join(tempDir, "1.2.3", runtimeExecutableName()) {
+ t.Fatalf("RuntimePath() = %q", gotWrapper)
+ }
+ if got, err := os.ReadFile(filepath.Join(filepath.Dir(gotWrapper), "runtime.node")); err != nil || !bytes.Equal(got, node) {
+ t.Fatalf("runtime.node content=%q err=%v", got, err)
+ }
+ if cliPath := filepath.Join(filepath.Dir(gotWrapper), binaryNameForOS()); fileExists(cliPath) {
+ t.Fatalf("managed runtime installation unexpectedly materialized CLI host at %q", cliPath)
+ }
+}
+
+func fileExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
+
+func TestInstallVerifiedFileRestoresExecutablePermission(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("Windows does not use Unix execute bits")
+ }
+ path := filepath.Join(t.TempDir(), "copilot-runtime")
+ content := []byte("wrapper")
+ hash := sha256.Sum256(content)
+ if err := os.WriteFile(path, content, 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := installVerifiedFile(path, bytes.NewReader(content), hash[:], 0755, "runtime wrapper"); err != nil {
+ t.Fatal(err)
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode().Perm()&0111 == 0 {
+ t.Fatal("existing runtime wrapper was not made executable")
+ }
+}
+
func mustPanic(t *testing.T, fn func()) {
t.Helper()
defer func() {
diff --git a/go/internal/ffihost/ffihost.go b/go/internal/ffihost/ffihost.go
index 30cd831281..9a824881de 100644
--- a/go/internal/ffihost/ffihost.go
+++ b/go/internal/ffihost/ffihost.go
@@ -159,8 +159,8 @@ type Host struct {
// Create resolves the native library and prepares the host. environment and
// args contain SDK-managed runtime options.
-func Create(cliEntrypoint string, environment map[string]string, args []string) (*Host, error) {
- libraryPath, err := ResolveLibraryPath(cliEntrypoint)
+func Create(runtimeEntrypoint, cliEntrypoint string, environment map[string]string, args []string) (*Host, error) {
+ libraryPath, err := ResolveLibraryPath(runtimeEntrypoint)
if err != nil {
return nil, err
}
@@ -207,18 +207,13 @@ func (h *Host) Start() error {
runtime.KeepAlive(argv)
runtime.KeepAlive(env)
if h.serverID == 0 {
- return fmt.Errorf("copilot_runtime_host_start failed (library %q, entrypoint %q)", h.libraryPath, h.cliEntrypoint)
+ return fmt.Errorf("copilot_runtime_host_start failed (library %q)", h.libraryPath)
}
- // host_start spawned the worker child via libuv's uv_spawn, which installs a
- // SIGCHLD handler without SA_ONSTACK on its first call. The Go runtime aborts
- // ("non-Go code set up signal handler without SA_ONSTACK flag") when it later
- // reaps one of its own os/exec children (e.g. a test-spawned MCP server) and
- // the delivered SIGCHLD lands on a non-signal stack. Re-add SA_ONSTACK to that
- // foreign handler now that it exists (implemented on darwin+linux; a no-op on
- // other platforms, and before the first spawn there is nothing to fix — hence
- // here rather than at library load).
- rearmForeignSignalHandlers(h.lib.handle)
+ if h.cliEntrypoint != "" {
+ // A legacy embedded host may install a SIGCHLD handler without SA_ONSTACK.
+ rearmForeignSignalHandlers(h.lib.handle)
+ }
callbackHandle := sharedOutboundCallback()
callbackToken := uintptr(nextOutboundToken.Add(1))
@@ -229,7 +224,9 @@ func (h *Host) Start() error {
outboundTargets.Delete(callbackToken)
h.callbackToken = 0
h.lib.hostShutdown(h.serverID)
- rearmForeignSignalHandlers(h.lib.handle)
+ if h.cliEntrypoint != "" {
+ rearmForeignSignalHandlers(h.lib.handle)
+ }
h.serverID = 0
return fmt.Errorf("copilot_runtime_connection_open failed")
}
@@ -243,14 +240,12 @@ func (h *Host) Writer() io.WriteCloser { return hostWriter{h} }
func (h *Host) Reader() io.ReadCloser { return h.recv }
func (h *Host) buildArgv() []byte {
- // 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 (avoids ABI skew).
- var argv []string
- if strings.HasSuffix(strings.ToLower(h.cliEntrypoint), ".js") {
- argv = []string{"node", h.cliEntrypoint, "--embedded-host", "--no-auto-update"}
- } else {
- argv = []string{h.cliEntrypoint, "--embedded-host", "--no-auto-update"}
+ argv := make([]string, 0, len(h.args)+4)
+ if h.cliEntrypoint != "" {
+ if strings.HasSuffix(strings.ToLower(h.cliEntrypoint), ".js") {
+ argv = append(argv, "node")
+ }
+ argv = append(argv, h.cliEntrypoint, "--embedded-host", "--no-auto-update")
}
argv = append(argv, h.args...)
b, _ := json.Marshal(argv)
@@ -365,10 +360,10 @@ func (h *Host) Dispose() {
}
if serverID != 0 {
h.lib.hostShutdown(serverID)
- // libuv may restore a previously saved SIGCHLD action while tearing down
- // its final child watcher, so repair the process-wide handler again after
- // shutdown before Go reaps another os/exec child.
- rearmForeignSignalHandlers(h.lib.handle)
+ if h.cliEntrypoint != "" {
+ // A legacy host may restore its saved SIGCHLD action during shutdown.
+ rearmForeignSignalHandlers(h.lib.handle)
+ }
}
h.recv.Close()
}
diff --git a/go/internal/ffihost/ffihost_test.go b/go/internal/ffihost/ffihost_test.go
index bc588fa6a9..ccb48af419 100644
--- a/go/internal/ffihost/ffihost_test.go
+++ b/go/internal/ffihost/ffihost_test.go
@@ -25,10 +25,9 @@ func TestDisposeUnregistersOutboundTarget(t *testing.T) {
}
}
-func TestBuildArgvAppendsManagedOptions(t *testing.T) {
+func TestBuildArgvWithoutEntrypointContainsOnlyManagedOptions(t *testing.T) {
host := &Host{
- cliEntrypoint: "copilot",
- args: []string{"--log-level", "debug", "--remote"},
+ args: []string{"--log-level", "debug", "--remote"},
}
var argv []string
@@ -36,7 +35,45 @@ func TestBuildArgvAppendsManagedOptions(t *testing.T) {
t.Fatal(err)
}
- expected := []string{"copilot", "--embedded-host", "--no-auto-update", "--log-level", "debug", "--remote"}
+ expected := []string{"--log-level", "debug", "--remote"}
+ if len(argv) != len(expected) {
+ t.Fatalf("Expected %d arguments, got %d: %v", len(expected), len(argv), argv)
+ }
+ for i := range expected {
+ if argv[i] != expected[i] {
+ t.Fatalf("Expected argument %d to be %q, got %q", i, expected[i], argv[i])
+ }
+ }
+}
+
+func TestBuildArgvPreservesExplicitEntrypoint(t *testing.T) {
+ host := &Host{cliEntrypoint: "copilot", args: []string{"--remote"}}
+
+ var argv []string
+ if err := json.Unmarshal(host.buildArgv(), &argv); err != nil {
+ t.Fatal(err)
+ }
+
+ expected := []string{"copilot", "--embedded-host", "--no-auto-update", "--remote"}
+ if len(argv) != len(expected) {
+ t.Fatalf("Expected %d arguments, got %d: %v", len(expected), len(argv), argv)
+ }
+ for i := range expected {
+ if argv[i] != expected[i] {
+ t.Fatalf("Expected argument %d to be %q, got %q", i, expected[i], argv[i])
+ }
+ }
+}
+
+func TestBuildArgvUsesNodeForExplicitJavaScriptEntrypoint(t *testing.T) {
+ host := &Host{cliEntrypoint: "copilot.js"}
+
+ var argv []string
+ if err := json.Unmarshal(host.buildArgv(), &argv); err != nil {
+ t.Fatal(err)
+ }
+
+ expected := []string{"node", "copilot.js", "--embedded-host", "--no-auto-update"}
if len(argv) != len(expected) {
t.Fatalf("Expected %d arguments, got %d: %v", len(expected), len(argv), argv)
}
diff --git a/go/internal/ffihost/resolve.go b/go/internal/ffihost/resolve.go
index c8d4052322..5205a01c19 100644
--- a/go/internal/ffihost/resolve.go
+++ b/go/internal/ffihost/resolve.go
@@ -63,7 +63,8 @@ func PrebuildsFolder() string {
// entrypoint. It checks, in order:
//
// 1. The natural platform library name next to the CLI (bundled/flat layout).
-// 2. prebuilds//runtime.node next to the CLI (dev/package layout).
+// 2. runtime.node next to the CLI (out-of-process wrapper layout).
+// 3. prebuilds//runtime.node next to the CLI (dev/package layout).
//
// It returns an error when neither exists.
func ResolveLibraryPath(cliEntrypoint string) (string, error) {
@@ -78,6 +79,11 @@ func ResolveLibraryPath(cliEntrypoint string) (string, error) {
return flat, nil
}
+ adjacent := filepath.Join(dir, "runtime.node")
+ if fileExists(adjacent) {
+ return adjacent, nil
+ }
+
if folder := PrebuildsFolder(); folder != "" {
prebuilt := filepath.Join(dir, "prebuilds", folder, "runtime.node")
if fileExists(prebuilt) {
@@ -86,7 +92,7 @@ func ResolveLibraryPath(cliEntrypoint string) (string, error) {
}
return "", fmt.Errorf(
- "in-process FFI runtime library not found next to %q (looked for %q and prebuilds/%s/runtime.node); "+
+ "in-process FFI runtime library not found next to %q (looked for %q, runtime.node, and prebuilds/%s/runtime.node); "+
"use a runtime package that ships the native library",
abs, NaturalLibraryName(), PrebuildsFolder())
}
diff --git a/java/README.md b/java/README.md
index d7d6bdf269..5fbadae171 100644
--- a/java/README.md
+++ b/java/README.md
@@ -20,7 +20,10 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A
To use the SDK, you'll need:
- Java 17 or later. **JDK 25 recommended**. The distributed jar is a multi-release jar (MR-JAR) and is compiled on JDK 25 with `maven.compiler.release` set to 17. This means, when run on JDK 25 and later, the SDK automatically uses virtual threads for its default internal executor.
-- GitHub Copilot CLI 1.0.55-5 or later installed and in `PATH` (or provide custom `cliPath`)
+
+Managed stdio and TCP connections materialize the platform classifier's
+`copilot-runtime[.exe]` and adjacent `runtime.node` by default. An explicit
+`cliPath` overrides the bundled runtime.
## Installation
@@ -557,7 +560,7 @@ mvn clean verify -Dcopilot.native.libc=glibc
mvn clean package -pl copilot-native -DskipTests -Dcopilot.native.libc=glibc -Dcopilot.native.skip.download=true
```
-On Linux x64, the classifier JAR contains `native/linux-x64/runtime.node`, `native/linux-x64/platform.properties`, and `native/linux-x64/copilot`. On Windows x64, it contains `native/win32-x64/runtime.node`, `native/win32-x64/platform.properties`, and `native/win32-x64/copilot.exe`. On Apple Silicon macOS, it contains `native/darwin-arm64/runtime.node`, `native/darwin-arm64/platform.properties`, and `native/darwin-arm64/copilot`. The placeholder JAR remains OS-neutral and contains no native binaries. Unsupported hosts retain the placeholder-only behavior.
+Each classifier JAR includes `runtime.node`, `platform.properties`, and `copilot-runtime` (or `copilot-runtime.exe`) under its `native/` directory. It does not contain the legacy `copilot` SEA. The placeholder JAR remains OS-neutral and contains no native binaries. Unsupported hosts retain the placeholder-only behavior.
## License
diff --git a/java/copilot-native/pom.xml b/java/copilot-native/pom.xml
index 4ad0e02929..75ce571577 100644
--- a/java/copilot-native/pom.xml
+++ b/java/copilot-native/pom.xml
@@ -126,7 +126,7 @@
@@ -203,12 +203,15 @@
-
+
+
+
+
-
+
-
+
@@ -243,7 +246,6 @@
inprocesslinux-x64
- copilot
@@ -306,7 +308,6 @@
linux-x64
- copilot
@@ -361,7 +362,6 @@
win32-x64
- copilot.exe
@@ -416,7 +416,6 @@
darwin-arm64
- copilot
diff --git a/java/copilot-native/scripts/create-native-classifier-test-fixture.mjs b/java/copilot-native/scripts/create-native-classifier-test-fixture.mjs
index 095d8c1e9e..08eccb644a 100644
--- a/java/copilot-native/scripts/create-native-classifier-test-fixture.mjs
+++ b/java/copilot-native/scripts/create-native-classifier-test-fixture.mjs
@@ -13,14 +13,14 @@ export function createNativeClassifierTestFixture({
outputPath,
repoRoot,
}) {
- const cliFilename = classifier.startsWith("win32")
- ? "copilot.exe"
- : "copilot";
+ const runtimeFilename = classifier.startsWith("win32")
+ ? "copilot-runtime.exe"
+ : "copilot-runtime";
const nativeVersion = readPinnedNativeVersion(repoRoot, classifier);
const prefix = `native/${classifier}`;
writeStoredZip(outputPath, [
[`${prefix}/runtime.node`, "test runtime"],
- [`${prefix}/${cliFilename}`, "test cli"],
+ [`${prefix}/${runtimeFilename}`, "test runtime wrapper"],
[
`${prefix}/platform.properties`,
`classifier=${classifier}\nversion=${nativeVersion}\n`,
diff --git a/java/copilot-native/scripts/fetch-native.mjs b/java/copilot-native/scripts/fetch-native.mjs
index 7b68f04065..4ee91b6aa6 100644
--- a/java/copilot-native/scripts/fetch-native.mjs
+++ b/java/copilot-native/scripts/fetch-native.mjs
@@ -3,18 +3,16 @@
*--------------------------------------------------------------------------------------------*/
/**
- * Downloads the `runtime.node` native binary for a single platform classifier
- * and stages it for packaging into a classifier JAR.
+ * Downloads the native runtime artifacts for one platform classifier.
*
* Steps:
* 1. Read the pinned version and the SHA-512 `integrity` value for
* `@github/copilot-` from `nodejs/package-lock.json`.
* 2. `npm pack` that exact version into the staging directory.
* 3. Verify the downloaded tarball against the `integrity` value.
- * 4. Extract `package/prebuilds//runtime.node` to
- * `//native//runtime.node`.
- * 5. Extract `package/copilot` (or `package/copilot.exe` on Windows) to
- * `//native//copilot`.
+ * 4. Stage the hostless runtime tree, flattening the selected prebuild directory
+ * beside the package's retained top-level runtime assets.
+ * 5. Write an inventory consumed by the SDK's generic classpath extractor.
* 6. Write `//native//platform.properties`.
*
* Usage: node fetch-native.mjs
@@ -25,6 +23,28 @@ import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
+const excludedTopLevel = 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',
+]);
+
const [repoRoot, stagingDir, classifier] = process.argv.slice(2);
if (!repoRoot || !stagingDir || !classifier) {
@@ -52,34 +72,35 @@ const outDir = path.join(stagingDir, classifier);
const resourceDir = path.join(outDir, 'native', classifier);
const runtimePath = path.join(resourceDir, 'runtime.node');
const isWindows = classifier.startsWith('win32');
-const cliTarballMember = isWindows ? 'package/copilot.exe' : 'package/copilot';
-const cliFilename = isWindows ? 'copilot.exe' : 'copilot';
-const cliPath = path.join(resourceDir, cliFilename);
+const wrapperFilename = isWindows ? 'copilot-runtime.exe' : 'copilot-runtime';
+const wrapperPath = path.join(resourceDir, wrapperFilename);
+const inventoryPath = path.join(resourceDir, 'runtime-assets.list');
const platformPropertiesPath = path.join(resourceDir, 'platform.properties');
const expectedPlatformProperties = `classifier=${classifier}\nversion=${version}\n`;
+const stagingSchema = 'hostless-runtime-v2';
const stampPath = path.join(outDir, '.version');
// Idempotence: skip the download only when every required staged artifact
// matches the package identity recorded in the stamp.
if (
fs.existsSync(runtimePath) &&
- fs.existsSync(cliPath) &&
+ fs.existsSync(wrapperPath) &&
+ fs.existsSync(inventoryPath) &&
fs.existsSync(platformPropertiesPath) &&
fs.existsSync(stampPath)
) {
const stampLines = fs.readFileSync(stampPath, 'utf8').trim().split('\n');
- const stampVersion = stampLines[0] || '';
- const stampIntegrity = stampLines[1] || '';
- const stampRuntimeDigest = stampLines[2] || '';
- const stampCliDigest = stampLines[3] || '';
- const currentRuntimeDigest = digestFile(runtimePath);
- const currentCliDigest = digestFile(cliPath);
+ const stampSchema = stampLines[0] || '';
+ const stampVersion = stampLines[1] || '';
+ const stampIntegrity = stampLines[2] || '';
+ const stampTreeDigest = stampLines[3] || '';
+ const currentTreeDigest = digestTree(resourceDir);
const currentPlatformProperties = fs.readFileSync(platformPropertiesPath, 'utf8');
if (
+ stampSchema === stagingSchema &&
stampVersion === version &&
stampIntegrity === integrity &&
- stampRuntimeDigest === currentRuntimeDigest &&
- stampCliDigest === currentCliDigest &&
+ stampTreeDigest === currentTreeDigest &&
currentPlatformProperties === expectedPlatformProperties
) {
console.log(`${packageName}@${version} already staged at ${runtimePath}`);
@@ -107,28 +128,99 @@ if (actual !== integrity) {
}
console.log(`Integrity verified (${integrity.slice(0, 20)}...).`);
-const memberPath = `package/prebuilds/${classifier}/runtime.node`;
-execFileSync('tar', ['-xzf', tarballPath, '-C', outDir, memberPath], { stdio: 'inherit' });
-fs.renameSync(path.join(outDir, memberPath), runtimePath);
-
-// Extract the copilot CLI executable (necessary-and-sufficient runtime artifact invariant:
-// host_start needs both runtime.node and the copilot CLI from the same package version).
-execFileSync('tar', ['-xzf', tarballPath, '-C', outDir, cliTarballMember], { stdio: 'inherit' });
-fs.renameSync(path.join(outDir, cliTarballMember), cliPath);
-if (!isWindows) {
- fs.chmodSync(cliPath, 0o755);
+const inventory = [];
+const members = execFileSync('tar', ['-tzf', tarballPath], { encoding: 'utf8' })
+ .split(/\r?\n/)
+ .filter(Boolean);
+for (const member of members) {
+ const destinationRelative = hostlessRuntimePath(member, classifier);
+ if (destinationRelative === null) {
+ continue;
+ }
+ const listing = execFileSync('tar', ['-tvzf', tarballPath, member], { encoding: 'utf8' }).trim();
+ if (listing.startsWith('d')) {
+ continue;
+ }
+ if (!listing.startsWith('-')) {
+ throw new Error(`Unsupported runtime package entry: ${member}`);
+ }
+ const content = execFileSync('tar', ['-xOzf', tarballPath, member], {
+ encoding: null,
+ maxBuffer: 512 * 1024 * 1024,
+ });
+ const destination = path.resolve(resourceDir, destinationRelative);
+ const resourceRoot = `${path.resolve(resourceDir)}${path.sep}`;
+ if (!destination.startsWith(resourceRoot)) {
+ throw new Error(`Runtime package entry escapes staging directory: ${member}`);
+ }
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
+ fs.writeFileSync(destination, content);
+ const mode = listing.slice(0, 10).includes('x') ? 0o755 : 0o644;
+ fs.chmodSync(destination, mode);
+ inventory.push(`${mode.toString(8)}\t${destinationRelative.split(path.sep).join('/')}`);
}
+inventory.sort();
+fs.writeFileSync(inventoryPath, `${inventory.join('\n')}\n`);
-fs.rmSync(path.join(outDir, 'package'), { recursive: true, force: true });
fs.rmSync(tarballPath, { force: true });
+if (!fs.existsSync(runtimePath) || !fs.existsSync(wrapperPath)) {
+ throw new Error(`Package ${packageName}@${version} is missing the runtime wrapper pair`);
+}
fs.writeFileSync(platformPropertiesPath, expectedPlatformProperties);
-const runtimeDigest = digestFile(runtimePath);
-const cliDigest = digestFile(cliPath);
-fs.writeFileSync(stampPath, `${version}\n${integrity}\n${runtimeDigest}\n${cliDigest}\n`);
+const treeDigest = digestTree(resourceDir);
+fs.writeFileSync(stampPath, `${stagingSchema}\n${version}\n${integrity}\n${treeDigest}\n`);
console.log(`Staged ${runtimePath}`);
-function digestFile(filePath) {
- return `sha512-${createHash('sha512').update(fs.readFileSync(filePath)).digest('base64')}`;
+function hostlessRuntimePath(packageRelative, platform) {
+ if (packageRelative.includes('\\')) {
+ return null;
+ }
+ const parts = packageRelative.split('/');
+ if (parts[0] !== 'package' || parts.some((part) => !part || part === '..')) {
+ return null;
+ }
+ parts.shift();
+ const topLevel = parts[0];
+ const fileName = parts.at(-1);
+ if (
+ excludedTopLevel.has(topLevel) ||
+ (topLevel.startsWith('tree-sitter') && topLevel.endsWith('.wasm')) ||
+ (topLevel.startsWith('voice-') && topLevel.endsWith('.js')) ||
+ fileName === 'cli-native.node' ||
+ parts.includes('mediaremote-adapter') ||
+ fileName.startsWith('copilot-runtime-bin')
+ ) {
+ return null;
+ }
+ if (topLevel === 'prebuilds') {
+ if (parts[1] !== platform || parts.length < 3) {
+ return null;
+ }
+ return path.join(...parts.slice(2));
+ }
+ return path.join(...parts);
+}
+
+function walkFiles(directory) {
+ const files = [];
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
+ const entryPath = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ files.push(...walkFiles(entryPath));
+ } else if (entry.isFile()) {
+ files.push(entryPath);
+ }
+ }
+ return files;
+}
+
+function digestTree(directory) {
+ const hash = createHash('sha512');
+ for (const file of walkFiles(directory).sort()) {
+ const relative = path.relative(directory, file).split(path.sep).join('/');
+ hash.update(relative).update('\0').update(fs.readFileSync(file)).update('\0');
+ }
+ return `sha512-${hash.digest('base64')}`;
}
diff --git a/java/copilot-native/scripts/fetch-native.test.mjs b/java/copilot-native/scripts/fetch-native.test.mjs
index 39f0c59486..ceea52029d 100644
--- a/java/copilot-native/scripts/fetch-native.test.mjs
+++ b/java/copilot-native/scripts/fetch-native.test.mjs
@@ -7,29 +7,41 @@ import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
-import { spawnSync } from 'node:child_process';
+import { execFileSync, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
const version = '1.0.79';
const integrity = 'sha512-test-integrity';
const runtimeContent = 'runtime content';
-const cliContent = 'cli content';
+const wrapperContent = 'wrapper content';
+const stagingSchema = 'hostless-runtime-v2';
const scriptPath = fileURLToPath(new URL('./fetch-native.mjs', import.meta.url));
for (const classifier of ['linux-x64', 'win32-x64', 'darwin-arm64']) {
- test(`${classifier}: missing CLI does not use incremental fast path`, (t) => {
+ test(`${classifier}: complete hostless artifacts use incremental fast path without a CLI`, (t) => {
const fixture = createFixture(t, classifier);
- fs.rmSync(fixture.cliPath);
+
+ const result = runScript(fixture);
+
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /already staged/);
+ assert.equal(fs.existsSync(fixture.npmMarkerPath), false);
+ });
+
+ test(`${classifier}: missing runtime wrapper does not use incremental fast path`, (t) => {
+ const fixture = createFixture(t, classifier);
+ fs.rmSync(fixture.wrapperPath);
const result = runScript(fixture);
assertRestagingAttempted(fixture, result);
});
- test(`${classifier}: stale CLI does not use incremental fast path`, (t) => {
+ test(`${classifier}: legacy staging schema does not use incremental fast path`, (t) => {
const fixture = createFixture(t, classifier);
- fs.writeFileSync(fixture.cliPath, 'stale CLI content');
+ const stampPath = path.join(fixture.stagingDir, classifier, '.version');
+ fs.writeFileSync(stampPath, fs.readFileSync(stampPath, 'utf8').replace(stagingSchema, 'hostless-runtime-v1'));
const result = runScript(fixture);
@@ -45,6 +57,15 @@ for (const classifier of ['linux-x64', 'win32-x64', 'darwin-arm64']) {
assertRestagingAttempted(fixture, result);
});
+ test(`${classifier}: missing retained runtime asset does not use incremental fast path`, (t) => {
+ const fixture = createFixture(t, classifier);
+ fs.rmSync(fixture.ripgrepPath);
+
+ const result = runScript(fixture);
+
+ assertRestagingAttempted(fixture, result);
+ });
+
test(`${classifier}: complete matching artifacts use incremental fast path`, (t) => {
const fixture = createFixture(t, classifier);
@@ -56,6 +77,55 @@ for (const classifier of ['linux-x64', 'win32-x64', 'darwin-arm64']) {
});
}
+test('stages retained package assets and excludes CLI-only content', (t) => {
+ const classifier = 'linux-x64';
+ const fixture = createFixture(t, classifier);
+ const packageRoot = path.join(fixture.repoRoot, 'package-root', 'package');
+ fs.mkdirSync(path.join(packageRoot, 'prebuilds', classifier), { recursive: true });
+ fs.mkdirSync(path.join(packageRoot, 'ripgrep', 'bin', classifier), { recursive: true });
+ fs.mkdirSync(path.join(packageRoot, 'definitions'), { recursive: true });
+ fs.writeFileSync(path.join(packageRoot, 'copilot'), 'excluded');
+ fs.writeFileSync(path.join(packageRoot, 'prebuilds', classifier, 'runtime.node'), runtimeContent);
+ fs.writeFileSync(path.join(packageRoot, 'prebuilds', classifier, 'copilot-runtime'), wrapperContent);
+ fs.writeFileSync(path.join(packageRoot, 'ripgrep', 'bin', classifier, 'rg'), 'ripgrep content');
+ fs.chmodSync(path.join(packageRoot, 'ripgrep', 'bin', classifier, 'rg'), 0o755);
+ fs.writeFileSync(path.join(packageRoot, 'definitions', 'future.json'), '{}');
+ fs.writeFileSync(path.join(packageRoot, 'app.js'), 'excluded');
+ fs.writeFileSync(path.join(packageRoot, 'LICENSE.md'), 'excluded');
+ fs.writeFileSync(path.join(packageRoot, 'README.md'), 'excluded');
+ const tarball = path.join(fixture.repoRoot, 'fixture.tgz');
+ execFileSync('tar', ['-czf', tarball, '-C', path.dirname(packageRoot), 'package']);
+ const packageIntegrity = digest(fs.readFileSync(tarball));
+ fs.writeFileSync(
+ path.join(fixture.repoRoot, 'nodejs', 'package-lock.json'),
+ JSON.stringify({
+ packages: {
+ [`node_modules/@github/copilot-${classifier}`]: { version, integrity: packageIntegrity },
+ },
+ }),
+ );
+ const fakeNpmPath = path.join(fixture.fakeBinDir, process.platform === 'win32' ? 'npm.cmd' : 'npm');
+ const fakeNpm =
+ process.platform === 'win32'
+ ? '@copy "%FETCH_NATIVE_TARBALL%" "%4\\fixture.tgz" >nul\r\n@echo fixture.tgz\r\n'
+ : '#!/bin/sh\ncp "$FETCH_NATIVE_TARBALL" "$4/fixture.tgz"\nprintf "fixture.tgz\\n"\n';
+ fs.writeFileSync(fakeNpmPath, fakeNpm);
+ fs.chmodSync(fakeNpmPath, 0o755);
+ fs.rmSync(path.join(fixture.stagingDir, classifier), { recursive: true, force: true });
+
+ const result = runScript(fixture, { FETCH_NATIVE_TARBALL: tarball });
+
+ assert.equal(result.status, 0, result.stderr);
+ const resourceDir = path.join(fixture.stagingDir, classifier, 'native', classifier);
+ assert.equal(fs.readFileSync(path.join(resourceDir, 'ripgrep', 'bin', classifier, 'rg'), 'utf8'), 'ripgrep content');
+ assert.equal(fs.readFileSync(path.join(resourceDir, 'definitions', 'future.json'), 'utf8'), '{}');
+ assert.equal(fs.existsSync(path.join(resourceDir, 'app.js')), false);
+ assert.equal(fs.existsSync(path.join(resourceDir, 'copilot')), false);
+ assert.equal(fs.existsSync(path.join(resourceDir, 'LICENSE.md')), false);
+ assert.equal(fs.existsSync(path.join(resourceDir, 'README.md')), false);
+ assert.match(fs.readFileSync(path.join(resourceDir, 'runtime-assets.list'), 'utf8'), /ripgrep\/bin\/linux-x64\/rg/);
+});
+
function createFixture(t, classifier) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-native-test-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
@@ -79,14 +149,25 @@ function createFixture(t, classifier) {
);
const runtimePath = path.join(resourceDir, 'runtime.node');
- const cliPath = path.join(resourceDir, classifier.startsWith('win32') ? 'copilot.exe' : 'copilot');
+ const wrapperPath = path.join(
+ resourceDir,
+ classifier.startsWith('win32') ? 'copilot-runtime.exe' : 'copilot-runtime',
+ );
const platformPropertiesPath = path.join(resourceDir, 'platform.properties');
+ const ripgrepPath = path.join(resourceDir, 'ripgrep', 'bin', classifier, 'rg');
+ const inventoryPath = path.join(resourceDir, 'runtime-assets.list');
+ fs.mkdirSync(path.dirname(ripgrepPath), { recursive: true });
fs.writeFileSync(runtimePath, runtimeContent);
- fs.writeFileSync(cliPath, cliContent);
+ fs.writeFileSync(wrapperPath, wrapperContent);
+ fs.writeFileSync(ripgrepPath, 'ripgrep content');
+ fs.writeFileSync(
+ inventoryPath,
+ `644\truntime.node\n755\tcopilot-runtime\n755\tripgrep/bin/${classifier}/rg\n`,
+ );
fs.writeFileSync(platformPropertiesPath, `classifier=${classifier}\nversion=${version}\n`);
fs.writeFileSync(
path.join(stagingDir, classifier, '.version'),
- `${version}\n${integrity}\n${digest(runtimeContent)}\n${digest(cliContent)}\n`,
+ `${stagingSchema}\n${version}\n${integrity}\n${digestTree(resourceDir)}\n`,
);
const fakeNpmPath = path.join(fakeBinDir, process.platform === 'win32' ? 'npm.cmd' : 'npm');
@@ -104,18 +185,20 @@ function createFixture(t, classifier) {
fakeBinDir,
npmMarkerPath,
runtimePath,
- cliPath,
+ wrapperPath,
+ ripgrepPath,
platformPropertiesPath,
};
}
-function runScript(fixture) {
+function runScript(fixture, extraEnv = {}) {
return spawnSync(process.execPath, [scriptPath, fixture.repoRoot, fixture.stagingDir, fixture.classifier], {
encoding: 'utf8',
env: {
...process.env,
PATH: `${fixture.fakeBinDir}${path.delimiter}${process.env.PATH}`,
FETCH_NATIVE_NPM_MARKER: fixture.npmMarkerPath,
+ ...extraEnv,
},
});
}
@@ -125,6 +208,29 @@ function assertRestagingAttempted(fixture, result) {
assert.equal(fs.readFileSync(fixture.npmMarkerPath, 'utf8').trim(), 'invoked');
}
+function digestTree(directory) {
+ const hash = createHash('sha512');
+ for (const file of walkFiles(directory).sort()) {
+ const relative = path.relative(directory, file).split(path.sep).join('/');
+ hash.update(relative).update('\0').update(fs.readFileSync(file)).update('\0');
+ }
+
+ return `sha512-${hash.digest('base64')}`;
+}
+
function digest(content) {
return `sha512-${createHash('sha512').update(content).digest('base64')}`;
}
+
+function walkFiles(directory) {
+ const files = [];
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
+ const entryPath = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ files.push(...walkFiles(entryPath));
+ } else {
+ files.push(entryPath);
+ }
+ }
+ return files;
+}
diff --git a/java/copilot-native/scripts/validate-native-artifact.mjs b/java/copilot-native/scripts/validate-native-artifact.mjs
index dda86a917a..9af4ffd772 100644
--- a/java/copilot-native/scripts/validate-native-artifact.mjs
+++ b/java/copilot-native/scripts/validate-native-artifact.mjs
@@ -27,13 +27,13 @@ export function validateNativeClassifierJar({
}
const archive = readJar(jarPath);
- const cliFilename = classifier.startsWith("win32")
- ? "copilot.exe"
- : "copilot";
+ const runtimeFilename = classifier.startsWith("win32")
+ ? "copilot-runtime.exe"
+ : "copilot-runtime";
const resourcePrefix = `native/${classifier}/`;
const requiredEntries = [
`${resourcePrefix}runtime.node`,
- `${resourcePrefix}${cliFilename}`,
+ `${resourcePrefix}${runtimeFilename}`,
`${resourcePrefix}platform.properties`,
];
diff --git a/java/copilot-native/scripts/validate-native-artifact.test.mjs b/java/copilot-native/scripts/validate-native-artifact.test.mjs
index af4017ca14..e332c09269 100644
--- a/java/copilot-native/scripts/validate-native-artifact.test.mjs
+++ b/java/copilot-native/scripts/validate-native-artifact.test.mjs
@@ -124,7 +124,7 @@ test("rejects missing native resources", (t) => {
expectedFilename: artifactName,
repoRoot: fixture.repoRoot,
}),
- /copilot\.exe/,
+ /copilot-runtime\.exe/,
);
});
@@ -132,7 +132,7 @@ test("rejects incorrect pinned package metadata", (t) => {
const fixture = createFixture(t);
writeStoredZip(fixture.jarPath, [
["native/win32-x64/runtime.node", "runtime"],
- ["native/win32-x64/copilot.exe", "cli"],
+ ["native/win32-x64/copilot-runtime.exe", "runtime wrapper"],
[
"native/win32-x64/platform.properties",
"classifier=win32-x64\nversion=0.0.1\n",
@@ -160,7 +160,7 @@ test("rejects Linux resources in a Windows classifier", (t) => {
});
writeStoredZip(fixture.jarPath, [
["native/win32-x64/runtime.node", "runtime"],
- ["native/win32-x64/copilot.exe", "cli"],
+ ["native/win32-x64/copilot-runtime.exe", "runtime wrapper"],
[
"native/win32-x64/platform.properties",
"classifier=win32-x64\nversion=9.8.7\n",
@@ -188,7 +188,7 @@ test("rejects Windows resources in a Linux classifier", (t) => {
const linuxJarPath = path.join(fixture.root, linuxArtifactName);
writeStoredZip(linuxJarPath, [
["native/linux-x64/runtime.node", "runtime"],
- ["native/linux-x64/copilot", "cli"],
+ ["native/linux-x64/copilot-runtime", "runtime wrapper"],
[
"native/linux-x64/platform.properties",
"classifier=linux-x64\nversion=9.8.6\n",
@@ -388,7 +388,7 @@ test("local publication validation rejects cross-classifier contamination", (t)
path.join(publicationDirectory, `${artifactId}-${version}-linux-x64.jar`),
[
["native/linux-x64/runtime.node", "runtime"],
- ["native/linux-x64/copilot", "cli"],
+ ["native/linux-x64/copilot-runtime", "runtime wrapper"],
[
"native/linux-x64/platform.properties",
"classifier=linux-x64\nversion=9.8.6\n",
diff --git a/java/sdk/src/main/java/com/github/copilot/CliServerManager.java b/java/sdk/src/main/java/com/github/copilot/CliServerManager.java
index acc683a720..3087787afd 100644
--- a/java/sdk/src/main/java/com/github/copilot/CliServerManager.java
+++ b/java/sdk/src/main/java/com/github/copilot/CliServerManager.java
@@ -11,6 +11,7 @@
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -19,6 +20,7 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import com.github.copilot.ffi.NativeRuntimeLoader;
import com.github.copilot.rpc.CopilotClientOptions;
/**
@@ -64,7 +66,7 @@ void setConnectionToken(String connectionToken) {
ProcessInfo startCliServer() throws IOException, InterruptedException {
clearStderrBuffer();
- String cliPath = options.getCliPath() != null ? options.getCliPath() : "copilot";
+ RuntimeLaunch launch = resolveCliLaunch();
var args = new ArrayList();
if (options.getCliArgs() != null) {
@@ -106,7 +108,7 @@ ProcessInfo startCliServer() throws IOException, InterruptedException {
args.add("--remote");
}
- List command = resolveCliCommand(cliPath, args);
+ List command = resolveCliCommand(launch.executable(), args);
var pb = new ProcessBuilder(command);
pb.redirectErrorStream(false);
@@ -122,51 +124,7 @@ ProcessInfo startCliServer() throws IOException, InterruptedException {
pb.directory(new File(options.getCwd()));
}
- if (options.getEnvironment() != null) {
- pb.environment().clear();
- pb.environment().putAll(options.getEnvironment());
- }
- pb.environment().remove("NODE_DEBUG");
-
- // Set auth token in environment if provided
- if (options.getGitHubToken() != null && !options.getGitHubToken().isEmpty()) {
- pb.environment().put("COPILOT_SDK_AUTH_TOKEN", options.getGitHubToken());
- }
-
- // Set Copilot home directory if configured
- if (options.getCopilotHome() != null && !options.getCopilotHome().isEmpty()) {
- pb.environment().put("COPILOT_HOME", options.getCopilotHome());
- }
-
- // Set connection token for TCP mode
- if (connectionToken != null && !connectionToken.isEmpty()) {
- pb.environment().put("COPILOT_CONNECTION_TOKEN", connectionToken);
- }
-
- // Set telemetry environment variables if configured
- if (options.getTelemetry() != null) {
- var telemetry = options.getTelemetry();
- pb.environment().put("COPILOT_OTEL_ENABLED", "true");
- if (telemetry.getOtlpEndpoint() != null) {
- pb.environment().put("OTEL_EXPORTER_OTLP_ENDPOINT", telemetry.getOtlpEndpoint());
- }
- if (telemetry.getOtlpProtocol() != null) {
- pb.environment().put("OTEL_EXPORTER_OTLP_PROTOCOL", telemetry.getOtlpProtocol());
- }
- if (telemetry.getFilePath() != null) {
- pb.environment().put("COPILOT_OTEL_FILE_EXPORTER_PATH", telemetry.getFilePath());
- }
- if (telemetry.getExporterType() != null) {
- pb.environment().put("COPILOT_OTEL_EXPORTER_TYPE", telemetry.getExporterType());
- }
- if (telemetry.getSourceName() != null) {
- pb.environment().put("COPILOT_OTEL_SOURCE_NAME", telemetry.getSourceName());
- }
- if (telemetry.getCaptureContent().isPresent()) {
- pb.environment().put("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT",
- telemetry.getCaptureContent().get() ? "true" : "false");
- }
- }
+ configureProcessEnvironment(pb);
Process process = pb.start();
@@ -310,6 +268,63 @@ private List resolveCliCommand(String cliPath, List args) {
return result;
}
+ void configureProcessEnvironment(ProcessBuilder pb) {
+ if (options.getEnvironment() != null) {
+ pb.environment().clear();
+ pb.environment().putAll(options.getEnvironment());
+ }
+ pb.environment().remove("NODE_DEBUG");
+
+ // Set auth token in environment if provided
+ if (options.getGitHubToken() != null && !options.getGitHubToken().isEmpty()) {
+ pb.environment().put("COPILOT_SDK_AUTH_TOKEN", options.getGitHubToken());
+ }
+
+ // Set Copilot home directory if configured
+ if (options.getCopilotHome() != null && !options.getCopilotHome().isEmpty()) {
+ pb.environment().put("COPILOT_HOME", options.getCopilotHome());
+ }
+
+ // Set connection token for TCP mode
+ if (connectionToken != null && !connectionToken.isEmpty()) {
+ pb.environment().put("COPILOT_CONNECTION_TOKEN", connectionToken);
+ }
+
+ // Set telemetry environment variables if configured
+ if (options.getTelemetry() != null) {
+ var telemetry = options.getTelemetry();
+ pb.environment().put("COPILOT_OTEL_ENABLED", "true");
+ if (telemetry.getOtlpEndpoint() != null) {
+ pb.environment().put("OTEL_EXPORTER_OTLP_ENDPOINT", telemetry.getOtlpEndpoint());
+ }
+ if (telemetry.getOtlpProtocol() != null) {
+ pb.environment().put("OTEL_EXPORTER_OTLP_PROTOCOL", telemetry.getOtlpProtocol());
+ }
+ if (telemetry.getFilePath() != null) {
+ pb.environment().put("COPILOT_OTEL_FILE_EXPORTER_PATH", telemetry.getFilePath());
+ }
+ if (telemetry.getExporterType() != null) {
+ pb.environment().put("COPILOT_OTEL_EXPORTER_TYPE", telemetry.getExporterType());
+ }
+ if (telemetry.getSourceName() != null) {
+ pb.environment().put("COPILOT_OTEL_SOURCE_NAME", telemetry.getSourceName());
+ }
+ if (telemetry.getCaptureContent().isPresent()) {
+ pb.environment().put("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT",
+ telemetry.getCaptureContent().get() ? "true" : "false");
+ }
+ }
+ }
+
+ RuntimeLaunch resolveCliLaunch() throws IOException {
+ if (options.getCliPath() != null) {
+ return new RuntimeLaunch(options.getCliPath());
+ }
+
+ Path wrapper = NativeRuntimeLoader.resolveRuntimeWrapper();
+ return new RuntimeLaunch(wrapper.toString());
+ }
+
static URI parseCliUrl(String url) {
// If it's just a port number, treat as localhost
try {
@@ -337,4 +352,7 @@ static URI parseCliUrl(String url) {
*/
record ProcessInfo(Process process, Integer port) {
}
+
+ record RuntimeLaunch(String executable) {
+ }
}
diff --git a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java
index ea2b0b67dd..661fe6dab4 100644
--- a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java
+++ b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java
@@ -8,6 +8,7 @@
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
+import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
@@ -479,7 +480,8 @@ void setInProcessTransportFactory(InProcessTransportFactory factory) {
private static InProcessTransport openInProcessTransport(CopilotClientOptions options) throws IOException {
FfiRuntimeHost host = new FfiRuntimeHost();
try {
- host.start(resolveInProcessEntrypoint(), options);
+ Path explicitEntrypoint = NativeRuntimeLoader.resolveConfiguredEntrypoint();
+ host.start(explicitEntrypoint == null ? null : explicitEntrypoint.toString(), options);
} catch (RuntimeException | Error e) {
host.close();
throw e;
@@ -487,15 +489,6 @@ private static InProcessTransport openInProcessTransport(CopilotClientOptions op
return new InProcessTransport(host.getReceiveStream(), host.getSendStream(), host);
}
- /**
- * Resolves the runtime entrypoint handed to the in-process host. The copilot
- * CLI executable is resolved from the same bundled location as
- * {@code runtime.node} — no environment variables or PATH search.
- */
- private static String resolveInProcessEntrypoint() throws IOException {
- return NativeRuntimeLoader.resolveEntrypoint().toString();
- }
-
private static void closeRuntimeHost(AutoCloseable host) {
try {
host.close();
diff --git a/java/sdk/src/main/java/com/github/copilot/ffi/FfiRuntimeHost.java b/java/sdk/src/main/java/com/github/copilot/ffi/FfiRuntimeHost.java
index 5e7d2d461a..cb5bba1af1 100644
--- a/java/sdk/src/main/java/com/github/copilot/ffi/FfiRuntimeHost.java
+++ b/java/sdk/src/main/java/com/github/copilot/ffi/FfiRuntimeHost.java
@@ -88,13 +88,13 @@ private static Path resolveLibraryPath() throws IOException {
* Starts the in-process runtime and opens a connection.
*
* @param entrypointPath
- * runtime entrypoint path passed in {@code argv_json}
+ * optional explicit legacy CLI entrypoint passed in
+ * {@code argv_json}
* @param options
* client options used to construct {@code argv_json} and
* {@code env_json}
*/
public void start(String entrypointPath, CopilotClientOptions options) {
- Objects.requireNonNull(entrypointPath, "entrypointPath must not be null");
Objects.requireNonNull(options, "options must not be null");
if (disposed.get()) {
throw new IllegalStateException("FfiRuntimeHost is already closed.");
@@ -108,8 +108,7 @@ public void start(String entrypointPath, CopilotClientOptions options) {
int hostHandle = runHostStartOnBlockingThread(argvJson, envJson);
if (hostHandle == 0) {
String lib = libraryPath != null ? libraryPath : "";
- throw new IllegalStateException(
- "copilot_runtime_host_start failed (library '" + lib + "', entrypoint '" + entrypointPath + "').");
+ throw new IllegalStateException("copilot_runtime_host_start failed (library '" + lib + "').");
}
// Hold operationLock while publishing handles to serialize with close().
@@ -270,12 +269,14 @@ private int runHostStartOnBlockingThread(byte[] argvJson, byte[] envJson) {
private static byte[] buildArgvJson(String entrypointPath, CopilotClientOptions options) {
List 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..c7705fbb46 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();
@@ -158,9 +154,10 @@ void resolveEntrypointUsesConfiguredCliWhenRuntimeIsInPrebuilds(@TempDir Path te
}
@Test
- void resolveFromCliPathReturnsAbsolutePathForRelativeCliPath() throws Exception {
+ void resolveFromCliPathReturnsAbsolutePathForRelativeCliPath(@TempDir Path tempDir) throws Exception {
Path workingDirectory = Path.of("").toAbsolutePath();
- Path fakeCliDir = Files.createTempDirectory(Path.of("target").toAbsolutePath(), "relative-cli-test-");
+ Path fakeCliDir = tempDir.resolve("cli-dir");
+ Files.createDirectories(fakeCliDir);
Path fakeCliPath = fakeCliDir.resolve("copilot");
Files.createFile(fakeCliPath);
Path runtimeNode = fakeCliDir.resolve(NativeRuntimeLoader.RUNTIME_FILENAME);
@@ -173,6 +170,7 @@ void resolveFromCliPathReturnsAbsolutePathForRelativeCliPath() throws Exception
@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 +195,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 +210,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 +230,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 +242,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 +261,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 +275,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 +287,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 +301,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 +317,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 +338,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 +356,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 +378,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 +410,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 +426,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 +447,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 +475,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 +513,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 +524,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 +582,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 +600,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 +626,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 +634,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..d9c4bf3e79 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,18 @@ 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);
+ 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 +2801,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 +2844,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 +2867,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..2bb83f195e 100644
--- a/nodejs/test/e2e/client.e2e.test.ts
+++ b/nodejs/test/e2e/client.e2e.test.ts
@@ -206,7 +206,7 @@ describe("Client", () => {
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");
+ 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