/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ package com.github.copilot.sdk; import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; import java.net.Socket; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import com.github.copilot.sdk.json.CopilotClientOptions; /** * Manages the lifecycle of the Copilot CLI server process. *

* This class handles spawning the CLI server process, building command lines, * detecting the listening port, and establishing connections. */ final class CliServerManager { private static final Logger LOG = Logger.getLogger(CliServerManager.class.getName()); private final CopilotClientOptions options; private final StringBuilder stderrBuffer = new StringBuilder(); CliServerManager(CopilotClientOptions options) { this.options = options; } /** * Starts the CLI server process. * * @return information about the started process including detected port * @throws IOException * if the process cannot be started * @throws InterruptedException * if interrupted while waiting for port detection */ ProcessInfo startCliServer() throws IOException, InterruptedException { clearStderrBuffer(); String cliPath = options.getCliPath() != null ? options.getCliPath() : "copilot"; var args = new ArrayList(); if (options.getCliArgs() != null) { args.addAll(Arrays.asList(options.getCliArgs())); } args.add("--server"); args.add("--no-auto-update"); args.add("--log-level"); args.add(options.getLogLevel()); if (options.isUseStdio()) { args.add("--stdio"); } else if (options.getPort() > 0) { args.add("--port"); args.add(String.valueOf(options.getPort())); } // Add auth-related flags if (options.getGitHubToken() != null && !options.getGitHubToken().isEmpty()) { args.add("--auth-token-env"); args.add("COPILOT_SDK_AUTH_TOKEN"); } // Default UseLoggedInUser to false when GitHubToken is provided boolean useLoggedInUser = options.getUseLoggedInUser() != null ? options.getUseLoggedInUser() : (options.getGitHubToken() == null || options.getGitHubToken().isEmpty()); if (!useLoggedInUser) { args.add("--no-auto-login"); } List command = resolveCliCommand(cliPath, args); var pb = new ProcessBuilder(command); pb.redirectErrorStream(false); // Note: On Windows, console window visibility depends on how the parent Java // process was launched. GUI applications started with 'javaw' will not create // visible console windows for subprocesses. Console applications started with // 'java' will share their console with subprocesses. Java's ProcessBuilder // doesn't provide explicit CREATE_NO_WINDOW flags like native Windows APIs, // but the default behavior is appropriate for most use cases. if (options.getCwd() != null) { pb.directory(new File(options.getCwd())); } if (options.getEnvironment() != null) { pb.environment().clear(); pb.environment().putAll(options.getEnvironment()); } pb.environment().remove("NODE_DEBUG"); // Set auth token in environment if provided if (options.getGitHubToken() != null && !options.getGitHubToken().isEmpty()) { pb.environment().put("COPILOT_SDK_AUTH_TOKEN", options.getGitHubToken()); } Process process = pb.start(); // Forward stderr to logger in background startStderrReader(process); Integer detectedPort = null; if (!options.isUseStdio()) { detectedPort = waitForPortAnnouncement(process); } return new ProcessInfo(process, detectedPort); } /** * Connects to a running Copilot server. * * @param process * the CLI process (null if connecting to external server) * @param tcpHost * the host to connect to (null for stdio mode) * @param tcpPort * the port to connect to (null for stdio mode) * @return the JSON-RPC client connected to the server * @throws IOException * if connection fails */ JsonRpcClient connectToServer(Process process, String tcpHost, Integer tcpPort) throws IOException { if (tcpHost != null && tcpPort != null) { // TCP mode: external server or child process with explicit port Socket socket = new Socket(tcpHost, tcpPort); return JsonRpcClient.fromSocket(socket); } else if (process != null) { // Stdio mode: child process return JsonRpcClient.fromProcess(process); } else { throw new IllegalStateException("Cannot connect: no process for stdio and no host:port for TCP"); } } private void startStderrReader(Process process) { var stderrThread = new Thread(() -> { try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { synchronized (stderrBuffer) { stderrBuffer.append(line).append('\n'); } LOG.fine("[CLI] " + line); } } catch (IOException e) { LOG.log(Level.FINE, "Error reading stderr", e); } }, "cli-stderr-reader"); stderrThread.setDaemon(true); stderrThread.start(); } private Integer waitForPortAnnouncement(Process process) throws IOException { try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { Pattern portPattern = Pattern.compile("listening on port (\\d+)", Pattern.CASE_INSENSITIVE); long deadline = System.currentTimeMillis() + 30000; while (System.currentTimeMillis() < deadline) { String line = reader.readLine(); if (line == null) { String stderr = getStderrOutput(); if (!stderr.isEmpty()) { throw new IOException("CLI process exited unexpectedly. stderr: " + stderr); } throw new IOException("CLI process exited unexpectedly"); } Matcher matcher = portPattern.matcher(line); if (matcher.find()) { return Integer.parseInt(matcher.group(1)); } } process.destroyForcibly(); throw new IOException("Timeout waiting for CLI to announce port"); } } String getStderrOutput() { synchronized (stderrBuffer) { return stderrBuffer.toString().trim(); } } private void clearStderrBuffer() { synchronized (stderrBuffer) { stderrBuffer.setLength(0); } } private List resolveCliCommand(String cliPath, List args) { boolean isJsFile = cliPath.toLowerCase().endsWith(".js"); if (isJsFile) { var result = new ArrayList(); result.add("node"); result.add(cliPath); result.addAll(args); return result; } // On Windows, use cmd /c to resolve the executable String os = System.getProperty("os.name").toLowerCase(); if (os.contains("win") && !new File(cliPath).isAbsolute()) { var result = new ArrayList(); result.add("cmd"); result.add("/c"); result.add(cliPath); result.addAll(args); return result; } var result = new ArrayList(); result.add(cliPath); result.addAll(args); return result; } static URI parseCliUrl(String url) { // If it's just a port number, treat as localhost try { int port = Integer.parseInt(url); return URI.create("http://localhost:" + port); } catch (NumberFormatException e) { // Not a port number, continue } // Add scheme if missing if (!url.toLowerCase().startsWith("http://") && !url.toLowerCase().startsWith("https://")) { url = "https://" + url; } return URI.create(url); } /** * Information about a started CLI server process. * * @param process * the CLI process * @param port * the detected TCP port (null for stdio mode) */ record ProcessInfo(Process process, Integer port) { } }