Skip to content

Commit 09425c2

Browse files
Copilotedburns
andauthored
Port copilotHome, tcpConnectionToken, and instructionDirectories from reference implementation
Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
1 parent 5620113 commit 09425c2

8 files changed

Lines changed: 220 additions & 9 deletions

src/main/java/com/github/copilot/sdk/CliServerManager.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,23 @@ final class CliServerManager {
3333

3434
private final CopilotClientOptions options;
3535
private final StringBuilder stderrBuffer = new StringBuilder();
36+
private String connectionToken;
3637

3738
CliServerManager(CopilotClientOptions options) {
3839
this.options = options;
3940
}
4041

42+
/**
43+
* Sets the connection token to pass to the CLI process via environment
44+
* variable.
45+
*
46+
* @param connectionToken
47+
* the token, or {@code null} if not applicable
48+
*/
49+
void setConnectionToken(String connectionToken) {
50+
this.connectionToken = connectionToken;
51+
}
52+
4153
/**
4254
* Starts the CLI server process.
4355
*
@@ -115,6 +127,16 @@ ProcessInfo startCliServer() throws IOException, InterruptedException {
115127
pb.environment().put("COPILOT_SDK_AUTH_TOKEN", options.getGitHubToken());
116128
}
117129

130+
// Set Copilot home directory if configured
131+
if (options.getCopilotHome() != null && !options.getCopilotHome().isEmpty()) {
132+
pb.environment().put("COPILOT_HOME", options.getCopilotHome());
133+
}
134+
135+
// Set connection token for TCP mode
136+
if (connectionToken != null && !connectionToken.isEmpty()) {
137+
pb.environment().put("COPILOT_CONNECTION_TOKEN", connectionToken);
138+
}
139+
118140
// Set telemetry environment variables if configured
119141
if (options.getTelemetry() != null) {
120142
var telemetry = options.getTelemetry();

src/main/java/com/github/copilot/sdk/CopilotClient.java

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ public final class CopilotClient implements AutoCloseable {
8383
private volatile boolean disposed = false;
8484
private final String optionsHost;
8585
private final Integer optionsPort;
86+
private final String effectiveConnectionToken;
8687
private volatile List<ModelInfo> modelsCache;
8788
private final Object modelsCacheLock = new Object();
8889

@@ -122,6 +123,24 @@ public CopilotClient(CopilotClientOptions options) {
122123
"GitHubToken and UseLoggedInUser cannot be used with CliUrl (external server manages its own auth)");
123124
}
124125

126+
// Validate tcpConnectionToken
127+
if (this.options.getTcpConnectionToken() != null) {
128+
if (this.options.getTcpConnectionToken().isEmpty()) {
129+
throw new IllegalArgumentException("TcpConnectionToken must be a non-empty string");
130+
}
131+
if (this.options.isUseStdio()) {
132+
throw new IllegalArgumentException("TcpConnectionToken cannot be used with UseStdio = true");
133+
}
134+
}
135+
136+
// Compute effective connection token: use provided, or auto-generate for
137+
// SDK-spawned TCP mode, or null for stdio/external server
138+
boolean sdkSpawnsCli = !this.options.isUseStdio()
139+
&& (this.options.getCliUrl() == null || this.options.getCliUrl().isEmpty());
140+
this.effectiveConnectionToken = this.options.getTcpConnectionToken() != null
141+
? this.options.getTcpConnectionToken()
142+
: (sdkSpawnsCli ? java.util.UUID.randomUUID().toString() : null);
143+
125144
// Parse CliUrl if provided
126145
if (this.options.getCliUrl() != null && !this.options.getCliUrl().isEmpty()) {
127146
URI uri = CliServerManager.parseCliUrl(this.options.getCliUrl());
@@ -133,6 +152,7 @@ public CopilotClient(CopilotClientOptions options) {
133152
}
134153

135154
this.serverManager = new CliServerManager(this.options);
155+
this.serverManager.setConnectionToken(this.effectiveConnectionToken);
136156
}
137157

138158
/**
@@ -202,27 +222,57 @@ private Connection startCoreBody() {
202222
}
203223

204224
private static final int MIN_PROTOCOL_VERSION = 2;
225+
private static final int METHOD_NOT_FOUND_ERROR_CODE = -32601;
205226

206227
private void verifyProtocolVersion(Connection connection) throws Exception {
207228
int expectedVersion = SdkProtocolVersion.get();
208-
var params = new HashMap<String, Object>();
209-
params.put("message", null);
210-
PingResponse pingResponse = connection.rpc.invoke("ping", params, PingResponse.class).get(30, TimeUnit.SECONDS);
229+
Integer serverVersion;
230+
231+
try {
232+
// Try the new 'connect' RPC which supports connection tokens
233+
var connectParams = new HashMap<String, Object>();
234+
connectParams.put("token", effectiveConnectionToken);
235+
var connectResponse = connection.rpc.invoke("connect", connectParams, ConnectResult.class).get(30,
236+
TimeUnit.SECONDS);
237+
serverVersion = connectResponse.protocolVersion();
238+
} catch (Exception e) {
239+
// Unwrap CompletionException/ExecutionException to check inner cause
240+
Throwable cause = e;
241+
while (cause instanceof java.util.concurrent.ExecutionException || cause instanceof CompletionException) {
242+
cause = cause.getCause();
243+
}
244+
if (cause instanceof JsonRpcException rpcEx && rpcEx.getCode() == METHOD_NOT_FOUND_ERROR_CODE) {
245+
// Legacy server without 'connect'; fall back to 'ping'.
246+
// A token, if any, is silently dropped — the legacy server can't enforce one.
247+
var params = new HashMap<String, Object>();
248+
params.put("message", null);
249+
PingResponse pingResponse = connection.rpc.invoke("ping", params, PingResponse.class).get(30,
250+
TimeUnit.SECONDS);
251+
serverVersion = pingResponse.protocolVersion();
252+
} else {
253+
throw e;
254+
}
255+
}
211256

212-
if (pingResponse.protocolVersion() == null) {
213-
throw new RuntimeException("SDK protocol version mismatch: SDK expects version " + expectedVersion
214-
+ ", but server does not report a protocol version. "
257+
if (serverVersion == null) {
258+
throw new RuntimeException("SDK protocol version mismatch: SDK supports versions " + MIN_PROTOCOL_VERSION
259+
+ "-" + expectedVersion + ", but server does not report a protocol version. "
215260
+ "Please update your server to ensure compatibility.");
216261
}
217262

218-
int serverVersion = pingResponse.protocolVersion();
219263
if (serverVersion < MIN_PROTOCOL_VERSION || serverVersion > expectedVersion) {
220-
throw new RuntimeException("SDK protocol version mismatch: SDK expects version " + expectedVersion
221-
+ " (minimum " + MIN_PROTOCOL_VERSION + "), but server reports version " + serverVersion + ". "
264+
throw new RuntimeException("SDK protocol version mismatch: SDK supports versions " + MIN_PROTOCOL_VERSION
265+
+ "-" + expectedVersion + ", but server reports version " + serverVersion + ". "
222266
+ "Please update your SDK or server to ensure compatibility.");
223267
}
224268
}
225269

270+
/**
271+
* Internal record for the 'connect' RPC response.
272+
*/
273+
record ConnectResult(Integer protocolVersion) {
274+
}
275+
226276
/**
227277
* Disconnects from the Copilot server and closes all active sessions.
228278
* <p>

src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess
122122
request.setAgent(config.getAgent());
123123
request.setInfiniteSessions(config.getInfiniteSessions());
124124
request.setSkillDirectories(config.getSkillDirectories());
125+
request.setInstructionDirectories(config.getInstructionDirectories());
125126
request.setDisabledSkills(config.getDisabledSkills());
126127
request.setConfigDir(config.getConfigDir());
127128
request.setEnableConfigDiscovery(config.getEnableConfigDiscovery());
@@ -199,6 +200,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo
199200
request.setDefaultAgent(config.getDefaultAgent());
200201
request.setAgent(config.getAgent());
201202
request.setSkillDirectories(config.getSkillDirectories());
203+
request.setInstructionDirectories(config.getInstructionDirectories());
202204
request.setDisabledSkills(config.getDisabledSkills());
203205
request.setInfiniteSessions(config.getInfiniteSessions());
204206
request.setModelCapabilities(config.getModelCapabilities());

src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public class CopilotClientOptions {
4444
private String[] cliArgs;
4545
private String cliPath;
4646
private String cliUrl;
47+
private String copilotHome;
4748
private String cwd;
4849
private Map<String, String> environment;
4950
private Executor executor;
@@ -53,6 +54,7 @@ public class CopilotClientOptions {
5354
private int port;
5455
private TelemetryConfig telemetry;
5556
private Integer sessionIdleTimeoutSeconds;
57+
private String tcpConnectionToken;
5658
private Boolean useLoggedInUser;
5759
private boolean useStdio = true;
5860

@@ -191,6 +193,34 @@ public CopilotClientOptions setCliUrl(String cliUrl) {
191193
return this;
192194
}
193195

196+
/**
197+
* Gets the base directory for Copilot data (session state, config, etc.).
198+
*
199+
* @return the Copilot home directory path, or {@code null} to use the CLI
200+
* default ({@code ~/.copilot})
201+
*/
202+
public String getCopilotHome() {
203+
return copilotHome;
204+
}
205+
206+
/**
207+
* Sets the base directory for Copilot data (session state, config, etc.).
208+
* <p>
209+
* Sets the {@code COPILOT_HOME} environment variable on the spawned CLI
210+
* process. When {@code null}, the CLI defaults to {@code ~/.copilot}.
211+
* <p>
212+
* This option is only used when the SDK spawns the CLI process; it is ignored
213+
* when connecting to an external server via {@link #setCliUrl(String)}.
214+
*
215+
* @param copilotHome
216+
* the Copilot home directory path (must not be {@code null})
217+
* @return this options instance for method chaining
218+
*/
219+
public CopilotClientOptions setCopilotHome(String copilotHome) {
220+
this.copilotHome = Objects.requireNonNull(copilotHome, "copilotHome must not be null");
221+
return this;
222+
}
223+
194224
/**
195225
* Gets the working directory for the CLI process.
196226
*
@@ -462,6 +492,31 @@ public CopilotClientOptions setSessionIdleTimeoutSeconds(Integer sessionIdleTime
462492
return this;
463493
}
464494

495+
/**
496+
* Gets the connection token for the headless CLI server (TCP only).
497+
*
498+
* @return the connection token, or {@code null} if not set
499+
*/
500+
public String getTcpConnectionToken() {
501+
return tcpConnectionToken;
502+
}
503+
504+
/**
505+
* Sets the connection token for the headless CLI server (TCP only).
506+
* <p>
507+
* When the SDK spawns its own CLI in TCP mode and this is omitted, a UUID is
508+
* generated automatically so the loopback listener is safe by default. Cannot
509+
* be combined with {@link #setUseStdio(boolean)} = {@code true}.
510+
*
511+
* @param tcpConnectionToken
512+
* the connection token (must not be {@code null} or empty)
513+
* @return this options instance for method chaining
514+
*/
515+
public CopilotClientOptions setTcpConnectionToken(String tcpConnectionToken) {
516+
this.tcpConnectionToken = Objects.requireNonNull(tcpConnectionToken, "tcpConnectionToken must not be null");
517+
return this;
518+
}
519+
465520
/**
466521
* Returns whether to use the logged-in user for authentication.
467522
*
@@ -533,6 +588,7 @@ public CopilotClientOptions clone() {
533588
copy.cliArgs = this.cliArgs != null ? this.cliArgs.clone() : null;
534589
copy.cliPath = this.cliPath;
535590
copy.cliUrl = this.cliUrl;
591+
copy.copilotHome = this.copilotHome;
536592
copy.cwd = this.cwd;
537593
copy.environment = this.environment != null ? new java.util.HashMap<>(this.environment) : null;
538594
copy.executor = this.executor;
@@ -541,6 +597,7 @@ public CopilotClientOptions clone() {
541597
copy.onListModels = this.onListModels;
542598
copy.port = this.port;
543599
copy.sessionIdleTimeoutSeconds = this.sessionIdleTimeoutSeconds;
600+
copy.tcpConnectionToken = this.tcpConnectionToken;
544601
copy.telemetry = this.telemetry;
545602
copy.useLoggedInUser = this.useLoggedInUser;
546603
copy.useStdio = this.useStdio;

src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ public final class CreateSessionRequest {
9191
@JsonProperty("skillDirectories")
9292
private List<String> skillDirectories;
9393

94+
@JsonProperty("instructionDirectories")
95+
private List<String> instructionDirectories;
96+
9497
@JsonProperty("disabledSkills")
9598
private List<String> disabledSkills;
9699

@@ -326,6 +329,18 @@ public void setSkillDirectories(List<String> skillDirectories) {
326329
this.skillDirectories = skillDirectories;
327330
}
328331

332+
/** Gets instruction directories. @return the instruction directories */
333+
public List<String> getInstructionDirectories() {
334+
return instructionDirectories == null ? null : Collections.unmodifiableList(instructionDirectories);
335+
}
336+
337+
/**
338+
* Sets instruction directories. @param instructionDirectories the directories
339+
*/
340+
public void setInstructionDirectories(List<String> instructionDirectories) {
341+
this.instructionDirectories = instructionDirectories;
342+
}
343+
329344
/** Gets disabled skills. @return the disabled skill names */
330345
public List<String> getDisabledSkills() {
331346
return disabledSkills == null ? null : Collections.unmodifiableList(disabledSkills);

src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ public class ResumeSessionConfig {
5959
private DefaultAgentConfig defaultAgent;
6060
private String agent;
6161
private List<String> skillDirectories;
62+
private List<String> instructionDirectories;
6263
private List<String> disabledSkills;
6364
private InfiniteSessionConfig infiniteSessions;
6465
private Consumer<SessionEvent> onEvent;
@@ -591,6 +592,27 @@ public ResumeSessionConfig setSkillDirectories(List<String> skillDirectories) {
591592
return this;
592593
}
593594

595+
/**
596+
* Gets the additional directories to search for custom instruction files.
597+
*
598+
* @return the list of instruction directory paths
599+
*/
600+
public List<String> getInstructionDirectories() {
601+
return instructionDirectories == null ? null : Collections.unmodifiableList(instructionDirectories);
602+
}
603+
604+
/**
605+
* Sets additional directories to search for custom instruction files.
606+
*
607+
* @param instructionDirectories
608+
* the list of instruction directory paths
609+
* @return this config for method chaining
610+
*/
611+
public ResumeSessionConfig setInstructionDirectories(List<String> instructionDirectories) {
612+
this.instructionDirectories = instructionDirectories;
613+
return this;
614+
}
615+
594616
/**
595617
* Gets the disabled skills.
596618
*
@@ -775,6 +797,9 @@ public ResumeSessionConfig clone() {
775797
copy.defaultAgent = this.defaultAgent;
776798
copy.agent = this.agent;
777799
copy.skillDirectories = this.skillDirectories != null ? new ArrayList<>(this.skillDirectories) : null;
800+
copy.instructionDirectories = this.instructionDirectories != null
801+
? new ArrayList<>(this.instructionDirectories)
802+
: null;
778803
copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null;
779804
copy.infiniteSessions = this.infiniteSessions;
780805
copy.onEvent = this.onEvent;

src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ public final class ResumeSessionRequest {
9898
@JsonProperty("skillDirectories")
9999
private List<String> skillDirectories;
100100

101+
@JsonProperty("instructionDirectories")
102+
private List<String> instructionDirectories;
103+
101104
@JsonProperty("disabledSkills")
102105
private List<String> disabledSkills;
103106

@@ -366,6 +369,18 @@ public void setSkillDirectories(List<String> skillDirectories) {
366369
this.skillDirectories = skillDirectories;
367370
}
368371

372+
/** Gets instruction directories. @return the instruction directories */
373+
public List<String> getInstructionDirectories() {
374+
return instructionDirectories == null ? null : Collections.unmodifiableList(instructionDirectories);
375+
}
376+
377+
/**
378+
* Sets instruction directories. @param instructionDirectories the directories
379+
*/
380+
public void setInstructionDirectories(List<String> instructionDirectories) {
381+
this.instructionDirectories = instructionDirectories;
382+
}
383+
369384
/** Gets disabled skills. @return the disabled skill names */
370385
public List<String> getDisabledSkills() {
371386
return disabledSkills == null ? null : Collections.unmodifiableList(disabledSkills);

0 commit comments

Comments
 (0)