-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathCliServerManager.java
More file actions
288 lines (247 loc) · 10.4 KB
/
CliServerManager.java
File metadata and controls
288 lines (247 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
/*---------------------------------------------------------------------------------------------
* 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;
import com.github.copilot.sdk.json.TelemetryConfig;
/**
* Manages the lifecycle of the Copilot CLI server process.
* <p>
* 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<String>();
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<String> 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());
}
// Set telemetry environment variables if configured
TelemetryConfig telemetry = options.getTelemetry();
if (telemetry != null) {
pb.environment().put("COPILOT_OTEL_ENABLED", "true");
if (telemetry.getOtlpEndpoint() != null) {
pb.environment().put("OTEL_EXPORTER_OTLP_ENDPOINT", telemetry.getOtlpEndpoint());
}
if (telemetry.getFilePath() != null) {
pb.environment().put("COPILOT_OTEL_FILE_EXPORTER_PATH", telemetry.getFilePath());
}
if (telemetry.getExporterType() != null) {
pb.environment().put("COPILOT_OTEL_EXPORTER_TYPE", telemetry.getExporterType());
}
if (telemetry.getSourceName() != null) {
pb.environment().put("COPILOT_OTEL_SOURCE_NAME", telemetry.getSourceName());
}
if (telemetry.getCaptureContent() != null) {
pb.environment().put("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT",
telemetry.getCaptureContent() ? "true" : "false");
}
}
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<String> resolveCliCommand(String cliPath, List<String> args) {
boolean isJsFile = cliPath.toLowerCase().endsWith(".js");
if (isJsFile) {
var result = new ArrayList<String>();
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<String>();
result.add("cmd");
result.add("/c");
result.add(cliPath);
result.addAll(args);
return result;
}
var result = new ArrayList<String>();
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) {
}
}