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