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
35 changes: 35 additions & 0 deletions docs/features/plugin-directories.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,41 @@ let client = Client::start(

> The example above uses an stdio runtime connection — the default when the SDK bundles the CLI. If you connect to an external runtime via a URL (`forUri` / `ForUri`), pass `--plugin-dir` to the long-running CLI server when you start it; the SDK does not forward `--plugin-dir` to runtimes it didn't spawn.

## Trusted host-bundled plugin directories

Applications that ship their own trusted plugins can register them as a client startup option. The SDK sends the complete ordered set after connecting and verifying the protocol, before `start` returns or any session can be created. Paths must be absolute; leaving the option unset or empty makes no RPC call.

<!-- docs-validate: hidden -->
```typescript
import { CopilotClient } from "@github/copilot-sdk";

async function main() {
const client = new CopilotClient({
builtinPluginDirectories: [
"/opt/my-app/copilot-plugins/core",
"/opt/my-app/copilot-plugins/github",
],
});
await client.start();
}

main();
```
<!-- /docs-validate: hidden -->

The equivalent option in each SDK is:

| SDK | Startup option |
|---|---|
| Node.js / TypeScript | `builtinPluginDirectories: string[]` |
| Python | `builtin_plugin_directories=[...]` |
| Go | `BuiltinPluginDirectories: []string{...}` |
| .NET | `BuiltinPluginDirectories = [...]` |
| Java | `.setBuiltinPluginDirectories(List.of(Path.of(...)))` |
| Rust | `.with_builtin_plugin_directories([...])` |

This is a trust boundary for plugins bundled and controlled by the host application. It is distinct from `--plugin-dir`, which is a CLI process launch argument for explicitly loading ordinary plugin directories. The startup option also works when connecting to an existing runtime because it is sent over JSON-RPC rather than forwarded as a process argument.

## What a plugin can contribute

Loading a plugin directory makes its extensions visible to every session created by the client. The runtime merges plugin-provided extensions with anything you register inline:
Expand Down
40 changes: 40 additions & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
private readonly ILogger _logger;
private readonly int? _optionsPort;
private readonly string? _optionsHost;
private readonly string[] _builtinPluginDirectories;
private readonly Func<CancellationToken, Task<IList<ModelInfo>>>? _onListModels;
private readonly List<LifecycleSubscription> _lifecycleHandlers = [];

Expand Down Expand Up @@ -138,6 +139,14 @@ public CopilotClient(CopilotClientOptions? options = null)
{
_options = options ?? new();
_connection = _options.Connection ?? ResolveDefaultConnection(_options);
_builtinPluginDirectories = _options.BuiltinPluginDirectories?.ToArray() ?? [];
foreach (var path in _builtinPluginDirectories.Where(path => !IsFullyQualifiedPath(path)))
{
throw new ArgumentException(
$"{nameof(CopilotClientOptions)}.{nameof(CopilotClientOptions.BuiltinPluginDirectories)} " +
$"must contain only absolute paths: {path}",
nameof(options));
}

switch (_connection)
{
Expand Down Expand Up @@ -317,6 +326,26 @@ private static Uri ParseRuntimeUrl(string url)
return new Uri(url);
}

private static bool IsFullyQualifiedPath(string path)
{
if (string.IsNullOrEmpty(path) || !Path.IsPathRooted(path))
{
return false;
}
#if NETSTANDARD2_0
if (Path.DirectorySeparatorChar != '\\')
{
return true;
}

bool IsSeparator(char value) => value == '\\' || value == '/';
return (path.Length >= 3 && path[1] == ':' && IsSeparator(path[2]))
|| (path.Length >= 2 && IsSeparator(path[0]) && IsSeparator(path[1]));
#else
return Path.IsPathFullyQualified(path);
#endif
}

/// <summary>
/// Starts the Copilot client and connects to the server.
/// </summary>
Expand Down Expand Up @@ -423,6 +452,13 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
"CopilotClient.StartAsync protocol verification complete. Elapsed={Elapsed}",
startTimestamp);

if (_builtinPluginDirectories.Length > 0)
{
var request = new BuiltinPluginDirectoriesRequest(_builtinPluginDirectories);
await InvokeRpcAsync<JsonElement>(
connection.Rpc, "plugins.builtin.set", [request], null, ct);
}

var sessionFsTimestamp = Stopwatch.GetTimestamp();
await ConfigureSessionFsAsync(ct);
if (_options.SessionFs is not null)
Expand Down Expand Up @@ -2946,6 +2982,9 @@ internal record ConnectHandshakeRequest(
string? Token,
[property: JsonPropertyName("enableGitHubTelemetryForwarding")] bool? EnableGitHubTelemetryForwarding = null);

internal record BuiltinPluginDirectoriesRequest(
string[] Paths);

internal record SetForegroundSessionRequest(
string SessionId);

Expand Down Expand Up @@ -2981,6 +3020,7 @@ internal record HooksInvokeResponse(
[JsonSerializable(typeof(GetSessionMetadataRequest))]
[JsonSerializable(typeof(GetSessionMetadataResponse))]
[JsonSerializable(typeof(ConnectHandshakeRequest))]
[JsonSerializable(typeof(BuiltinPluginDirectoriesRequest))]
[JsonSerializable(typeof(McpOAuthTokenStorageMode))]
[JsonSerializable(typeof(EmbeddingCacheStorageMode))]
[JsonSerializable(typeof(ModelCapabilitiesOverride))]
Expand Down
8 changes: 8 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ private CopilotClientOptions(CopilotClientOptions? other)
Connection = other.Connection;
WorkingDirectory = other.WorkingDirectory;
BaseDirectory = other.BaseDirectory;
BuiltinPluginDirectories = other.BuiltinPluginDirectories is null ? null : [.. other.BuiltinPluginDirectories];
Environment = other.Environment;
GitHubToken = other.GitHubToken;
Logger = other.Logger;
Expand Down Expand Up @@ -358,6 +359,13 @@ private CopilotClientOptions(CopilotClientOptions? other)
/// </summary>
public string? BaseDirectory { get; set; }

/// <summary>
/// Absolute paths to trusted plugin directories bundled by the host.
/// When non-empty, the complete set is registered with the runtime during
/// startup before sessions can be created.
/// </summary>
public IList<string>? BuiltinPluginDirectories { get; set; }

/// <summary>
/// Log level for the Copilot runtime. Use the well-known values on
/// <see cref="CopilotLogLevel"/> (<see cref="CopilotLogLevel.None"/>,
Expand Down
3 changes: 3 additions & 0 deletions dotnet/test/Unit/CloneTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public void CopilotClientOptions_Clone_CopiesAllProperties()
GitHubToken = "ghp_test",
UseLoggedInUser = false,
BaseDirectory = "/custom/copilot/home",
BuiltinPluginDirectories = ["/plugins/core", "/plugins/github"],
EnableRemoteSessions = true,
SessionIdleTimeoutSeconds = 600,
};
Expand All @@ -33,6 +34,8 @@ public void CopilotClientOptions_Clone_CopiesAllProperties()
Assert.Equal(original.GitHubToken, clone.GitHubToken);
Assert.Equal(original.UseLoggedInUser, clone.UseLoggedInUser);
Assert.Equal(original.BaseDirectory, clone.BaseDirectory);
Assert.Equal(original.BuiltinPluginDirectories, clone.BuiltinPluginDirectories);
Assert.NotSame(original.BuiltinPluginDirectories, clone.BuiltinPluginDirectories);
Assert.Equal(original.EnableRemoteSessions, clone.EnableRemoteSessions);
Assert.Equal(original.SessionIdleTimeoutSeconds, clone.SessionIdleTimeoutSeconds);
}
Expand Down
66 changes: 66 additions & 0 deletions dotnet/test/Unit/GitHubTelemetryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,60 @@ namespace GitHub.Copilot.Test.Unit;

public sealed class GitHubTelemetryTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task BuiltinPluginDirectories_Default_Or_Empty_Does_Not_Call_Rpc(bool useEmpty)
{
await using var server = await FakeTelemetryServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions
{
Connection = RuntimeConnection.ForUri(server.Url),
BuiltinPluginDirectories = useEmpty ? [] : null,
});

await client.StartAsync();

Assert.Equal(0, server.BuiltinPluginSetCount);
}

[Fact]
public async Task BuiltinPluginDirectories_Are_Set_Once_Before_Start_Completes()
{
var paths = new[]
{
Path.GetFullPath(Path.Join("plugins", "core")),
Path.GetFullPath(Path.Join("plugins", "github")),
};
await using var server = await FakeTelemetryServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions
{
Connection = RuntimeConnection.ForUri(server.Url),
BuiltinPluginDirectories = paths,
});

await client.StartAsync();

Assert.Equal(1, server.BuiltinPluginSetCount);
var payload = server.LastBuiltinPluginParams
?? throw new InvalidOperationException("plugins.builtin.set was not captured.");
Assert.Collection(
payload.GetProperty("paths").EnumerateArray(),
value => Assert.Equal(paths[0], value.GetString()),
value => Assert.Equal(paths[1], value.GetString()));
}

[Fact]
public void BuiltinPluginDirectories_Reject_Relative_Paths()
{
var exception = Assert.Throws<ArgumentException>(() => new CopilotClient(new CopilotClientOptions
{
BuiltinPluginDirectories = ["plugins/core"],
}));

Assert.Contains("absolute paths", exception.Message);
}

[Fact]
public async Task CreateSession_Opts_Into_Forwarding_When_Handler_Provided()
{
Expand Down Expand Up @@ -266,6 +320,10 @@ public string Url

public JsonElement? LastConnectParams { get; private set; }

public JsonElement? LastBuiltinPluginParams { get; private set; }

public int BuiltinPluginSetCount { get; private set; }

public static Task<FakeTelemetryServer> StartAsync()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
Expand Down Expand Up @@ -347,6 +405,7 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
object? result = method switch
{
"connect" => CaptureConnect(request),
"plugins.builtin.set" => CaptureBuiltinPluginDirectories(request),
"session.create" => CaptureCreate(request),
"session.resume" => CaptureResume(request),
"session.send" => new Dictionary<string, object?> { ["messageId"] = "message-1" },
Expand Down Expand Up @@ -375,6 +434,13 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
};
}

private Dictionary<string, object?> CaptureBuiltinPluginDirectories(JsonElement request)
{
BuiltinPluginSetCount++;
LastBuiltinPluginParams = request.TryGetProperty("params", out var p) ? p.Clone() : null;
return new Dictionary<string, object?>();
}

private Dictionary<string, object?> CaptureCreate(JsonElement request)
{
LastCreateParams = request.TryGetProperty("params", out var p) ? p.Clone() : null;
Expand Down
22 changes: 22 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import (
"net"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
Expand Down Expand Up @@ -225,6 +226,12 @@ func NewClient(options *ClientOptions) *Client {
if options != nil {
opts = *options
}
for _, path := range opts.BuiltinPluginDirectories {
if !filepath.IsAbs(path) {
panic(fmt.Sprintf("BuiltinPluginDirectories must contain only absolute paths: %s", path))
}
}
opts.BuiltinPluginDirectories = append([]string(nil), opts.BuiltinPluginDirectories...)

// Resolve the connection. An explicit connection always wins; otherwise
// honor the same process/environment override as the other SDKs.
Expand Down Expand Up @@ -453,6 +460,21 @@ func (c *Client) Start(ctx context.Context) error {
return errors.Join(err, killErr)
}

if len(c.options.BuiltinPluginDirectories) > 0 {
if _, err := c.client.Request(ctx, "plugins.builtin.set", map[string]any{
"paths": c.options.BuiltinPluginDirectories,
}); err != nil {
c.client.Stop()
c.client = nil
c.conn = nil
c.RPC = nil
c.internalRPC = nil
killErr := c.killProcess()
c.state = stateError
return errors.Join(err, killErr)
Comment thread
lutzroeder marked this conversation as resolved.
}
}

// If a session filesystem provider was configured, register it.
if c.options.SessionFS != nil {
req := &rpc.SessionFSSetProviderRequest{
Expand Down
Loading
Loading