Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/go-sdk-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@ permissions:

jobs:
test:
name: "Go SDK Tests"
name: "Go SDK Tests (${{ matrix.os }}, ${{ matrix.transport }})"
if: github.event.repository.fork == false
env:
POWERSHELL_UPDATECHECK: Off
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
transport: ["default", "inprocess"]
runs-on: ${{ matrix.os }}
defaults:
run:
Expand Down Expand Up @@ -78,6 +79,12 @@ jobs:
if: runner.os == 'Windows'
run: pwsh.exe -Command "Write-Host 'PowerShell ready'"

- name: Select inprocess transport
if: matrix.transport == 'inprocess'
run: |
echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV"
echo "GOFLAGS=-tags=copilot_inprocess" >> "$GITHUB_ENV"

- name: Run Go SDK tests
env:
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}
Expand Down
67 changes: 55 additions & 12 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -349,16 +349,49 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
{
if (_connection is InProcessRuntimeConnection)
{
// In-process FFI hosting: load the Rust cdylib and let it spawn
// the CLI worker, instead of the SDK launching a CLI child process.
// The worker reads its configuration (telemetry export, etc.) from
// the environment passed here, so apply the same telemetry-derived
// vars the child-process path sets on its startInfo.Environment.
var ffiEnvironment = _options.Environment?.ToDictionary(kvp => kvp.Key, kvp => (string?)kvp.Value)
?? new Dictionary<string, string?>();
ApplyTelemetryEnvironment(ffiEnvironment, _options.Telemetry);
var resolvedFfiEnvironment = ffiEnvironment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value!);
var ffiHost = FfiRuntimeHost.Create(ResolveCliPathForFfi(), GetNapiPrebuildsFolderOrThrow(), resolvedFfiEnvironment, _logger);
var ffiEnvironment = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(_options.GitHubToken))
{
ffiEnvironment["COPILOT_SDK_AUTH_TOKEN"] = _options.GitHubToken!;
}
if (!string.IsNullOrEmpty(_options.BaseDirectory))
{
ffiEnvironment["COPILOT_HOME"] = _options.BaseDirectory!;
}
if (_options.Mode == CopilotClientMode.Empty)
{
ffiEnvironment["COPILOT_DISABLE_KEYTAR"] = "1";
}

var ffiArgs = new List<string>();
if (_options.LogLevel is { } logLevel && !string.IsNullOrEmpty(logLevel.Value))
{
ffiArgs.AddRange(["--log-level", logLevel.Value]);
}
if (!string.IsNullOrEmpty(_options.GitHubToken))
{
ffiArgs.AddRange(["--auth-token-env", "COPILOT_SDK_AUTH_TOKEN"]);
}
var useLoggedInUser = _options.UseLoggedInUser ?? string.IsNullOrEmpty(_options.GitHubToken);
if (!useLoggedInUser)
{
ffiArgs.Add("--no-auto-login");
}
if (_options.SessionIdleTimeoutSeconds is > 0)
{
ffiArgs.AddRange(["--session-idle-timeout", _options.SessionIdleTimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture)]);
}
if (_options.EnableRemoteSessions)
{
ffiArgs.Add("--remote");
}

var ffiHost = FfiRuntimeHost.Create(
ResolveCliPathForFfi(),
GetNapiPrebuildsFolderOrThrow(),
ffiEnvironment,
ffiArgs,
_logger);
_ffiHost = ffiHost;
await ffiHost.StartAsync(ct);
connection = await ConnectToServerAsync(null, null, null, null, ct, ffiHost);
Expand Down Expand Up @@ -2214,7 +2247,12 @@ private static void ApplyTelemetryEnvironment(IDictionary<string, string?> envir
{
string os;
if (OperatingSystem.IsWindows()) os = "win";
else if (OperatingSystem.IsLinux()) os = "linux";
else if (OperatingSystem.IsLinux())
{
os = RuntimeInformation.RuntimeIdentifier.StartsWith("linux-musl-", StringComparison.Ordinal)
? "linux-musl"
: "linux";
}
else if (OperatingSystem.IsMacOS()) os = "osx";
else return null;

Expand Down Expand Up @@ -2261,7 +2299,12 @@ private string ResolveCliPathForFfi()
{
string platform;
if (OperatingSystem.IsWindows()) platform = "win32";
else if (OperatingSystem.IsLinux()) platform = "linux";
else if (OperatingSystem.IsLinux())
{
platform = RuntimeInformation.RuntimeIdentifier.StartsWith("linux-musl-", StringComparison.Ordinal)
? "linuxmusl"
: "linux";
}
else if (OperatingSystem.IsMacOS()) platform = "darwin";
else return null;

Expand Down
16 changes: 11 additions & 5 deletions dotnet/src/FfiRuntimeHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ internal sealed partial class FfiRuntimeHost : IDisposable
private readonly string _cliEntrypoint;
private readonly string _libraryPath;
private readonly IReadOnlyDictionary<string, string>? _environment;
private readonly IReadOnlyList<string> _args;

private readonly CallbackReceiveStream _receiveStream = new();
private CallbackSendStream? _sendStream;
Expand All @@ -52,11 +53,12 @@ internal sealed partial class FfiRuntimeHost : IDisposable
private uint _connectionId;
private bool _disposed;

private FfiRuntimeHost(string libraryPath, string cliEntrypoint, IReadOnlyDictionary<string, string>? environment, ILogger logger)
private FfiRuntimeHost(string libraryPath, string cliEntrypoint, IReadOnlyDictionary<string, string>? environment, IReadOnlyList<string> args, ILogger logger)
{
_libraryPath = libraryPath;
_cliEntrypoint = cliEntrypoint;
_environment = environment;
_args = args;
_logger = logger;
}

Expand All @@ -79,7 +81,7 @@ private FfiRuntimeHost(string libraryPath, string cliEntrypoint, IReadOnlyDictio
/// <paramref name="prebuildsFolder"/> is the napi-rs
/// <c>&lt;node-platform&gt;-&lt;arch&gt;</c> folder name (e.g. <c>win32-x64</c>).
/// </summary>
public static FfiRuntimeHost Create(string cliEntrypoint, string prebuildsFolder, IReadOnlyDictionary<string, string>? environment, ILogger logger)
public static FfiRuntimeHost Create(string cliEntrypoint, string prebuildsFolder, IReadOnlyDictionary<string, string>? environment, IReadOnlyList<string> args, ILogger logger)
{
var fullEntrypoint = Path.GetFullPath(cliEntrypoint);
var distDir = Path.GetDirectoryName(fullEntrypoint)
Expand All @@ -96,7 +98,7 @@ public static FfiRuntimeHost Create(string cliEntrypoint, string prebuildsFolder
$"FFI runtime library not found. Looked for '{flatLibraryPath}' and '{prebuildsLibraryPath}'.");

PrepareNativeLibrary(libraryPath);
return new FfiRuntimeHost(libraryPath, fullEntrypoint, environment, logger);
return new FfiRuntimeHost(libraryPath, fullEntrypoint, environment, args, logger);
}

/// <summary>
Expand All @@ -122,7 +124,7 @@ public async Task StartAsync(CancellationToken cancellationToken)
// perform the blocking FFI handshake on a background thread.
await Task.Run(() =>
{
var argvJson = BuildArgvJson(_cliEntrypoint);
var argvJson = BuildArgvJson(_cliEntrypoint, _args);
var envJson = BuildEnvJson(_environment);

_serverId = NativeHostStart(argvJson, envJson);
Expand Down Expand Up @@ -152,7 +154,7 @@ await Task.Run(() =>
}
}

private static byte[] BuildArgvJson(string cliEntrypoint)
private static byte[] BuildArgvJson(string cliEntrypoint, IReadOnlyList<string> 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.
Expand All @@ -170,6 +172,10 @@ private static byte[] BuildArgvJson(string cliEntrypoint)
// 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);
}
writer.WriteEndArray();
}
return stream.ToArray();
Expand Down
5 changes: 4 additions & 1 deletion dotnet/src/build/GitHub.Copilot.SDK.targets
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<_CopilotOs Condition="'$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.StartsWith('win'))">win</_CopilotOs>
<_CopilotOs Condition="'$(_CopilotOs)' == '' And '$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.StartsWith('osx'))">osx</_CopilotOs>
<_CopilotOs Condition="'$(_CopilotOs)' == '' And '$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.StartsWith('maccatalyst'))">osx</_CopilotOs>
<_CopilotOs Condition="'$(_CopilotOs)' == '' And '$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.StartsWith('linux-musl'))">linux-musl</_CopilotOs>
<_CopilotOs Condition="'$(_CopilotOs)' == '' And '$(RuntimeIdentifier)' != ''">linux</_CopilotOs>

<!-- Determine arch: from RID suffix if set, otherwise from build host -->
Expand All @@ -22,7 +23,7 @@

<!-- Fail if we couldn't determine a portable RID from the given RuntimeIdentifier -->
<Target Name="_ValidateCopilotRid" BeforeTargets="BeforeBuild" Condition="'$(RuntimeIdentifier)' != '' And '$(_CopilotRid)' == ''">
<Error Text="Could not determine a supported portable RID from RuntimeIdentifier '$(RuntimeIdentifier)'. Supported RIDs: win-x64, win-arm64, linux-x64, linux-arm64, osx-x64, osx-arm64." />
<Error Text="Could not determine a supported portable RID from RuntimeIdentifier '$(RuntimeIdentifier)'. Supported RIDs: win-x64, win-arm64, linux-x64, linux-arm64, linux-musl-x64, linux-musl-arm64, osx-x64, osx-arm64." />
</Target>

<!-- Map RID to platform name used in npm packages -->
Expand All @@ -31,6 +32,8 @@
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'win-arm64'">win32-arm64</_CopilotPlatform>
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'linux-x64'">linux-x64</_CopilotPlatform>
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'linux-arm64'">linux-arm64</_CopilotPlatform>
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'linux-musl-x64'">linuxmusl-x64</_CopilotPlatform>
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'linux-musl-arm64'">linuxmusl-arm64</_CopilotPlatform>
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'osx-x64'">darwin-x64</_CopilotPlatform>
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'osx-arm64'">darwin-arm64</_CopilotPlatform>
<_CopilotBinary Condition="$(_CopilotRid.StartsWith('win-'))">copilot.exe</_CopilotBinary>
Expand Down
50 changes: 48 additions & 2 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,49 @@ Follow these steps to embed the CLI:

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.

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.

## In-process transport (Experimental)

> **Experimental:** the in-process API may change in a future release.

By default the SDK starts the runtime as a child process and talks JSON-RPC over stdio or TCP. The **in-process** transport instead loads a native runtime library directly into your process.

Build your application with the `copilot_inprocess` build tag:

```sh
go build -tags copilot_inprocess
```

```go
client := copilot.NewClient(&copilot.ClientOptions{
Connection: copilot.InProcessConnection{},
})
if err := client.Start(context.Background()); err != nil {
log.Fatal(err)
}
defer client.Stop()
```

Resolution and requirements:

- The application must be built with the `copilot_inprocess` build tag.
- Set `COPILOT_SDK_DEFAULT_CONNECTION=inprocess` to select the in-process
transport when `ClientOptions.Connection` is nil. An explicit connection
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.
- 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.

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

- `Env` — the host process has a single environment block. Set variables on the host process environment instead.
- `WorkingDirectory` — the runtime shares the host process's working directory. Change the process working directory before creating the client.
- `Telemetry` — per-client telemetry is lowered to native-runtime environment variables. Use a child-process transport for per-client telemetry.

Implemented with pure-Go FFI (via [purego](https://github.com/ebitengine/purego)), so `CGO_ENABLED=0` and cross-compilation are preserved; no C toolchain is required.

## API Reference

### Client
Expand Down Expand Up @@ -142,11 +185,14 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec
**ClientOptions:**

- `Connection` (RuntimeConnection): How the SDK connects to the runtime. Construct via one of:
- `StdioConnection{Path, Args}` — spawn a runtime over stdio (the default if `Connection` is nil)
- `TCPConnection{Port, ConnectionToken, Path, Args}` — spawn a runtime that listens on TCP
- `StdioConnection{Path, Args, Env}` — spawn a runtime over stdio (the default if `Connection` is nil)
- `TCPConnection{Port, ConnectionToken, Path, Args, Env}` — spawn a runtime that listens on TCP
- `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).

`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
- `BaseDirectory` (string): Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime. When empty, the runtime defaults to `~/.copilot`. Ignored with `URIConnection`. This does **not** affect where the Go SDK extracts the embedded CLI binary; use `embeddedcli.Config.Dir` for the extraction/cache location.
- `LogLevel` (string): Log level. When empty (default), the runtime uses its own default level (the SDK does not pass `--log-level`).
Expand Down
Loading
Loading