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..1ca18dc1ad 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,24 @@ 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); + // 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; + // 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 +228,275 @@ 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(); + 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()) { + StdioRuntimeConnection stdio = RuntimeConnection.forStdio(options.getCliPath()); + if (args != null) { + stdio.setArgs(args); + } + return stdio; + } + TcpRuntimeConnection tcp = RuntimeConnection.forTcp().setPath(options.getCliPath()).setPort(options.getPort()) + .setConnectionToken(options.getTcpConnectionToken()); + if (args != null) { + tcp.setArgs(args); + } + return tcp; + } + + /** + * 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 +525,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 +548,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 +593,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 +745,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 +772,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 +1702,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..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 @@ -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,39 @@ 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 + * 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 + * 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 +788,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);