Skip to content

Commit 3e24aba

Browse files
python: add in-process (FFI) transport (#1975)
1 parent 728d513 commit 3e24aba

27 files changed

Lines changed: 1678 additions & 179 deletions

.github/workflows/python-sdk-tests.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ permissions:
3131

3232
jobs:
3333
test:
34-
name: "Python SDK Tests"
34+
name: "Python SDK Tests (${{ matrix.os }}, ${{ matrix.transport }})"
3535
if: github.event.repository.fork == false
3636
env:
3737
POWERSHELL_UPDATECHECK: Off
@@ -41,6 +41,7 @@ jobs:
4141
os: [ubuntu-latest, macos-latest, windows-latest]
4242
# Test the oldest supported Python version to make sure compatibility is maintained.
4343
python-version: ["3.11"]
44+
transport: ["default", "inprocess"]
4445
runs-on: ${{ matrix.os }}
4546
defaults:
4647
run:
@@ -86,6 +87,11 @@ jobs:
8687
if: runner.os == 'Windows'
8788
run: pwsh.exe -Command "Write-Host 'PowerShell ready'"
8889

90+
- name: Select inprocess transport
91+
if: matrix.transport == 'inprocess'
92+
run: |
93+
echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV"
94+
8995
- name: Run Python SDK tests
9096
env:
9197
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}

dotnet/src/Client.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,17 @@ private static void ValidateEnvironmentOptions(CopilotClientOptions options, Run
237237
nameof(options));
238238
}
239239

240+
if (options.WorkingDirectory is not null)
241+
{
242+
throw new ArgumentException(
243+
$"{nameof(CopilotClientOptions)}.{nameof(CopilotClientOptions.WorkingDirectory)} is not supported with " +
244+
$"{nameof(RuntimeConnection)}.{nameof(RuntimeConnection.ForInProcess)}(): the in-process transport hosts " +
245+
"the native runtime in the shared host process and spawns the worker without a working-directory " +
246+
"parameter, so a per-client working directory cannot be honored in-process. Use a child-process " +
247+
"transport, or set the process working directory before creating the client.",
248+
nameof(options));
249+
}
250+
240251
return;
241252
}
242253

dotnet/test/Harness/E2ETestContext.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,17 @@ public CopilotClient CreateClient(
244244
{
245245
options ??= new CopilotClientOptions();
246246

247-
options.WorkingDirectory ??= WorkDir;
248247
options.Logger ??= Logger;
249248

249+
// Resolve the working directory the worker should run in. Child-process and
250+
// URI transports take it as a per-client option; the in-process transport
251+
// rejects a per-client WorkingDirectory (the native host spawns the worker
252+
// without a cwd parameter), so — mirroring the Node/Rust harnesses — we point
253+
// THIS process's cwd at the desired directory before the worker spawns and
254+
// clear the per-client option. InProcessEnvIsolationAttribute.After restores
255+
// the cwd after the test.
256+
var desiredWorkingDirectory = options.WorkingDirectory ?? WorkDir;
257+
250258
// Tests must supply environment via the 'environment' parameter, which the
251259
// harness routes to the right place per transport (the connection for
252260
// child-process transports, the host process for in-process). Setting
@@ -300,12 +308,24 @@ public CopilotClient CreateClient(
300308
{
301309
InProcessEnvIsolation.Apply(name, value);
302310
}
311+
312+
// A per-client WorkingDirectory is rejected in-process; instead point this
313+
// process's cwd at the desired directory so the worker inherits it at spawn
314+
// (restored after the test by InProcessEnvIsolationAttribute).
315+
options.WorkingDirectory = null;
316+
InProcessEnvIsolation.SetWorkingDirectory(desiredWorkingDirectory);
303317
}
304318
else if (options.Connection is ChildProcessRuntimeConnection child)
305319
{
306320
// Child-process transport: hand the environment to the spawned child
307321
// via the connection, where per-client environment is coherent.
308322
child.Environment = env;
323+
options.WorkingDirectory = desiredWorkingDirectory;
324+
}
325+
else
326+
{
327+
// URI / existing-runtime transport: per-client WorkingDirectory applies normally.
328+
options.WorkingDirectory = desiredWorkingDirectory;
309329
}
310330

311331
// Auto-inject auth token unless connecting to an existing runtime via URI.

dotnet/test/Harness/InProcessEnvIsolation.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ internal static class InProcessEnvIsolation
2222
// Captured at load, before any fixture/test mutates env.
2323
private static readonly Dictionary<string, string?> s_ambient = CaptureEnvironment();
2424

25+
// The process working directory captured at load, restored after each test so an
26+
// in-process test that repoints the cwd (the FFI worker inherits it at spawn)
27+
// can't leak that change into the next test.
28+
private static readonly string s_ambientCwd = Directory.GetCurrentDirectory();
29+
2530
// Runs at assembly load so the ambient env is snapshotted before the shared
2631
// fixture mirrors per-test env onto the process. Justifies suppressing CA2255.
2732
#pragma warning disable CA2255 // ModuleInitializer discouraged in libraries; intentional in this test harness.
@@ -56,8 +61,21 @@ public static void NeutralizeAmbientCredentials()
5661
}
5762
}
5863

64+
// Points the process working directory at the given path so the in-process FFI
65+
// worker inherits it at spawn (the native host has no per-client cwd parameter).
66+
// RestoreAmbient() returns the process to its load-time cwd after the test.
67+
public static void SetWorkingDirectory(string path) =>
68+
Directory.SetCurrentDirectory(path);
69+
5970
public static void RestoreAmbient()
6071
{
72+
// Unconditionally repoint the process cwd at its load-time value. We must
73+
// not read Directory.GetCurrentDirectory() first: an in-process test can
74+
// chdir into a temp work dir that the harness then deletes, so getcwd()
75+
// would throw FileNotFoundException. SetCurrentDirectory to an absolute
76+
// path succeeds regardless of whether the old cwd still exists.
77+
Directory.SetCurrentDirectory(s_ambientCwd);
78+
6179
foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables())
6280
{
6381
var name = (string)entry.Key;

nodejs/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,11 @@ new CopilotClient(options?: CopilotClientOptions)
8484
**Options:**
8585

8686
- `connection?: RuntimeConnection` - How to connect to the Copilot runtime. Construct via the factory functions on `RuntimeConnection`:
87-
- `RuntimeConnection.forStdio({ path?, args? })` (default) — spawn the runtime and communicate over its stdin/stdout.
88-
- `RuntimeConnection.forTcp({ port?, connectionToken?, path?, args? })` — spawn the runtime as a TCP server.
87+
- `RuntimeConnection.forStdio({ path?, args?, env? })` (default) — spawn the runtime and communicate over its stdin/stdout.
88+
- `RuntimeConnection.forTcp({ port?, connectionToken?, path?, args?, env? })` — spawn the runtime as a TCP server.
8989
- `RuntimeConnection.forUri(url, { connectionToken? })` — connect to an already-running runtime (mutually exclusive with `gitHubToken`/`useLoggedInUser`). There is no top-level `cliUrl` shortcut; use this factory for URL-based connections.
90+
- `RuntimeConnection.forInProcess()` — host the runtime in-process over its native C ABI (FFI). **Experimental.** Because the runtime shares this process, `env`, `telemetry`, and `workingDirectory` are rejected with this transport; set them on the host process instead.
91+
- The child-process transports (`forStdio`/`forTcp`) also accept a per-connection `env`. Set it there or via the top-level `env` option — not both (setting both throws).
9092
- `mode?: "empty" | "copilot-cli"` - Defaulting strategy. Use `"empty"` for multi-user server mode; defaults to `"copilot-cli"`.
9193
- `workingDirectory?: string` - Working directory for the runtime process (default: current process cwd).
9294
- `baseDirectory?: string` - Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime. When not set, the runtime defaults to `~/.copilot`. Ignored when connecting via `RuntimeConnection.forUri`.

nodejs/src/client.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,32 @@ export class CopilotClient {
644644
"constructing the client instead."
645645
);
646646
}
647+
if (conn.kind === "inprocess" && options.env !== undefined) {
648+
throw new Error(
649+
"env is not supported with RuntimeConnection.forInProcess(): the in-process transport loads " +
650+
"the native runtime into the shared host process, whose single environment block cannot " +
651+
"carry per-client values. Set the variables on the host process environment instead."
652+
);
653+
}
654+
if (conn.kind === "inprocess" && options.telemetry !== undefined) {
655+
throw new Error(
656+
"telemetry is not supported with RuntimeConnection.forInProcess(): telemetry configuration " +
657+
"is lowered to environment variables read by native runtime code running in the shared " +
658+
"host process, so per-client telemetry cannot be honored in-process. Configure telemetry " +
659+
"via the host process environment, or use a child-process transport."
660+
);
661+
}
662+
if (
663+
(conn.kind === "stdio" || conn.kind === "tcp") &&
664+
conn.env !== undefined &&
665+
options.env !== undefined
666+
) {
667+
throw new Error(
668+
"Set environment variables via either the client-level env option or the connection's env " +
669+
"(RuntimeConnection.forStdio/forTcp), not both. Prefer the connection-level env for " +
670+
"child-process transports."
671+
);
672+
}
647673
if (conn.kind === "tcp" && conn.connectionToken !== undefined) {
648674
if (typeof conn.connectionToken !== "string" || conn.connectionToken.length === 0) {
649675
throw new Error("connectionToken must be a non-empty string");
@@ -681,7 +707,13 @@ export class CopilotClient {
681707
this.onGitHubTelemetry = options.onGitHubTelemetry;
682708
this.setupClientGlobalHandlers();
683709

684-
const effectiveEnv = options.env ?? process.env;
710+
// Connection-level env (child-process transports only) takes precedence
711+
// over the client-level env, which falls back to the ambient process env.
712+
// The constructor guard above rejects setting both, so at most one of the
713+
// first two is defined. Mirrors .NET/Python precedence.
714+
const connEnv: Record<string, string> | undefined =
715+
conn.kind === "stdio" || conn.kind === "tcp" ? conn.env : undefined;
716+
const effectiveEnv = connEnv ?? options.env ?? process.env;
685717
this.resolvedEnv = effectiveEnv;
686718
this.resolvedCliPath =
687719
conn.kind === "stdio" || conn.kind === "tcp"

nodejs/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export type {
6363
InProcessRuntimeConnection,
6464
TcpRuntimeConnection,
6565
UriRuntimeConnection,
66+
ChildProcessRuntimeConnection,
6667
CustomAgentConfig,
6768
ElicitationFieldValue,
6869
ElicitationHandler,

nodejs/src/types.ts

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,29 @@ export type RuntimeConnection =
103103
| UriRuntimeConnection;
104104

105105
/**
106-
* Spawns a runtime child process and communicates over its stdin/stdout.
107-
* This is the default if no {@link CopilotClientOptions.connection} is set.
106+
* Shared shape for the transports that spawn a runtime **child process**
107+
* ({@link StdioRuntimeConnection} and {@link TcpRuntimeConnection}).
108108
*/
109-
export interface StdioRuntimeConnection {
110-
readonly kind: "stdio";
109+
export interface ChildProcessRuntimeConnection {
111110
/** Path to the runtime executable. When omitted, the bundled runtime is used. */
112111
readonly path?: string;
113112
/** Extra command-line arguments to pass to the runtime process. */
114113
readonly args?: readonly string[];
114+
/**
115+
* Environment variables for the spawned runtime child process, replacing the
116+
* inherited environment. Cannot be combined with
117+
* {@link CopilotClientOptions.env}; setting both throws when the client is
118+
* constructed. When omitted, the client-level env (or `process.env`) is used.
119+
*/
120+
readonly env?: Record<string, string>;
121+
}
122+
123+
/**
124+
* Spawns a runtime child process and communicates over its stdin/stdout.
125+
* This is the default if no {@link CopilotClientOptions.connection} is set.
126+
*/
127+
export interface StdioRuntimeConnection extends ChildProcessRuntimeConnection {
128+
readonly kind: "stdio";
115129
}
116130

117131
/**
@@ -137,7 +151,7 @@ export interface InProcessRuntimeConnection {
137151
/**
138152
* Spawns a runtime child process that listens on a TCP socket and connects to it.
139153
*/
140-
export interface TcpRuntimeConnection {
154+
export interface TcpRuntimeConnection extends ChildProcessRuntimeConnection {
141155
readonly kind: "tcp";
142156
/**
143157
* TCP port to listen on. `0` (the default) auto-allocates a free port.
@@ -150,10 +164,6 @@ export interface TcpRuntimeConnection {
150164
* loopback listener is safe by default.
151165
*/
152166
readonly connectionToken?: string;
153-
/** Path to the runtime executable. When omitted, the bundled runtime is used. */
154-
readonly path?: string;
155-
/** Extra command-line arguments to pass to the runtime process. */
156-
readonly args?: readonly string[];
157167
}
158168

159169
/**
@@ -177,8 +187,10 @@ export const RuntimeConnection = {
177187
* Spawn a runtime child process and communicate over its stdin/stdout.
178188
* This is the default if no {@link CopilotClientOptions.connection} is set.
179189
*/
180-
forStdio(opts: { path?: string; args?: readonly string[] } = {}): StdioRuntimeConnection {
181-
return { kind: "stdio", path: opts.path, args: opts.args };
190+
forStdio(
191+
opts: { path?: string; args?: readonly string[]; env?: Record<string, string> } = {}
192+
): StdioRuntimeConnection {
193+
return { kind: "stdio", path: opts.path, args: opts.args, env: opts.env };
182194
},
183195
/**
184196
* Spawn a runtime child process that listens on a TCP socket and connect to it.
@@ -189,6 +201,7 @@ export const RuntimeConnection = {
189201
connectionToken?: string;
190202
path?: string;
191203
args?: readonly string[];
204+
env?: Record<string, string>;
192205
} = {}
193206
): TcpRuntimeConnection {
194207
return {
@@ -197,6 +210,7 @@ export const RuntimeConnection = {
197210
connectionToken: opts.connectionToken,
198211
path: opts.path,
199212
args: opts.args,
213+
env: opts.env,
200214
};
201215
},
202216
/**

nodejs/test/client.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2007,6 +2007,77 @@ describe("CopilotClient", () => {
20072007
/gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri/
20082008
);
20092009
});
2010+
2011+
it("should throw error when env is used with forInProcess", () => {
2012+
expect(() => {
2013+
new CopilotClient({
2014+
connection: RuntimeConnection.forInProcess(),
2015+
env: { FOO: "bar" },
2016+
logLevel: "error",
2017+
});
2018+
}).toThrow(/env is not supported with RuntimeConnection.forInProcess/);
2019+
});
2020+
2021+
it("should throw error when telemetry is used with forInProcess", () => {
2022+
expect(() => {
2023+
new CopilotClient({
2024+
connection: RuntimeConnection.forInProcess(),
2025+
telemetry: { otlpEndpoint: "http://localhost:4318" },
2026+
logLevel: "error",
2027+
});
2028+
}).toThrow(/telemetry is not supported with RuntimeConnection.forInProcess/);
2029+
});
2030+
2031+
it("should throw error when workingDirectory is used with forInProcess", () => {
2032+
expect(() => {
2033+
new CopilotClient({
2034+
connection: RuntimeConnection.forInProcess(),
2035+
workingDirectory: "/tmp",
2036+
logLevel: "error",
2037+
});
2038+
}).toThrow(/workingDirectory is not supported with RuntimeConnection.forInProcess/);
2039+
});
2040+
2041+
it("should throw error when env is set on both the client and a stdio connection", () => {
2042+
expect(() => {
2043+
new CopilotClient({
2044+
connection: RuntimeConnection.forStdio({ env: { FOO: "conn" } }),
2045+
env: { FOO: "client" },
2046+
logLevel: "error",
2047+
});
2048+
}).toThrow(
2049+
/Set environment variables via either the client-level env option or the connection/
2050+
);
2051+
});
2052+
2053+
it("should throw error when env is set on both the client and a tcp connection", () => {
2054+
expect(() => {
2055+
new CopilotClient({
2056+
connection: RuntimeConnection.forTcp({ env: { FOO: "conn" } }),
2057+
env: { FOO: "client" },
2058+
logLevel: "error",
2059+
});
2060+
}).toThrow(
2061+
/Set environment variables via either the client-level env option or the connection/
2062+
);
2063+
});
2064+
2065+
it("should use the connection-level env for child-process transports", () => {
2066+
const client = new CopilotClient({
2067+
connection: RuntimeConnection.forStdio({ env: { FOO: "from-conn" } }),
2068+
logLevel: "error",
2069+
});
2070+
expect((client as any).resolvedEnv).toEqual({ FOO: "from-conn" });
2071+
});
2072+
2073+
it("should allow env on the client alone with a child-process transport", () => {
2074+
const client = new CopilotClient({
2075+
connection: RuntimeConnection.forStdio(),
2076+
env: { FOO: "from-client" },
2077+
logLevel: "error",
2078+
});
2079+
expect((client as any).resolvedEnv).toEqual({ FOO: "from-client" });
2080+
});
20102081
});
20112082

20122083
describe("overridesBuiltInTool in tool definitions", () => {

0 commit comments

Comments
 (0)