Skip to content

Commit 1935fd3

Browse files
authored
Add built-in plugin directory support (#2330)
* Add built-in plugin directory support * Address built-in plugin directory feedback Use a plain code fence for the single-language docs example and clear Go connection state when built-in plugin registration fails so reconnect can start cleanly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: cd609bcf-9f69-4d40-960b-dbd12e90158a * Address .NET code scanning comments Make the built-in plugin path validation filter explicit and avoid Path.Combine in the test paths flagged by code scanning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: cd609bcf-9f69-4d40-960b-dbd12e90158a --------- Copilot-Session: cd609bcf-9f69-4d40-960b-dbd12e90158a
1 parent 3b0d556 commit 1935fd3

18 files changed

Lines changed: 898 additions & 10 deletions

File tree

docs/features/plugin-directories.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,41 @@ let client = Client::start(
240240

241241
> 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.
242242
243+
## Trusted host-bundled plugin directories
244+
245+
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.
246+
247+
<!-- docs-validate: hidden -->
248+
```typescript
249+
import { CopilotClient } from "@github/copilot-sdk";
250+
251+
async function main() {
252+
const client = new CopilotClient({
253+
builtinPluginDirectories: [
254+
"/opt/my-app/copilot-plugins/core",
255+
"/opt/my-app/copilot-plugins/github",
256+
],
257+
});
258+
await client.start();
259+
}
260+
261+
main();
262+
```
263+
<!-- /docs-validate: hidden -->
264+
265+
The equivalent option in each SDK is:
266+
267+
| SDK | Startup option |
268+
|---|---|
269+
| Node.js / TypeScript | `builtinPluginDirectories: string[]` |
270+
| Python | `builtin_plugin_directories=[...]` |
271+
| Go | `BuiltinPluginDirectories: []string{...}` |
272+
| .NET | `BuiltinPluginDirectories = [...]` |
273+
| Java | `.setBuiltinPluginDirectories(List.of(Path.of(...)))` |
274+
| Rust | `.with_builtin_plugin_directories([...])` |
275+
276+
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.
277+
243278
## What a plugin can contribute
244279

245280
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:

dotnet/src/Client.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
7676
private readonly ILogger _logger;
7777
private readonly int? _optionsPort;
7878
private readonly string? _optionsHost;
79+
private readonly string[] _builtinPluginDirectories;
7980
private readonly Func<CancellationToken, Task<IList<ModelInfo>>>? _onListModels;
8081
private readonly List<LifecycleSubscription> _lifecycleHandlers = [];
8182

@@ -138,6 +139,14 @@ public CopilotClient(CopilotClientOptions? options = null)
138139
{
139140
_options = options ?? new();
140141
_connection = _options.Connection ?? ResolveDefaultConnection(_options);
142+
_builtinPluginDirectories = _options.BuiltinPluginDirectories?.ToArray() ?? [];
143+
foreach (var path in _builtinPluginDirectories.Where(path => !IsFullyQualifiedPath(path)))
144+
{
145+
throw new ArgumentException(
146+
$"{nameof(CopilotClientOptions)}.{nameof(CopilotClientOptions.BuiltinPluginDirectories)} " +
147+
$"must contain only absolute paths: {path}",
148+
nameof(options));
149+
}
141150

142151
switch (_connection)
143152
{
@@ -317,6 +326,26 @@ private static Uri ParseRuntimeUrl(string url)
317326
return new Uri(url);
318327
}
319328

329+
private static bool IsFullyQualifiedPath(string path)
330+
{
331+
if (string.IsNullOrEmpty(path) || !Path.IsPathRooted(path))
332+
{
333+
return false;
334+
}
335+
#if NETSTANDARD2_0
336+
if (Path.DirectorySeparatorChar != '\\')
337+
{
338+
return true;
339+
}
340+
341+
bool IsSeparator(char value) => value == '\\' || value == '/';
342+
return (path.Length >= 3 && path[1] == ':' && IsSeparator(path[2]))
343+
|| (path.Length >= 2 && IsSeparator(path[0]) && IsSeparator(path[1]));
344+
#else
345+
return Path.IsPathFullyQualified(path);
346+
#endif
347+
}
348+
320349
/// <summary>
321350
/// Starts the Copilot client and connects to the server.
322351
/// </summary>
@@ -423,6 +452,13 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
423452
"CopilotClient.StartAsync protocol verification complete. Elapsed={Elapsed}",
424453
startTimestamp);
425454

455+
if (_builtinPluginDirectories.Length > 0)
456+
{
457+
var request = new BuiltinPluginDirectoriesRequest(_builtinPluginDirectories);
458+
await InvokeRpcAsync<JsonElement>(
459+
connection.Rpc, "plugins.builtin.set", [request], null, ct);
460+
}
461+
426462
var sessionFsTimestamp = Stopwatch.GetTimestamp();
427463
await ConfigureSessionFsAsync(ct);
428464
if (_options.SessionFs is not null)
@@ -2946,6 +2982,9 @@ internal record ConnectHandshakeRequest(
29462982
string? Token,
29472983
[property: JsonPropertyName("enableGitHubTelemetryForwarding")] bool? EnableGitHubTelemetryForwarding = null);
29482984

2985+
internal record BuiltinPluginDirectoriesRequest(
2986+
string[] Paths);
2987+
29492988
internal record SetForegroundSessionRequest(
29502989
string SessionId);
29512990

@@ -2981,6 +3020,7 @@ internal record HooksInvokeResponse(
29813020
[JsonSerializable(typeof(GetSessionMetadataRequest))]
29823021
[JsonSerializable(typeof(GetSessionMetadataResponse))]
29833022
[JsonSerializable(typeof(ConnectHandshakeRequest))]
3023+
[JsonSerializable(typeof(BuiltinPluginDirectoriesRequest))]
29843024
[JsonSerializable(typeof(McpOAuthTokenStorageMode))]
29853025
[JsonSerializable(typeof(EmbeddingCacheStorageMode))]
29863026
[JsonSerializable(typeof(ModelCapabilitiesOverride))]

dotnet/src/Types.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ private CopilotClientOptions(CopilotClientOptions? other)
309309
Connection = other.Connection;
310310
WorkingDirectory = other.WorkingDirectory;
311311
BaseDirectory = other.BaseDirectory;
312+
BuiltinPluginDirectories = other.BuiltinPluginDirectories is null ? null : [.. other.BuiltinPluginDirectories];
312313
Environment = other.Environment;
313314
GitHubToken = other.GitHubToken;
314315
Logger = other.Logger;
@@ -358,6 +359,13 @@ private CopilotClientOptions(CopilotClientOptions? other)
358359
/// </summary>
359360
public string? BaseDirectory { get; set; }
360361

362+
/// <summary>
363+
/// Absolute paths to trusted plugin directories bundled by the host.
364+
/// When non-empty, the complete set is registered with the runtime during
365+
/// startup before sessions can be created.
366+
/// </summary>
367+
public IList<string>? BuiltinPluginDirectories { get; set; }
368+
361369
/// <summary>
362370
/// Log level for the Copilot runtime. Use the well-known values on
363371
/// <see cref="CopilotLogLevel"/> (<see cref="CopilotLogLevel.None"/>,

dotnet/test/Unit/CloneTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public void CopilotClientOptions_Clone_CopiesAllProperties()
2020
GitHubToken = "ghp_test",
2121
UseLoggedInUser = false,
2222
BaseDirectory = "/custom/copilot/home",
23+
BuiltinPluginDirectories = ["/plugins/core", "/plugins/github"],
2324
EnableRemoteSessions = true,
2425
SessionIdleTimeoutSeconds = 600,
2526
};
@@ -33,6 +34,8 @@ public void CopilotClientOptions_Clone_CopiesAllProperties()
3334
Assert.Equal(original.GitHubToken, clone.GitHubToken);
3435
Assert.Equal(original.UseLoggedInUser, clone.UseLoggedInUser);
3536
Assert.Equal(original.BaseDirectory, clone.BaseDirectory);
37+
Assert.Equal(original.BuiltinPluginDirectories, clone.BuiltinPluginDirectories);
38+
Assert.NotSame(original.BuiltinPluginDirectories, clone.BuiltinPluginDirectories);
3639
Assert.Equal(original.EnableRemoteSessions, clone.EnableRemoteSessions);
3740
Assert.Equal(original.SessionIdleTimeoutSeconds, clone.SessionIdleTimeoutSeconds);
3841
}

dotnet/test/Unit/GitHubTelemetryTests.cs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,60 @@ namespace GitHub.Copilot.Test.Unit;
1717

1818
public sealed class GitHubTelemetryTests
1919
{
20+
[Theory]
21+
[InlineData(false)]
22+
[InlineData(true)]
23+
public async Task BuiltinPluginDirectories_Default_Or_Empty_Does_Not_Call_Rpc(bool useEmpty)
24+
{
25+
await using var server = await FakeTelemetryServer.StartAsync();
26+
await using var client = new CopilotClient(new CopilotClientOptions
27+
{
28+
Connection = RuntimeConnection.ForUri(server.Url),
29+
BuiltinPluginDirectories = useEmpty ? [] : null,
30+
});
31+
32+
await client.StartAsync();
33+
34+
Assert.Equal(0, server.BuiltinPluginSetCount);
35+
}
36+
37+
[Fact]
38+
public async Task BuiltinPluginDirectories_Are_Set_Once_Before_Start_Completes()
39+
{
40+
var paths = new[]
41+
{
42+
Path.GetFullPath(Path.Join("plugins", "core")),
43+
Path.GetFullPath(Path.Join("plugins", "github")),
44+
};
45+
await using var server = await FakeTelemetryServer.StartAsync();
46+
await using var client = new CopilotClient(new CopilotClientOptions
47+
{
48+
Connection = RuntimeConnection.ForUri(server.Url),
49+
BuiltinPluginDirectories = paths,
50+
});
51+
52+
await client.StartAsync();
53+
54+
Assert.Equal(1, server.BuiltinPluginSetCount);
55+
var payload = server.LastBuiltinPluginParams
56+
?? throw new InvalidOperationException("plugins.builtin.set was not captured.");
57+
Assert.Collection(
58+
payload.GetProperty("paths").EnumerateArray(),
59+
value => Assert.Equal(paths[0], value.GetString()),
60+
value => Assert.Equal(paths[1], value.GetString()));
61+
}
62+
63+
[Fact]
64+
public void BuiltinPluginDirectories_Reject_Relative_Paths()
65+
{
66+
var exception = Assert.Throws<ArgumentException>(() => new CopilotClient(new CopilotClientOptions
67+
{
68+
BuiltinPluginDirectories = ["plugins/core"],
69+
}));
70+
71+
Assert.Contains("absolute paths", exception.Message);
72+
}
73+
2074
[Fact]
2175
public async Task CreateSession_Opts_Into_Forwarding_When_Handler_Provided()
2276
{
@@ -266,6 +320,10 @@ public string Url
266320

267321
public JsonElement? LastConnectParams { get; private set; }
268322

323+
public JsonElement? LastBuiltinPluginParams { get; private set; }
324+
325+
public int BuiltinPluginSetCount { get; private set; }
326+
269327
public static Task<FakeTelemetryServer> StartAsync()
270328
{
271329
var listener = new TcpListener(IPAddress.Loopback, 0);
@@ -347,6 +405,7 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
347405
object? result = method switch
348406
{
349407
"connect" => CaptureConnect(request),
408+
"plugins.builtin.set" => CaptureBuiltinPluginDirectories(request),
350409
"session.create" => CaptureCreate(request),
351410
"session.resume" => CaptureResume(request),
352411
"session.send" => new Dictionary<string, object?> { ["messageId"] = "message-1" },
@@ -375,6 +434,13 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
375434
};
376435
}
377436

437+
private Dictionary<string, object?> CaptureBuiltinPluginDirectories(JsonElement request)
438+
{
439+
BuiltinPluginSetCount++;
440+
LastBuiltinPluginParams = request.TryGetProperty("params", out var p) ? p.Clone() : null;
441+
return new Dictionary<string, object?>();
442+
}
443+
378444
private Dictionary<string, object?> CaptureCreate(JsonElement request)
379445
{
380446
LastCreateParams = request.TryGetProperty("params", out var p) ? p.Clone() : null;

go/client.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import (
3838
"net"
3939
"os"
4040
"os/exec"
41+
"path/filepath"
4142
"regexp"
4243
"strconv"
4344
"strings"
@@ -225,6 +226,12 @@ func NewClient(options *ClientOptions) *Client {
225226
if options != nil {
226227
opts = *options
227228
}
229+
for _, path := range opts.BuiltinPluginDirectories {
230+
if !filepath.IsAbs(path) {
231+
panic(fmt.Sprintf("BuiltinPluginDirectories must contain only absolute paths: %s", path))
232+
}
233+
}
234+
opts.BuiltinPluginDirectories = append([]string(nil), opts.BuiltinPluginDirectories...)
228235

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

463+
if len(c.options.BuiltinPluginDirectories) > 0 {
464+
if _, err := c.client.Request(ctx, "plugins.builtin.set", map[string]any{
465+
"paths": c.options.BuiltinPluginDirectories,
466+
}); err != nil {
467+
c.client.Stop()
468+
c.client = nil
469+
c.conn = nil
470+
c.RPC = nil
471+
c.internalRPC = nil
472+
killErr := c.killProcess()
473+
c.state = stateError
474+
return errors.Join(err, killErr)
475+
}
476+
}
477+
456478
// If a session filesystem provider was configured, register it.
457479
if c.options.SessionFS != nil {
458480
req := &rpc.SessionFSSetProviderRequest{

0 commit comments

Comments
 (0)