Skip to content

Commit dece20b

Browse files
Add in-process (FFI) transport to the Go SDK (#1976)
1 parent 5adb51b commit dece20b

59 files changed

Lines changed: 2809 additions & 328 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/go-sdk-tests.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,15 @@ permissions:
2929

3030
jobs:
3131
test:
32-
name: "Go SDK Tests"
32+
name: "Go SDK Tests (${{ matrix.os }}, ${{ matrix.transport }})"
3333
if: github.event.repository.fork == false
3434
env:
3535
POWERSHELL_UPDATECHECK: Off
3636
strategy:
3737
fail-fast: false
3838
matrix:
3939
os: [ubuntu-latest, macos-latest, windows-latest]
40+
transport: ["default", "inprocess"]
4041
runs-on: ${{ matrix.os }}
4142
defaults:
4243
run:
@@ -78,6 +79,12 @@ jobs:
7879
if: runner.os == 'Windows'
7980
run: pwsh.exe -Command "Write-Host 'PowerShell ready'"
8081

82+
- name: Select inprocess transport
83+
if: matrix.transport == 'inprocess'
84+
run: |
85+
echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV"
86+
echo "GOFLAGS=-tags=copilot_inprocess" >> "$GITHUB_ENV"
87+
8188
- name: Run Go SDK tests
8289
env:
8390
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}

dotnet/src/Client.cs

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -349,16 +349,49 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
349349
{
350350
if (_connection is InProcessRuntimeConnection)
351351
{
352-
// In-process FFI hosting: load the Rust cdylib and let it spawn
353-
// the CLI worker, instead of the SDK launching a CLI child process.
354-
// The worker reads its configuration (telemetry export, etc.) from
355-
// the environment passed here, so apply the same telemetry-derived
356-
// vars the child-process path sets on its startInfo.Environment.
357-
var ffiEnvironment = _options.Environment?.ToDictionary(kvp => kvp.Key, kvp => (string?)kvp.Value)
358-
?? new Dictionary<string, string?>();
359-
ApplyTelemetryEnvironment(ffiEnvironment, _options.Telemetry);
360-
var resolvedFfiEnvironment = ffiEnvironment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value!);
361-
var ffiHost = FfiRuntimeHost.Create(ResolveCliPathForFfi(), GetNapiPrebuildsFolderOrThrow(), resolvedFfiEnvironment, _logger);
352+
var ffiEnvironment = new Dictionary<string, string>();
353+
if (!string.IsNullOrEmpty(_options.GitHubToken))
354+
{
355+
ffiEnvironment["COPILOT_SDK_AUTH_TOKEN"] = _options.GitHubToken!;
356+
}
357+
if (!string.IsNullOrEmpty(_options.BaseDirectory))
358+
{
359+
ffiEnvironment["COPILOT_HOME"] = _options.BaseDirectory!;
360+
}
361+
if (_options.Mode == CopilotClientMode.Empty)
362+
{
363+
ffiEnvironment["COPILOT_DISABLE_KEYTAR"] = "1";
364+
}
365+
366+
var ffiArgs = new List<string>();
367+
if (_options.LogLevel is { } logLevel && !string.IsNullOrEmpty(logLevel.Value))
368+
{
369+
ffiArgs.AddRange(["--log-level", logLevel.Value]);
370+
}
371+
if (!string.IsNullOrEmpty(_options.GitHubToken))
372+
{
373+
ffiArgs.AddRange(["--auth-token-env", "COPILOT_SDK_AUTH_TOKEN"]);
374+
}
375+
var useLoggedInUser = _options.UseLoggedInUser ?? string.IsNullOrEmpty(_options.GitHubToken);
376+
if (!useLoggedInUser)
377+
{
378+
ffiArgs.Add("--no-auto-login");
379+
}
380+
if (_options.SessionIdleTimeoutSeconds is > 0)
381+
{
382+
ffiArgs.AddRange(["--session-idle-timeout", _options.SessionIdleTimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture)]);
383+
}
384+
if (_options.EnableRemoteSessions)
385+
{
386+
ffiArgs.Add("--remote");
387+
}
388+
389+
var ffiHost = FfiRuntimeHost.Create(
390+
ResolveCliPathForFfi(),
391+
GetNapiPrebuildsFolderOrThrow(),
392+
ffiEnvironment,
393+
ffiArgs,
394+
_logger);
362395
_ffiHost = ffiHost;
363396
await ffiHost.StartAsync(ct);
364397
connection = await ConnectToServerAsync(null, null, null, null, ct, ffiHost);
@@ -2214,7 +2247,12 @@ private static void ApplyTelemetryEnvironment(IDictionary<string, string?> envir
22142247
{
22152248
string os;
22162249
if (OperatingSystem.IsWindows()) os = "win";
2217-
else if (OperatingSystem.IsLinux()) os = "linux";
2250+
else if (OperatingSystem.IsLinux())
2251+
{
2252+
os = RuntimeInformation.RuntimeIdentifier.StartsWith("linux-musl-", StringComparison.Ordinal)
2253+
? "linux-musl"
2254+
: "linux";
2255+
}
22182256
else if (OperatingSystem.IsMacOS()) os = "osx";
22192257
else return null;
22202258

@@ -2261,7 +2299,12 @@ private string ResolveCliPathForFfi()
22612299
{
22622300
string platform;
22632301
if (OperatingSystem.IsWindows()) platform = "win32";
2264-
else if (OperatingSystem.IsLinux()) platform = "linux";
2302+
else if (OperatingSystem.IsLinux())
2303+
{
2304+
platform = RuntimeInformation.RuntimeIdentifier.StartsWith("linux-musl-", StringComparison.Ordinal)
2305+
? "linuxmusl"
2306+
: "linux";
2307+
}
22652308
else if (OperatingSystem.IsMacOS()) platform = "darwin";
22662309
else return null;
22672310

dotnet/src/FfiRuntimeHost.cs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ internal sealed partial class FfiRuntimeHost : IDisposable
4444
private readonly string _cliEntrypoint;
4545
private readonly string _libraryPath;
4646
private readonly IReadOnlyDictionary<string, string>? _environment;
47+
private readonly IReadOnlyList<string> _args;
4748

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

55-
private FfiRuntimeHost(string libraryPath, string cliEntrypoint, IReadOnlyDictionary<string, string>? environment, ILogger logger)
56+
private FfiRuntimeHost(string libraryPath, string cliEntrypoint, IReadOnlyDictionary<string, string>? environment, IReadOnlyList<string> args, ILogger logger)
5657
{
5758
_libraryPath = libraryPath;
5859
_cliEntrypoint = cliEntrypoint;
5960
_environment = environment;
61+
_args = args;
6062
_logger = logger;
6163
}
6264

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

98100
PrepareNativeLibrary(libraryPath);
99-
return new FfiRuntimeHost(libraryPath, fullEntrypoint, environment, logger);
101+
return new FfiRuntimeHost(libraryPath, fullEntrypoint, environment, args, logger);
100102
}
101103

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

128130
_serverId = NativeHostStart(argvJson, envJson);
@@ -152,7 +154,7 @@ await Task.Run(() =>
152154
}
153155
}
154156

155-
private static byte[] BuildArgvJson(string cliEntrypoint)
157+
private static byte[] BuildArgvJson(string cliEntrypoint, IReadOnlyList<string> args)
156158
{
157159
// A .js entrypoint (dev / dist-cli) is launched via node; the packaged
158160
// single-file CLI binary embeds its own Node and is invoked directly.
@@ -170,6 +172,10 @@ private static byte[] BuildArgvJson(string cliEntrypoint)
170172
// Pin the worker to the bundled pkg matching the loaded cdylib, instead of
171173
// drifting to a newer version under the user's ~/.copilot/pkg (ABI skew).
172174
writer.WriteStringValue("--no-auto-update");
175+
foreach (var arg in args)
176+
{
177+
writer.WriteStringValue(arg);
178+
}
173179
writer.WriteEndArray();
174180
}
175181
return stream.ToArray();

dotnet/src/build/GitHub.Copilot.SDK.targets

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<_CopilotOs Condition="'$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.StartsWith('win'))">win</_CopilotOs>
1010
<_CopilotOs Condition="'$(_CopilotOs)' == '' And '$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.StartsWith('osx'))">osx</_CopilotOs>
1111
<_CopilotOs Condition="'$(_CopilotOs)' == '' And '$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.StartsWith('maccatalyst'))">osx</_CopilotOs>
12+
<_CopilotOs Condition="'$(_CopilotOs)' == '' And '$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.StartsWith('linux-musl'))">linux-musl</_CopilotOs>
1213
<_CopilotOs Condition="'$(_CopilotOs)' == '' And '$(RuntimeIdentifier)' != ''">linux</_CopilotOs>
1314

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

2324
<!-- Fail if we couldn't determine a portable RID from the given RuntimeIdentifier -->
2425
<Target Name="_ValidateCopilotRid" BeforeTargets="BeforeBuild" Condition="'$(RuntimeIdentifier)' != '' And '$(_CopilotRid)' == ''">
25-
<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." />
26+
<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." />
2627
</Target>
2728

2829
<!-- Map RID to platform name used in npm packages -->
@@ -31,6 +32,8 @@
3132
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'win-arm64'">win32-arm64</_CopilotPlatform>
3233
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'linux-x64'">linux-x64</_CopilotPlatform>
3334
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'linux-arm64'">linux-arm64</_CopilotPlatform>
35+
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'linux-musl-x64'">linuxmusl-x64</_CopilotPlatform>
36+
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'linux-musl-arm64'">linuxmusl-arm64</_CopilotPlatform>
3437
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'osx-x64'">darwin-x64</_CopilotPlatform>
3538
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'osx-arm64'">darwin-arm64</_CopilotPlatform>
3639
<_CopilotBinary Condition="$(_CopilotRid.StartsWith('win-'))">copilot.exe</_CopilotBinary>

go/README.md

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,49 @@ Follow these steps to embed the CLI:
101101

102102
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.
103103

104+
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.
105+
106+
## In-process transport (Experimental)
107+
108+
> **Experimental:** the in-process API may change in a future release.
109+
110+
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.
111+
112+
Build your application with the `copilot_inprocess` build tag:
113+
114+
```sh
115+
go build -tags copilot_inprocess
116+
```
117+
118+
```go
119+
client := copilot.NewClient(&copilot.ClientOptions{
120+
Connection: copilot.InProcessConnection{},
121+
})
122+
if err := client.Start(context.Background()); err != nil {
123+
log.Fatal(err)
124+
}
125+
defer client.Stop()
126+
```
127+
128+
Resolution and requirements:
129+
130+
- The application must be built with the `copilot_inprocess` build tag.
131+
- Set `COPILOT_SDK_DEFAULT_CONNECTION=inprocess` to select the in-process
132+
transport when `ClientOptions.Connection` is nil. An explicit connection
133+
always takes precedence.
134+
- Set `COPILOT_CLI_PATH` only when using an externally provisioned compatible runtime package; otherwise the bundled runtime is used. No `PATH` lookup is performed.
135+
- Embedded runtime versions are isolated in separate cache directories. Start fails loudly if the native runtime is unavailable.
136+
- Linux in-process bundles include both glibc and musl runtime packages and select the matching package automatically at startup.
137+
- Only one native runtime version may be loaded per process.
138+
139+
The in-process transport rejects options that cannot be honored by a runtime hosted in your shared process (each panics at `NewClient`):
140+
141+
- `Env` — the host process has a single environment block. Set variables on the host process environment instead.
142+
- `WorkingDirectory` — the runtime shares the host process's working directory. Change the process working directory before creating the client.
143+
- `Telemetry` — per-client telemetry is lowered to native-runtime environment variables. Use a child-process transport for per-client telemetry.
144+
145+
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.
146+
104147
## API Reference
105148

106149
### Client
@@ -142,11 +185,14 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec
142185
**ClientOptions:**
143186

144187
- `Connection` (RuntimeConnection): How the SDK connects to the runtime. Construct via one of:
145-
- `StdioConnection{Path, Args}` — spawn a runtime over stdio (the default if `Connection` is nil)
146-
- `TCPConnection{Port, ConnectionToken, Path, Args}` — spawn a runtime that listens on TCP
188+
- `StdioConnection{Path, Args, Env}` — spawn a runtime over stdio (the default if `Connection` is nil)
189+
- `TCPConnection{Port, ConnectionToken, Path, Args, Env}` — spawn a runtime that listens on TCP
147190
- `URIConnection{URL, ConnectionToken}` — connect to an already-running runtime (no process spawned)
191+
- `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.
148192

149193
When `Path` is empty for stdio/tcp, the SDK uses the bundled CLI (or `COPILOT_CLI_PATH` env var).
194+
195+
`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`.
150196
- `WorkingDirectory` (string): Working directory for the runtime process
151197
- `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.
152198
- `LogLevel` (string): Log level. When empty (default), the runtime uses its own default level (the SDK does not pass `--log-level`).

0 commit comments

Comments
 (0)