From 016fab005beea935b424a341947812538dc49dfd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:21:24 +0000 Subject: [PATCH 1/3] Initial plan From e429fa0199f18f4aa2fd68da4381d93d55aa3ce1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:03:51 +0000 Subject: [PATCH 2/3] Add RuntimeConnection hierarchy and in-process transport to Java client Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- .../com/github/copilot/CopilotClient.java | 305 +++++++++++++- .../copilot/ffi/NativeRuntimeLoader.java | 12 +- .../copilot/rpc/CopilotClientOptions.java | 33 ++ .../rpc/InProcessRuntimeConnection.java | 27 ++ .../github/copilot/rpc/RuntimeConnection.java | 93 +++++ .../copilot/rpc/StdioRuntimeConnection.java | 68 ++++ .../copilot/rpc/TcpRuntimeConnection.java | 113 ++++++ .../copilot/rpc/UriRuntimeConnection.java | 54 +++ .../com/github/copilot/CopilotClientTest.java | 4 +- .../copilot/CopilotClientTransportTest.java | 383 ++++++++++++++++++ .../copilot/CreateSessionReKeyEntryTest.java | 5 +- 11 files changed, 1086 insertions(+), 11 deletions(-) create mode 100644 java/sdk/src/main/java/com/github/copilot/rpc/InProcessRuntimeConnection.java create mode 100644 java/sdk/src/main/java/com/github/copilot/rpc/RuntimeConnection.java create mode 100644 java/sdk/src/main/java/com/github/copilot/rpc/StdioRuntimeConnection.java create mode 100644 java/sdk/src/main/java/com/github/copilot/rpc/TcpRuntimeConnection.java create mode 100644 java/sdk/src/main/java/com/github/copilot/rpc/UriRuntimeConnection.java create mode 100644 java/sdk/src/test/java/com/github/copilot/CopilotClientTransportTest.java 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 7112a3d339..e5809f8352 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java @@ -5,8 +5,11 @@ package com.github.copilot; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.net.URI; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -21,8 +24,15 @@ import java.util.logging.Level; import java.util.logging.Logger; +import com.github.copilot.ffi.FfiRuntimeHost; +import com.github.copilot.ffi.NativeRuntimeLoader; import com.github.copilot.rpc.CopilotClientMode; import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.InProcessRuntimeConnection; +import com.github.copilot.rpc.RuntimeConnection; +import com.github.copilot.rpc.StdioRuntimeConnection; +import com.github.copilot.rpc.TcpRuntimeConnection; +import com.github.copilot.rpc.UriRuntimeConnection; import com.github.copilot.rpc.CreateSessionResponse; import com.github.copilot.generated.rpc.SessionOptionsUpdateParams; import com.github.copilot.generated.rpc.SessionInstalledPlugin; @@ -111,6 +121,7 @@ public final class CopilotClient implements AutoCloseable { private volatile boolean disposed = false; private final String optionsHost; private final Integer optionsPort; + private final RuntimeConnection runtimeConnection; private final String effectiveConnectionToken; private volatile List modelsCache; private final Object modelsCacheLock = new Object(); @@ -133,6 +144,19 @@ public CopilotClient() { public CopilotClient(CopilotClientOptions options) { this.options = options != null ? options : new CopilotClientOptions(); + // Resolve the transport: an explicit RuntimeConnection wins; otherwise the + // COPILOT_SDK_DEFAULT_CONNECTION env var, or the individual transport options. + RuntimeConnection requestedConnection = this.options.getConnection(); + if (requestedConnection != null) { + validateEnvironmentOptions(this.options, requestedConnection); + validateConnectionConflicts(this.options, requestedConnection); + applyConnection(this.options, requestedConnection); + } else { + requestedConnection = resolveDefaultConnection(this.options); + validateEnvironmentOptions(this.options, requestedConnection); + } + this.runtimeConnection = requestedConnection; + // When cliUrl is set, auto-correct useStdio since we're connecting via TCP if (this.options.getCliUrl() != null && !this.options.getCliUrl().isEmpty()) { this.options.setUseStdio(false); @@ -199,6 +223,266 @@ public CopilotClient(CopilotClientOptions options) { this.serverManager.setConnectionToken(this.effectiveConnectionToken); } + /** + * Environment variable that overrides the transport used when the caller does + * not set {@link CopilotClientOptions#setConnection(RuntimeConnection)}. + * Accepts {@code "inprocess"} or {@code "stdio"} (case-insensitive); unset + * keeps the transport selected by the individual transport options. Any other + * value is an error. Ignored when a connection is set explicitly. + */ + static final String DEFAULT_CONNECTION_ENV_VAR = "COPILOT_SDK_DEFAULT_CONNECTION"; + + /** + * Resolves the connection to use when the caller did not set one, honoring + * {@link #DEFAULT_CONNECTION_ENV_VAR} and otherwise inferring the transport + * from the individual transport options. + */ + private static RuntimeConnection resolveDefaultConnection(CopilotClientOptions options) { + return resolveDefaultConnection(options, System.getenv(DEFAULT_CONNECTION_ENV_VAR)); + } + + /** + * Resolves the default connection from an explicit environment-variable value. + * Package-private so tests can supply the value directly. + */ + static RuntimeConnection resolveDefaultConnection(CopilotClientOptions options, String envValue) { + if (envValue != null && !envValue.isEmpty()) { + if ("inprocess".equalsIgnoreCase(envValue)) { + return RuntimeConnection.forInProcess(); + } + if (!"stdio".equalsIgnoreCase(envValue)) { + throw new IllegalArgumentException("Invalid " + DEFAULT_CONNECTION_ENV_VAR + " value '" + envValue + + "'. Expected 'inprocess', 'stdio', or unset."); + } + } + + return inferConnectionFromOptions(options); + } + + /** + * Maps the individual transport options onto the equivalent + * {@link RuntimeConnection}, preserving the behavior of clients written before + * connections existed. + */ + private static RuntimeConnection inferConnectionFromOptions(CopilotClientOptions options) { + String cliUrl = options.getCliUrl(); + if (cliUrl != null && !cliUrl.isEmpty()) { + return RuntimeConnection.forUri(cliUrl).setConnectionToken(options.getTcpConnectionToken()); + } + if (options.isUseStdio()) { + return RuntimeConnection.forStdio(options.getCliPath()); + } + return RuntimeConnection.forTcp().setPath(options.getCliPath()).setPort(options.getPort()) + .setConnectionToken(options.getTcpConnectionToken()); + } + + /** + * Rejects transport options that contradict the configured connection. Values + * that match what the connection implies are accepted so that constructing + * several clients from the same options instance stays valid. + */ + private static void validateConnectionConflicts(CopilotClientOptions options, RuntimeConnection connection) { + String impliedPath = null; + String impliedUrl = null; + String impliedToken = null; + int impliedPort = 0; + boolean impliedUseStdio = true; + List impliedArgs = null; + + if (connection instanceof StdioRuntimeConnection stdio) { + impliedPath = stdio.getPath(); + impliedArgs = stdio.getArgs(); + } else if (connection instanceof TcpRuntimeConnection tcp) { + impliedPath = tcp.getPath(); + impliedPort = tcp.getPort(); + impliedToken = tcp.getConnectionToken(); + impliedArgs = tcp.getArgs(); + impliedUseStdio = false; + } else if (connection instanceof UriRuntimeConnection uri) { + impliedUrl = uri.getUrl(); + impliedToken = uri.getConnectionToken(); + impliedUseStdio = false; + } + + rejectConflict("CliPath", options.getCliPath() != null && !options.getCliPath().equals(impliedPath)); + rejectConflict("CliUrl", options.getCliUrl() != null && !options.getCliUrl().isEmpty() + && !options.getCliUrl().equals(impliedUrl)); + rejectConflict("Port", options.getPort() != 0 && options.getPort() != impliedPort); + rejectConflict("TcpConnectionToken", + options.getTcpConnectionToken() != null && !options.getTcpConnectionToken().equals(impliedToken)); + rejectConflict("UseStdio", !options.isUseStdio() && impliedUseStdio); + rejectConflict("CliArgs", options.getCliArgs() != null + && !Arrays.asList(options.getCliArgs()).equals(impliedArgs == null ? List.of() : impliedArgs)); + } + + private static void rejectConflict(String optionName, boolean conflicting) { + if (conflicting) { + throw new IllegalArgumentException("CopilotClientOptions." + optionName + + " cannot be combined with CopilotClientOptions.setConnection(); configure the transport on the" + + " RuntimeConnection instead."); + } + } + + /** + * Projects the configured connection onto the individual transport options so + * that the rest of the client sees a single, consistent view of the transport. + */ + private static void applyConnection(CopilotClientOptions options, RuntimeConnection connection) { + if (connection instanceof StdioRuntimeConnection stdio) { + options.setUseStdio(true); + if (stdio.getPath() != null) { + options.setCliPath(stdio.getPath()); + } + applyConnectionArgs(options, stdio.getArgs()); + } else if (connection instanceof TcpRuntimeConnection tcp) { + options.setUseStdio(false); + if (tcp.getPath() != null) { + options.setCliPath(tcp.getPath()); + } + options.setPort(tcp.getPort()); + if (tcp.getConnectionToken() != null) { + options.setTcpConnectionToken(tcp.getConnectionToken()); + } + applyConnectionArgs(options, tcp.getArgs()); + } else if (connection instanceof UriRuntimeConnection uri) { + options.setUseStdio(false); + options.setCliUrl(uri.getUrl()); + if (uri.getConnectionToken() != null) { + options.setTcpConnectionToken(uri.getConnectionToken()); + } + } + } + + private static void applyConnectionArgs(CopilotClientOptions options, List args) { + if (args != null) { + options.setCliArgs(args.toArray(new String[0])); + } + } + + /** + * Rejects per-process options that the in-process transport cannot honor. These + * options are lowered onto a child process, but the in-process runtime runs + * inside the shared host process, whose single environment and working + * directory cannot carry per-client values. + */ + private static void validateEnvironmentOptions(CopilotClientOptions options, RuntimeConnection connection) { + if (!(connection instanceof InProcessRuntimeConnection)) { + return; + } + + rejectInProcessOption("Environment", options.getEnvironment() != null, + "set the variables on the host process environment instead"); + rejectInProcessOption("Telemetry", options.getTelemetry() != null, + "configure telemetry through the host process environment instead"); + rejectInProcessOption("Cwd", options.getCwd() != null, + "set the process working directory before creating the client instead"); + rejectInProcessOption("CliArgs", options.getCliArgs() != null && options.getCliArgs().length > 0, + "use the typed client options instead"); + } + + private static void rejectInProcessOption(String optionName, boolean present, String remedy) { + if (present) { + throw new IllegalArgumentException("CopilotClientOptions." + optionName + + " is not supported with RuntimeConnection.forInProcess(): the in-process runtime shares the host" + + " process, so per-client values cannot be honored; " + remedy + "."); + } + } + + /** + * Duplex streams of an in-process runtime, together with the resource that owns + * its lifetime. + * + * @param receiveStream + * stream carrying messages from the runtime + * @param sendStream + * stream carrying messages to the runtime + * @param host + * resource closed when the client stops + */ + record InProcessTransport(InputStream receiveStream, OutputStream sendStream, AutoCloseable host) { + } + + /** + * Opens the transport for the in-process runtime. Package-private so tests can + * substitute a fake for the native runtime. + */ + @FunctionalInterface + interface InProcessTransportFactory { + /** + * Opens the in-process transport. + * + * @param options + * client options used to configure the runtime + * @return the opened transport + * @throws IOException + * if the runtime cannot be started + */ + InProcessTransport open(CopilotClientOptions options) throws IOException; + } + + private volatile InProcessTransportFactory inProcessTransportFactory = CopilotClient::openInProcessTransport; + + /** + * Returns the resolved connection describing how this client reaches the + * runtime. Package-private test seam. + * + * @return the resolved connection + */ + RuntimeConnection getRuntimeConnection() { + return runtimeConnection; + } + + /** + * Replaces the in-process transport factory. Package-private test seam. + * + * @param factory + * the factory to use + */ + void setInProcessTransportFactory(InProcessTransportFactory factory) { + this.inProcessTransportFactory = java.util.Objects.requireNonNull(factory, "factory must not be null"); + } + + private static InProcessTransport openInProcessTransport(CopilotClientOptions options) throws IOException { + FfiRuntimeHost host = new FfiRuntimeHost(); + try { + host.start(resolveInProcessEntrypoint(options), options); + } catch (RuntimeException | Error e) { + host.close(); + throw e; + } + return new InProcessTransport(host.getReceiveStream(), host.getSendStream(), host); + } + + /** + * Resolves the runtime entrypoint handed to the in-process host. Callers do not + * configure this: the bundled runtime is used unless an explicit override is + * present in the environment. + */ + private static String resolveInProcessEntrypoint(CopilotClientOptions options) throws IOException { + String envPath = System.getenv(NativeRuntimeLoader.COPILOT_CLI_PATH_ENV); + if (envPath != null && !envPath.isBlank()) { + return envPath; + } + String cliPath = options.getCliPath(); + if (cliPath != null && !cliPath.isBlank()) { + return cliPath; + } + String discovered = NativeRuntimeLoader.findRuntimeOnPath(); + if (discovered != null) { + return discovered; + } + throw new IOException("The in-process runtime could not be located. Add the runtime artifact for this" + + " platform to the classpath, or use a child-process connection."); + } + + private static void closeRuntimeHost(AutoCloseable host) { + try { + host.close(); + } catch (Exception e) { + LOG.log(Level.FINE, "Error closing in-process runtime host", e); + } + } + /** * Starts the Copilot client and connects to the server. * @@ -227,11 +511,16 @@ private CompletableFuture startCore() { private Connection startCoreBody() { Process process = null; + InProcessTransport inProcessTransport = null; long startNanos = System.nanoTime(); try { JsonRpcClient rpc; - if (optionsHost != null && optionsPort != null) { + if (runtimeConnection instanceof InProcessRuntimeConnection) { + // In-process runtime hosted in this process (no child process) + inProcessTransport = inProcessTransportFactory.open(options); + rpc = JsonRpcClient.fromStreams(inProcessTransport.receiveStream(), inProcessTransport.sendStream()); + } else if (optionsHost != null && optionsPort != null) { // External server (TCP) rpc = serverManager.connectToServer(null, optionsHost, optionsPort); } else { @@ -245,7 +534,8 @@ private Connection startCoreBody() { LoggingHelpers.logTiming(LOG, Level.FINE, "CopilotClient.start transport setup complete. Elapsed={Elapsed}", startNanos); - Connection connection = new Connection(rpc, process, new ServerRpc(rpc::invoke)); + Connection connection = new Connection(rpc, process, new ServerRpc(rpc::invoke), + inProcessTransport == null ? null : inProcessTransport.host()); // Register handlers for server-to-client calls RpcHandlerDispatcher dispatcher = new RpcHandlerDispatcher(sessions, lifecycleManager::dispatch, executor); @@ -289,6 +579,9 @@ private Connection startCoreBody() { if (process != null) { cleanupCliProcess(process, true); } + if (inProcessTransport != null) { + closeRuntimeHost(inProcessTransport.host()); + } String stderr = serverManager.getStderrOutput(); if (!stderr.isEmpty()) { throw new CompletionException(new IOException( @@ -438,7 +731,7 @@ private CompletableFuture cleanupConnection(boolean gracefulRuntimeShutdow } CompletableFuture shutdownFuture = CompletableFuture.completedFuture(null); - if (gracefulRuntimeShutdown && connection.process != null) { + if (gracefulRuntimeShutdown && (connection.process != null || connection.runtimeHost != null)) { long runtimeShutdownStartNanos = System.nanoTime(); shutdownFuture = connection.rpc.invoke("runtime.shutdown", Map.of(), Void.class) .orTimeout(RUNTIME_SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS) @@ -465,6 +758,9 @@ private CompletableFuture cleanupConnection(boolean gracefulRuntimeShutdow if (connection.process != null) { cleanupCliProcess(connection.process, !gracefulRuntimeShutdown || error != null); } + if (connection.runtimeHost != null) { + closeRuntimeHost(connection.runtimeHost); + } return (Void) null; }); }).thenCompose(result -> result); @@ -1392,7 +1688,8 @@ private void shutdownOwnedExecutor() { } } - private static record Connection(JsonRpcClient rpc, Process process, ServerRpc serverRpc) { + private static record Connection(JsonRpcClient rpc, Process process, ServerRpc serverRpc, + AutoCloseable runtimeHost) { }; } 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 ebf43e7c32..ab65e00e3b 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 @@ -37,7 +37,8 @@ public final class NativeRuntimeLoader { static final String RUNTIME_FILENAME = "runtime.node"; - static final String COPILOT_CLI_PATH_ENV = "COPILOT_CLI_PATH"; + /** 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"; /** @@ -113,7 +114,7 @@ public static Path resolve() throws IOException { String classifier = PlatformDetector.detectClassifier(); String version = readVersion(loader); Path cacheBase = defaultCacheBase(); - return resolve(null, findCliOnPath(), cacheBase, loader, classifier, version); + return resolve(null, findRuntimeOnPath(), cacheBase, loader, classifier, version); } /** @@ -314,7 +315,12 @@ private static void copyResourceToTemp(URL resource, String resourcePath, Path t } } - private static String findCliOnPath() { + /** + * Finds the runtime executable on the {@code PATH}. + * + * @return the absolute path, or {@code null} if none was found + */ + public static String findRuntimeOnPath() { String pathValue = System.getenv("PATH"); if (pathValue == null || pathValue.isBlank()) { return null; 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 0d4494d738..cb43ad0f1a 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 @@ -51,6 +51,7 @@ public class CopilotClientOptions { private String[] cliArgs; private String cliPath; private String cliUrl; + private RuntimeConnection connection; private String copilotHome; private String cwd; private Map environment; @@ -204,6 +205,37 @@ public CopilotClientOptions setCliUrl(String cliUrl) { return this; } + /** + * Gets the connection that selects how the client reaches the Copilot runtime. + * + * @return the connection, or {@code null} to infer the transport from + * {@link #isUseStdio()}, {@link #getCliUrl()} and {@link #getCliPath()} + */ + @JsonIgnore + public RuntimeConnection getConnection() { + return connection; + } + + /** + * Sets the connection that selects how the client reaches the Copilot runtime. + *

+ * When set, the connection takes precedence over the transport-selecting + * options {@link #setUseStdio(boolean)}, {@link #setCliUrl(String)}, + * {@link #setCliPath(String)}, {@link #setPort(int)} and + * {@link #setTcpConnectionToken(String)}; combining a connection with any of + * those options makes the client constructor throw + * {@link IllegalArgumentException}. + * + * @param connection + * the connection, or {@code null} to infer the transport from the + * individual transport options + * @return this options instance for method chaining + */ + public CopilotClientOptions setConnection(RuntimeConnection connection) { + this.connection = connection; + return this; + } + /** * Gets the base directory for Copilot data (session state, config, etc.). * @@ -754,6 +786,7 @@ public CopilotClientOptions clone() { copy.cliArgs = this.cliArgs != null ? this.cliArgs.clone() : null; copy.cliPath = this.cliPath; copy.cliUrl = this.cliUrl; + copy.connection = this.connection; copy.copilotHome = this.copilotHome; copy.cwd = this.cwd; copy.environment = this.environment != null ? new java.util.HashMap<>(this.environment) : null; diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/InProcessRuntimeConnection.java b/java/sdk/src/main/java/com/github/copilot/rpc/InProcessRuntimeConnection.java new file mode 100644 index 0000000000..274f8b89dc --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/rpc/InProcessRuntimeConnection.java @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.github.copilot.CopilotExperimental; + +/** + * Hosts the runtime in-process by loading its native library and communicating + * over the C ABI — no child process is spawned by the SDK for JSON-RPC + * transport. Construct with {@link RuntimeConnection#forInProcess()}. + *

+ * The in-process runtime is self-contained: it carries everything it needs and + * requires no external installation. Because it runs inside the host process, + * per-client process settings ({@code environment}, {@code telemetry}, + * {@code cwd}, and {@code cliArgs}) are rejected; configure those on the host + * process instead, or use a child-process connection. + * + * @since 1.0.0 + */ +@CopilotExperimental +public final class InProcessRuntimeConnection extends RuntimeConnection { + + InProcessRuntimeConnection() { + } +} diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/RuntimeConnection.java b/java/sdk/src/main/java/com/github/copilot/rpc/RuntimeConnection.java new file mode 100644 index 0000000000..0814803198 --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/rpc/RuntimeConnection.java @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.github.copilot.CopilotExperimental; + +/** + * Configures how a {@link com.github.copilot.CopilotClient} connects to the + * Copilot runtime. + *

+ * Instances are created through the factory methods on this class and assigned + * with {@link CopilotClientOptions#setConnection(RuntimeConnection)}: + * + *

{@code
+ * // Spawn a runtime child process and talk over stdin/stdout (the default).
+ * new CopilotClientOptions().setConnection(RuntimeConnection.forStdio());
+ *
+ * // Spawn a runtime child process listening on a TCP socket.
+ * new CopilotClientOptions().setConnection(RuntimeConnection.forTcp().setPath("/usr/local/bin/copilot"));
+ *
+ * // Connect to an already-running runtime.
+ * new CopilotClientOptions().setConnection(RuntimeConnection.forUri("localhost:3000"));
+ * }
+ * + * @since 1.0.0 + */ +public abstract sealed class RuntimeConnection + permits StdioRuntimeConnection, TcpRuntimeConnection, UriRuntimeConnection, InProcessRuntimeConnection { + + RuntimeConnection() { + } + + /** + * Spawns a runtime child process and communicates over its stdin/stdout. This + * is the default when no connection is configured. + * + * @return a new stdio connection + */ + public static StdioRuntimeConnection forStdio() { + return new StdioRuntimeConnection(); + } + + /** + * Spawns a runtime child process at the given path and communicates over its + * stdin/stdout. + * + * @param path + * path to the runtime executable, or {@code null} to use the runtime + * discovered on the {@code PATH} + * @return a new stdio connection + */ + public static StdioRuntimeConnection forStdio(String path) { + return new StdioRuntimeConnection().setPath(path); + } + + /** + * Spawns a runtime child process that listens on a TCP socket and connects to + * it. + * + * @return a new TCP connection + */ + public static TcpRuntimeConnection forTcp() { + return new TcpRuntimeConnection(); + } + + /** + * Connects to an already-running runtime at the given URL. + * + * @param url + * URL of the runtime to connect to; accepts {@code "port"}, + * {@code "host:port"}, or a full URL + * @return a new URI connection + * @throws IllegalArgumentException + * if {@code url} is {@code null} or empty + */ + public static UriRuntimeConnection forUri(String url) { + return new UriRuntimeConnection(url); + } + + /** + * Hosts the runtime in-process by loading its native library and communicating + * over the C ABI — no child process is spawned by the SDK for JSON-RPC + * transport. + * + * @return a new in-process connection + */ + @CopilotExperimental + public static InProcessRuntimeConnection forInProcess() { + return new InProcessRuntimeConnection(); + } +} diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/StdioRuntimeConnection.java b/java/sdk/src/main/java/com/github/copilot/rpc/StdioRuntimeConnection.java new file mode 100644 index 0000000000..d707d86bd4 --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/rpc/StdioRuntimeConnection.java @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import java.util.ArrayList; +import java.util.List; + +/** + * Spawns a runtime child process and communicates over its stdin/stdout. + * Construct with {@link RuntimeConnection#forStdio()} or + * {@link RuntimeConnection#forStdio(String)}. + * + * @since 1.0.0 + */ +public final class StdioRuntimeConnection extends RuntimeConnection { + + private String path; + private List args; + + StdioRuntimeConnection() { + } + + /** + * Returns the path to the runtime executable. + * + * @return the path, or {@code null} to use the runtime discovered on the + * {@code PATH} + */ + public String getPath() { + return path; + } + + /** + * Sets the path to the runtime executable. + * + * @param path + * the path, or {@code null} to use the runtime discovered on the + * {@code PATH} + * @return this instance for method chaining + */ + public StdioRuntimeConnection setPath(String path) { + this.path = path; + return this; + } + + /** + * Returns the extra command-line arguments passed to the runtime process. + * + * @return the arguments, or {@code null} if none are configured + */ + public List getArgs() { + return args; + } + + /** + * Sets extra command-line arguments passed to the runtime process. + * + * @param args + * the arguments, or {@code null} for none + * @return this instance for method chaining + */ + public StdioRuntimeConnection setArgs(List args) { + this.args = args == null ? null : new ArrayList<>(args); + return this; + } +} diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/TcpRuntimeConnection.java b/java/sdk/src/main/java/com/github/copilot/rpc/TcpRuntimeConnection.java new file mode 100644 index 0000000000..933bee1dcb --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/rpc/TcpRuntimeConnection.java @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import java.util.ArrayList; +import java.util.List; + +/** + * Spawns a runtime child process listening on a TCP socket and connects to it. + * Construct with {@link RuntimeConnection#forTcp()}. + * + * @since 1.0.0 + */ +public final class TcpRuntimeConnection extends RuntimeConnection { + + private String path; + private int port; + private String connectionToken; + private List args; + + TcpRuntimeConnection() { + } + + /** + * Returns the path to the runtime executable. + * + * @return the path, or {@code null} to use the runtime discovered on the + * {@code PATH} + */ + public String getPath() { + return path; + } + + /** + * Sets the path to the runtime executable. + * + * @param path + * the path, or {@code null} to use the runtime discovered on the + * {@code PATH} + * @return this instance for method chaining + */ + public TcpRuntimeConnection setPath(String path) { + this.path = path; + return this; + } + + /** + * Returns the TCP port the spawned runtime listens on. + * + * @return the port, or {@code 0} to auto-allocate a free port + */ + public int getPort() { + return port; + } + + /** + * Sets the TCP port the spawned runtime listens on. + * + * @param port + * the port, or {@code 0} (the default) to auto-allocate a free port + * @return this instance for method chaining + */ + public TcpRuntimeConnection setPort(int port) { + this.port = port; + return this; + } + + /** + * Returns the shared secret the SDK sends to the spawned runtime to + * authenticate the TCP connection. + * + * @return the token, or {@code null} to generate one automatically + */ + public String getConnectionToken() { + return connectionToken; + } + + /** + * Sets the shared secret the SDK sends to the spawned runtime to authenticate + * the TCP connection. + * + * @param connectionToken + * the token, or {@code null} to generate one automatically + * @return this instance for method chaining + */ + public TcpRuntimeConnection setConnectionToken(String connectionToken) { + this.connectionToken = connectionToken; + return this; + } + + /** + * Returns the extra command-line arguments passed to the runtime process. + * + * @return the arguments, or {@code null} if none are configured + */ + public List getArgs() { + return args; + } + + /** + * Sets extra command-line arguments passed to the runtime process. + * + * @param args + * the arguments, or {@code null} for none + * @return this instance for method chaining + */ + public TcpRuntimeConnection setArgs(List args) { + this.args = args == null ? null : new ArrayList<>(args); + return this; + } +} diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/UriRuntimeConnection.java b/java/sdk/src/main/java/com/github/copilot/rpc/UriRuntimeConnection.java new file mode 100644 index 0000000000..ce064d3b3a --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/rpc/UriRuntimeConnection.java @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +/** + * Connects to an already-running runtime at the configured URL. Construct with + * {@link RuntimeConnection#forUri(String)}. + * + * @since 1.0.0 + */ +public final class UriRuntimeConnection extends RuntimeConnection { + + private final String url; + private String connectionToken; + + UriRuntimeConnection(String url) { + if (url == null || url.isEmpty()) { + throw new IllegalArgumentException("UriRuntimeConnection url must be a non-empty string"); + } + this.url = url; + } + + /** + * Returns the URL of the runtime to connect to. + * + * @return the URL; accepts {@code "port"}, {@code "host:port"}, or a full URL + */ + public String getUrl() { + return url; + } + + /** + * Returns the shared secret used to authenticate the connection. + * + * @return the token, or {@code null} if the runtime does not require one + */ + public String getConnectionToken() { + return connectionToken; + } + + /** + * Sets the shared secret used to authenticate the connection. + * + * @param connectionToken + * the token, or {@code null} if the runtime does not require one + * @return this instance for method chaining + */ + public UriRuntimeConnection setConnectionToken(String connectionToken) { + this.connectionToken = connectionToken; + return this; + } +} diff --git a/java/sdk/src/test/java/com/github/copilot/CopilotClientTest.java b/java/sdk/src/test/java/com/github/copilot/CopilotClientTest.java index d977563aeb..067571df13 100644 --- a/java/sdk/src/test/java/com/github/copilot/CopilotClientTest.java +++ b/java/sdk/src/test/java/com/github/copilot/CopilotClientTest.java @@ -610,9 +610,9 @@ void testListModels_WithCustomHandler_WorksWithoutStart() throws Exception { private static void setConnectionFuture(CopilotClient client, JsonRpcClient rpc, Process process) throws Exception { var connectionClass = Class.forName("com.github.copilot.CopilotClient$Connection"); var constructor = connectionClass.getDeclaredConstructor(JsonRpcClient.class, Process.class, - com.github.copilot.generated.rpc.ServerRpc.class); + com.github.copilot.generated.rpc.ServerRpc.class, AutoCloseable.class); constructor.setAccessible(true); - var connection = constructor.newInstance(rpc, process, null); + var connection = constructor.newInstance(rpc, process, null, null); Field field = CopilotClient.class.getDeclaredField("connectionFuture"); field.setAccessible(true); diff --git a/java/sdk/src/test/java/com/github/copilot/CopilotClientTransportTest.java b/java/sdk/src/test/java/com/github/copilot/CopilotClientTransportTest.java new file mode 100644 index 0000000000..a0031e1134 --- /dev/null +++ b/java/sdk/src/test/java/com/github/copilot/CopilotClientTransportTest.java @@ -0,0 +1,383 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.channels.Channels; +import java.nio.channels.Pipe; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.InProcessRuntimeConnection; +import com.github.copilot.rpc.RuntimeConnection; +import com.github.copilot.rpc.StdioRuntimeConnection; +import com.github.copilot.rpc.TcpRuntimeConnection; +import com.github.copilot.rpc.TelemetryConfig; +import com.github.copilot.rpc.UriRuntimeConnection; + +/** + * Unit tests for transport selection through {@link RuntimeConnection}: the + * in-process code path, {@code COPILOT_SDK_DEFAULT_CONNECTION} resolution, the + * backward-compatibility bridge from the individual transport options, and + * option validation. + */ +@AllowCopilotExperimental +class CopilotClientTransportTest { + + /** + * These tests assert the transport the client resolves, so they only hold when + * the ambient environment does not override the default connection. + */ + private static void assumeNoDefaultConnectionOverride() { + assumeTrue(System.getenv(CopilotClient.DEFAULT_CONNECTION_ENV_VAR) == null, + CopilotClient.DEFAULT_CONNECTION_ENV_VAR + " is set in the environment"); + } + + // ===== In-process routing ===== + + @Test + void inProcessConnectionStartsThroughInProcessRuntimeHost() throws Exception { + var options = new CopilotClientOptions().setConnection(RuntimeConnection.forInProcess()); + try (var runtime = new FakeInProcessRuntime(); var client = new CopilotClient(options)) { + client.setInProcessTransportFactory(runtime::open); + + client.start().get(30, TimeUnit.SECONDS); + + assertTrue(runtime.opened.get(), "The in-process runtime must be used for an in-process connection"); + assertInstanceOf(InProcessRuntimeConnection.class, client.getRuntimeConnection()); + + client.stop().get(30, TimeUnit.SECONDS); + assertTrue(runtime.closed.get(), "Stopping the client must close the in-process runtime host"); + } + } + + @Test + void inProcessStartupFailurePropagates() { + var options = new CopilotClientOptions().setConnection(RuntimeConnection.forInProcess()); + try (var client = new CopilotClient(options)) { + client.setInProcessTransportFactory(opts -> { + throw new IOException("no runtime available"); + }); + var failure = assertThrows(Exception.class, () -> client.start().get(30, TimeUnit.SECONDS)); + assertTrue(rootMessage(failure).contains("no runtime available")); + } + } + + @Test + void cliTransportDoesNotUseTheInProcessRuntime() throws Exception { + assumeNoDefaultConnectionOverride(); + var options = new CopilotClientOptions().setCliUrl("127.0.0.1:1"); + try (var client = new CopilotClient(options)) { + client.setInProcessTransportFactory(opts -> { + throw new AssertionError("The in-process runtime must not be used for a CLI transport"); + }); + + assertThrows(Exception.class, () -> client.start().get(30, TimeUnit.SECONDS)); + assertInstanceOf(UriRuntimeConnection.class, client.getRuntimeConnection()); + } + } + + // ===== COPILOT_SDK_DEFAULT_CONNECTION resolution ===== + + @Test + void defaultConnectionEnvVarSelectsInProcess() { + var connection = CopilotClient.resolveDefaultConnection(new CopilotClientOptions(), "inprocess"); + assertInstanceOf(InProcessRuntimeConnection.class, connection); + assertInstanceOf(InProcessRuntimeConnection.class, + CopilotClient.resolveDefaultConnection(new CopilotClientOptions(), "InProcess")); + } + + @Test + void defaultConnectionEnvVarStdioAndUnsetKeepTheConfiguredTransport() { + assertInstanceOf(StdioRuntimeConnection.class, + CopilotClient.resolveDefaultConnection(new CopilotClientOptions(), "stdio")); + assertInstanceOf(StdioRuntimeConnection.class, + CopilotClient.resolveDefaultConnection(new CopilotClientOptions(), null)); + assertInstanceOf(TcpRuntimeConnection.class, + CopilotClient.resolveDefaultConnection(new CopilotClientOptions().setUseStdio(false), "")); + } + + @Test + void defaultConnectionEnvVarRejectsUnknownValues() { + var error = assertThrows(IllegalArgumentException.class, + () -> CopilotClient.resolveDefaultConnection(new CopilotClientOptions(), "websocket")); + assertTrue(error.getMessage().contains(CopilotClient.DEFAULT_CONNECTION_ENV_VAR)); + } + + // ===== Backward-compatibility bridge ===== + + @Test + void legacyStdioOptionsInferStdioConnection() { + assumeNoDefaultConnectionOverride(); + try (var client = new CopilotClient(new CopilotClientOptions().setCliPath("/usr/local/bin/copilot"))) { + var connection = assertInstanceOf(StdioRuntimeConnection.class, client.getRuntimeConnection()); + assertEquals("/usr/local/bin/copilot", connection.getPath()); + } + } + + @Test + void legacyTcpOptionsInferTcpConnection() { + assumeNoDefaultConnectionOverride(); + var options = new CopilotClientOptions().setUseStdio(false).setPort(4321).setTcpConnectionToken("secret"); + try (var client = new CopilotClient(options)) { + var connection = assertInstanceOf(TcpRuntimeConnection.class, client.getRuntimeConnection()); + assertEquals(4321, connection.getPort()); + assertEquals("secret", connection.getConnectionToken()); + } + } + + @Test + void legacyCliUrlInfersUriConnection() { + assumeNoDefaultConnectionOverride(); + try (var client = new CopilotClient(new CopilotClientOptions().setCliUrl("localhost:3000"))) { + var connection = assertInstanceOf(UriRuntimeConnection.class, client.getRuntimeConnection()); + assertEquals("localhost:3000", connection.getUrl()); + } + } + + // ===== Connection applied to the transport options ===== + + @Test + void connectionIsProjectedOntoTransportOptions() { + var stdio = new CopilotClientOptions().setConnection(RuntimeConnection.forStdio("/opt/copilot")); + try (var client = new CopilotClient(stdio)) { + assertTrue(stdio.isUseStdio()); + assertEquals("/opt/copilot", stdio.getCliPath()); + } + + var tcp = new CopilotClientOptions().setConnection( + RuntimeConnection.forTcp().setPort(4321).setConnectionToken("secret").setArgs(List.of("--extra"))); + try (var client = new CopilotClient(tcp)) { + assertFalse(tcp.isUseStdio()); + assertEquals(4321, tcp.getPort()); + assertEquals("secret", tcp.getTcpConnectionToken()); + assertEquals(List.of("--extra"), List.of(tcp.getCliArgs())); + } + + var uri = new CopilotClientOptions().setConnection(RuntimeConnection.forUri("localhost:3000")); + try (var client = new CopilotClient(uri)) { + assertFalse(uri.isUseStdio()); + assertEquals("localhost:3000", uri.getCliUrl()); + } + } + + // ===== Conflicting configuration ===== + + @Test + void connectionCannotBeCombinedWithTransportOptions() { + assertConflict(new CopilotClientOptions().setConnection(RuntimeConnection.forStdio()) + .setCliPath("/usr/local/bin/copilot"), "CliPath"); + assertConflict( + new CopilotClientOptions().setConnection(RuntimeConnection.forInProcess()).setCliUrl("localhost:3000"), + "CliUrl"); + assertConflict(new CopilotClientOptions().setConnection(RuntimeConnection.forStdio()).setUseStdio(false), + "UseStdio"); + assertConflict(new CopilotClientOptions().setConnection(RuntimeConnection.forTcp()).setPort(4321), "Port"); + assertConflict( + new CopilotClientOptions().setConnection(RuntimeConnection.forTcp()).setTcpConnectionToken("secret"), + "TcpConnectionToken"); + assertConflict(new CopilotClientOptions().setConnection(RuntimeConnection.forStdio()) + .setCliArgs(new String[]{"--extra"}), "CliArgs"); + } + + @Test + void connectionCanBeReusedForSeveralClients() { + var options = new CopilotClientOptions().setConnection(RuntimeConnection.forStdio("/opt/copilot")); + try (var first = new CopilotClient(options); var second = new CopilotClient(options)) { + assertInstanceOf(StdioRuntimeConnection.class, first.getRuntimeConnection()); + assertInstanceOf(StdioRuntimeConnection.class, second.getRuntimeConnection()); + } + } + + private static void assertConflict(CopilotClientOptions options, String optionName) { + var error = assertThrows(IllegalArgumentException.class, () -> new CopilotClient(options)); + assertTrue(error.getMessage().contains(optionName), "Expected '" + optionName + "' in: " + error.getMessage()); + } + + // ===== Options rejected for the in-process transport ===== + + @Test + void inProcessRejectsPerProcessOptions() { + assertInProcessRejected(new CopilotClientOptions().setEnvironment(Map.of("FOO", "bar")), "Environment"); + assertInProcessRejected(new CopilotClientOptions().setTelemetry(new TelemetryConfig()), "Telemetry"); + assertInProcessRejected(new CopilotClientOptions().setCwd("/tmp"), "Cwd"); + assertInProcessRejected(new CopilotClientOptions().setCliArgs(new String[]{"--extra"}), "CliArgs"); + } + + private static void assertInProcessRejected(CopilotClientOptions options, String optionName) { + options.setConnection(RuntimeConnection.forInProcess()); + var error = assertThrows(IllegalArgumentException.class, () -> new CopilotClient(options)); + assertTrue(error.getMessage().contains(optionName), "Expected '" + optionName + "' in: " + error.getMessage()); + assertTrue(error.getMessage().contains("forInProcess"), + "Expected the in-process transport to be named in: " + error.getMessage()); + } + + private static String rootMessage(Throwable error) { + Throwable cause = error; + while (cause.getCause() != null) { + cause = cause.getCause(); + } + return String.valueOf(cause.getMessage()); + } + + /** + * Minimal loopback stand-in for the in-process runtime: it speaks just enough + * JSON-RPC for {@link CopilotClient#start()} to complete, so the test can + * assert that the client wires its transport to the in-process host rather than + * to a child process. + */ + private static final class FakeInProcessRuntime implements AutoCloseable { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final AtomicBoolean opened = new AtomicBoolean(); + private final AtomicBoolean closed = new AtomicBoolean(); + private final BytePipe toClient; + private final BytePipe toRuntime; + private final InputStream runtimeInput; + private final OutputStream runtimeOutput; + private final Thread responder; + + FakeInProcessRuntime() throws IOException { + this.toClient = new BytePipe(); + this.toRuntime = new BytePipe(); + this.runtimeInput = toRuntime.inputStream(); + this.runtimeOutput = toClient.outputStream(); + this.responder = new Thread(this::respondToRequests, "fake-inprocess-runtime"); + this.responder.setDaemon(true); + this.responder.start(); + } + + CopilotClient.InProcessTransport open(CopilotClientOptions options) { + opened.set(true); + return new CopilotClient.InProcessTransport(toClient.inputStream(), toRuntime.outputStream(), this::close); + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + toRuntime.close(); + toClient.close(); + } + + private void respondToRequests() { + try { + while (!closed.get()) { + JsonNode request = readMessage(runtimeInput); + if (request == null) { + return; + } + if (!request.hasNonNull("id")) { + continue; + } + var response = MAPPER.createObjectNode(); + response.put("jsonrpc", "2.0"); + response.set("id", request.get("id")); + var result = response.putObject("result"); + if ("connect".equals(request.path("method").asText())) { + result.put("protocolVersion", SdkProtocolVersion.get()); + } + writeMessage(runtimeOutput, response); + } + } catch (IOException e) { + // The streams are closed when the client shuts down. + } + } + + private static JsonNode readMessage(InputStream in) throws IOException { + int contentLength = -1; + var line = new ByteArrayOutputStream(); + while (true) { + int b = in.read(); + if (b == -1) { + return null; + } + if (b == '\n') { + String header = line.toString(StandardCharsets.UTF_8).trim(); + line.reset(); + if (header.isEmpty()) { + break; + } + if (header.toLowerCase(Locale.ROOT).startsWith("content-length:")) { + contentLength = Integer.parseInt(header.substring(header.indexOf(':') + 1).trim()); + } + } else if (b != '\r') { + line.write(b); + } + } + if (contentLength < 0) { + throw new IOException("Missing Content-Length header"); + } + byte[] body = in.readNBytes(contentLength); + if (body.length != contentLength) { + return null; + } + return MAPPER.readTree(body); + } + + private static void writeMessage(OutputStream out, JsonNode message) throws IOException { + byte[] body = MAPPER.writeValueAsBytes(message); + out.write(("Content-Length: " + body.length + "\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(body); + out.flush(); + } + } + + /** + * Duplex byte channel used by {@link FakeInProcessRuntime} to emulate the + * streams of an in-process runtime. + */ + private static final class BytePipe { + + private final Pipe pipe; + + BytePipe() throws IOException { + this.pipe = Pipe.open(); + } + + InputStream inputStream() { + return Channels.newInputStream(pipe.source()); + } + + OutputStream outputStream() { + return Channels.newOutputStream(pipe.sink()); + } + + void close() { + closeQuietly(pipe.sink()); + closeQuietly(pipe.source()); + } + + private static void closeQuietly(Closeable closeable) { + try { + closeable.close(); + } catch (IOException e) { + // Nothing useful to do while tearing down a test pipe. + } + } + } +} diff --git a/java/sdk/src/test/java/com/github/copilot/CreateSessionReKeyEntryTest.java b/java/sdk/src/test/java/com/github/copilot/CreateSessionReKeyEntryTest.java index 156c968489..79e968cd39 100644 --- a/java/sdk/src/test/java/com/github/copilot/CreateSessionReKeyEntryTest.java +++ b/java/sdk/src/test/java/com/github/copilot/CreateSessionReKeyEntryTest.java @@ -170,8 +170,9 @@ private static void injectConnection(CopilotClient client, JsonRpcClient rpc) th var ctor = connClass.getDeclaredConstructors()[0]; ctor.setAccessible(true); - // Connection(JsonRpcClient rpc, Process process, ServerRpc serverRpc) - Object connection = ctor.newInstance(rpc, null, null); + // Connection(JsonRpcClient rpc, Process process, ServerRpc serverRpc, + // AutoCloseable runtimeHost) + Object connection = ctor.newInstance(rpc, null, null, null); Field f = CopilotClient.class.getDeclaredField("connectionFuture"); f.setAccessible(true); From 5415bf26b6da89c16c7033920eaf2ad1d0cdb90f Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Mon, 3 Aug 2026 20:42:50 +0000 Subject: [PATCH 3/3] Validate connection conflicts for env-var-resolved connections and fix Javadoc When COPILOT_SDK_DEFAULT_CONNECTION overrides transport inference (e.g. inprocess), also call validateConnectionConflicts so that stale legacy transport options are detected early. Update inferConnectionFromOptions to carry cliArgs onto the inferred connection so that validation does not incorrectly reject matching values. Fix CopilotClientOptions.setConnection() Javadoc to clarify that only conflicting legacy option values throw; matching values are accepted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../java/com/github/copilot/CopilotClient.java | 18 ++++++++++++++++-- .../copilot/rpc/CopilotClientOptions.java | 8 +++++--- 2 files changed, 21 insertions(+), 5 deletions(-) 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 e5809f8352..1ca18dc1ad 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java @@ -154,6 +154,11 @@ public CopilotClient(CopilotClientOptions options) { } else { requestedConnection = resolveDefaultConnection(this.options); validateEnvironmentOptions(this.options, requestedConnection); + // When the env var overrides inference (e.g. inprocess), validate that + // no legacy transport options conflict with the resolved connection. + if (requestedConnection != null) { + validateConnectionConflicts(this.options, requestedConnection); + } } this.runtimeConnection = requestedConnection; @@ -266,14 +271,23 @@ static RuntimeConnection resolveDefaultConnection(CopilotClientOptions options, */ private static RuntimeConnection inferConnectionFromOptions(CopilotClientOptions options) { String cliUrl = options.getCliUrl(); + List args = options.getCliArgs() != null ? Arrays.asList(options.getCliArgs()) : null; if (cliUrl != null && !cliUrl.isEmpty()) { return RuntimeConnection.forUri(cliUrl).setConnectionToken(options.getTcpConnectionToken()); } if (options.isUseStdio()) { - return RuntimeConnection.forStdio(options.getCliPath()); + StdioRuntimeConnection stdio = RuntimeConnection.forStdio(options.getCliPath()); + if (args != null) { + stdio.setArgs(args); + } + return stdio; } - return RuntimeConnection.forTcp().setPath(options.getCliPath()).setPort(options.getPort()) + TcpRuntimeConnection tcp = RuntimeConnection.forTcp().setPath(options.getCliPath()).setPort(options.getPort()) .setConnectionToken(options.getTcpConnectionToken()); + if (args != null) { + tcp.setArgs(args); + } + return tcp; } /** 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 cb43ad0f1a..d1b52f4fe6 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 @@ -222,9 +222,11 @@ public RuntimeConnection getConnection() { * When set, the connection takes precedence over the transport-selecting * options {@link #setUseStdio(boolean)}, {@link #setCliUrl(String)}, * {@link #setCliPath(String)}, {@link #setPort(int)} and - * {@link #setTcpConnectionToken(String)}; combining a connection with any of - * those options makes the client constructor throw - * {@link IllegalArgumentException}. + * {@link #setTcpConnectionToken(String)}; combining a connection with + * conflicting values for any of those options makes the client constructor + * throw {@link IllegalArgumentException}. Values that match what the connection + * implies are accepted, so the same options instance can be reused across + * multiple client constructions. * * @param connection * the connection, or {@code null} to infer the transport from the