Skip to content

Commit bd50a9f

Browse files
Add in-process FFI transport for Rust SDK (#1915)
1 parent 991f7ab commit bd50a9f

30 files changed

Lines changed: 2974 additions & 828 deletions

.github/lsp.json

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,24 +21,6 @@
2121
".go": "go"
2222
},
2323
"rootUri": "go"
24-
},
25-
"rust-analyzer": {
26-
"command": "rust-analyzer",
27-
"fileExtensions": {
28-
".rs": "rust"
29-
},
30-
"initializationOptions": {
31-
"cargo": {
32-
"buildScripts": {
33-
"enable": true
34-
},
35-
"allFeatures": true
36-
},
37-
"checkOnSave": true,
38-
"check": {
39-
"command": "clippy"
40-
}
41-
}
4224
}
4325
}
4426
}

.github/workflows/publish.yml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,13 +171,17 @@ jobs:
171171
- name: Set version
172172
run: sed -i -E 's/^version = ".*"$/version = "${{ needs.version.outputs.version }}"/' Cargo.toml
173173
- name: Snapshot CLI version + hashes for build.rs
174-
run: bash scripts/snapshot-bundled-cli-version.sh
175-
- name: Verify cli-version.txt exists
176174
run: |
177-
if [[ ! -f cli-version.txt ]]; then
178-
echo "::error::cli-version.txt was not generated. The Snapshot step must run before packaging."
179-
exit 1
180-
fi
175+
bash scripts/snapshot-bundled-cli-version.sh
176+
bash scripts/snapshot-bundled-in-process-version.sh
177+
- name: Verify CLI version snapshots exist
178+
run: |
179+
for snapshot in cli-version.txt cli-version-in-process.txt; do
180+
if [[ ! -f "${snapshot}" ]]; then
181+
echo "::error::${snapshot} was not generated. The Snapshot step must run before packaging."
182+
exit 1
183+
fi
184+
done
181185
- name: Package (dry run)
182186
run: cargo publish --dry-run --allow-dirty
183187
- name: Upload artifact

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

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ permissions:
2929

3030
jobs:
3131
test:
32-
name: "Rust SDK Tests"
32+
name: "Rust SDK Tests (${{ matrix.os }}, default)"
3333
if: github.event.repository.fork == false
3434
env:
3535
POWERSHELL_UPDATECHECK: Off
@@ -84,7 +84,7 @@ jobs:
8484
# Share the bundled-CLI archive cache with the `bundle` job: build.rs
8585
# now downloads in both modes (embed for `bundle`, extract-to-cache
8686
# for this `test` job's `--no-default-features` build).
87-
- name: Cache bundled CLI tarball
87+
- name: Cache bundled CLI archives
8888
uses: actions/cache@v4
8989
with:
9090
path: ./rust/.bundled-cli-cache
@@ -98,7 +98,7 @@ jobs:
9898
if: runner.os == 'Linux'
9999
env:
100100
BUNDLED_CLI_CACHE_DIR: ${{ github.workspace }}/rust/.bundled-cli-cache
101-
run: cargo clippy --all-targets --features test-support -- --no-deps -D warnings -D clippy::unwrap_used -D clippy::disallowed_macros -D clippy::await_holding_invalid_type
101+
run: cargo clippy --all-targets --features test-support,bundled-in-process -- --no-deps -D warnings -D clippy::unwrap_used -D clippy::disallowed_macros -D clippy::await_holding_invalid_type
102102

103103
- name: cargo doc
104104
if: runner.os == 'Linux'
@@ -129,14 +129,92 @@ jobs:
129129
# The dedicated `bundle` job below exercises the embed pipeline.
130130
run: cargo test --no-default-features --features test-support -- --test-threads=4 --nocapture
131131

132+
# Exercises the in-process FFI transport (`Transport::InProcess`, the Rust
133+
# analogue of the .NET `RuntimeConnection.ForInProcess()`), mirroring the
134+
# `inprocess` transport cell in dotnet-sdk-tests.yml. Sets
135+
# COPILOT_SDK_DEFAULT_CONNECTION=inprocess so the client hosts the runtime
136+
# cdylib in-process instead of spawning a stdio child, then runs the whole
137+
# E2E suite over the in-process transport. The suite runs serially in-process
138+
# (the harness forces concurrency to 1) because it mirrors each test's
139+
# environment onto the shared process environment the in-process worker inherits.
140+
# Runs the whole E2E suite over the in-process transport on supported hosts.
141+
test-inprocess:
142+
name: "Rust SDK Tests (${{ matrix.os }}, inprocess)"
143+
if: github.event.repository.fork == false
144+
env:
145+
CARGO_TERM_COLOR: always
146+
RUST_BACKTRACE: 1
147+
strategy:
148+
fail-fast: false
149+
matrix:
150+
# TODO: Re-enable Windows after fixing the napi-oop peer shutdown crash.
151+
os: [ubuntu-latest, macos-latest]
152+
runs-on: ${{ matrix.os }}
153+
defaults:
154+
run:
155+
shell: bash
156+
working-directory: ./rust
157+
steps:
158+
- uses: actions/checkout@v6.0.2
159+
160+
- uses: ./.github/actions/setup-copilot
161+
id: setup-copilot
162+
163+
- name: Install Rust toolchain
164+
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
165+
with:
166+
toolchain: "1.94.0"
167+
168+
- uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2
169+
with:
170+
workspaces: "rust"
171+
prefix-key: v1-rust-no-bin
172+
cache-bin: false
173+
174+
- name: Read pinned @github/copilot CLI version
175+
id: cli-version
176+
working-directory: ./nodejs
177+
run: |
178+
version=$(node -p "require('./package-lock.json').packages['node_modules/@github/copilot'].version")
179+
echo "version=$version" >> "$GITHUB_OUTPUT"
180+
echo "Pinned CLI version: $version"
181+
182+
- name: Cache bundled CLI archives
183+
uses: actions/cache@v4
184+
with:
185+
path: ./rust/.bundled-cli-cache
186+
key: bundled-cli-${{ matrix.os }}-${{ steps.cli-version.outputs.version }}
187+
188+
- name: Install test harness dependencies
189+
working-directory: ./test/harness
190+
run: npm ci --ignore-scripts
191+
192+
- name: Warm up PowerShell
193+
if: runner.os == 'Windows'
194+
run: pwsh.exe -Command "Write-Host 'PowerShell ready'"
195+
196+
- name: Select in-process transport
197+
run: echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV"
198+
199+
- name: cargo test (in-process transport, full E2E suite)
200+
timeout-minutes: 60
201+
env:
202+
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}
203+
COPILOT_CLI_PATH: ${{ steps.setup-copilot.outputs.cli-path }}
204+
BUNDLED_CLI_CACHE_DIR: ${{ github.workspace }}/rust/.bundled-cli-cache
205+
# The harness forces serial execution in-process (both the async semaphore and
206+
# libtest via --test-threads=1) because it mirrors each test's environment onto
207+
# the shared process environment, so RUST_E2E_CONCURRENCY is not set here.
208+
run: cargo test --no-default-features --features test-support,bundled-in-process --test e2e -- --test-threads=1 --nocapture
209+
132210
# Validates the bundled-CLI build path on all three supported
133211
# platforms. While the regular `cargo test` job above also exercises
134212
# build.rs (bundling is on by default now), this matrix job is the
135213
# dedicated cross-platform smoke test for the download / verify /
136214
# extract / embed pipeline. Catches regressions before they ship to
137215
# crates.io and before bundling consumers hit them downstream.
138216
bundle:
139-
name: "Rust SDK Bundled CLI Build"
217+
name: "Rust SDK Bundled CLI Build (${{ matrix.os }})"
140218
if: github.event.repository.fork == false
141219
env:
142220
CARGO_TERM_COLOR: always
@@ -180,13 +258,15 @@ jobs:
180258
# ~130 MB on every CI invocation. Keyed by OS + CLI version so old
181259
# archives drop out when the pinned version bumps, keeping the
182260
# cache bounded.
183-
- name: Cache bundled CLI tarball
261+
- name: Cache bundled CLI archives
184262
uses: actions/cache@v4
185263
with:
186264
path: ./rust/.bundled-cli-cache
187265
key: bundled-cli-${{ matrix.os }}-${{ steps.cli-version.outputs.version }}
188266

189-
- name: cargo build (bundled-cli is the default feature)
267+
- name: Test bundled CLI build paths
190268
env:
191269
BUNDLED_CLI_CACHE_DIR: ${{ github.workspace }}/rust/.bundled-cli-cache
192-
run: cargo build
270+
run: |
271+
cargo build
272+
cargo test --features bundled-in-process --lib embedded_archive_contains_only_expected_files

rust/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
/target
22
Cargo.lock.bak
33
cli-version.txt
4+
cli-version-in-process.txt

rust/Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/Cargo.toml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ readme = "README.md"
1313
license = "MIT"
1414
include = [
1515
"src/**/*",
16+
"build/**/*",
1617
"examples/**/*",
1718
"tests/**/*",
1819
"build.rs",
1920
"Cargo.toml",
2021
"README.md",
2122
"LICENSE",
2223
"cli-version.txt",
24+
"cli-version-in-process.txt",
2325
]
2426

2527
[lib]
@@ -28,6 +30,7 @@ name = "github_copilot_sdk"
2830
[features]
2931
default = ["bundled-cli"]
3032
bundled-cli = ["dep:tar", "dep:flate2", "dep:zip"]
33+
bundled-in-process = ["bundled-cli", "dep:libloading"]
3134
derive = ["dep:schemars"]
3235
test-support = []
3336

@@ -49,10 +52,13 @@ tokio-stream = { version = "0.1", features = ["sync"] }
4952
tokio-util = { version = "0.7", default-features = false }
5053
tracing = "0.1"
5154
dirs = "5"
55+
libloading = { version = "0.8", optional = true }
5256
parking_lot = "0.12"
5357
regex = "1"
5458
getrandom = "0.2"
5559
uuid = { version = "1", default-features = false, features = ["v4"] }
60+
flate2 = { version = "1", optional = true }
61+
tar = { version = "0.4", optional = true }
5662
# LLM inference callback transport: idiomatic HTTP/WebSocket forwarding for the
5763
# `CopilotRequestHandler`, plus base64/byte/stream plumbing for the chunk protocol.
5864
base64 = "0.22"
@@ -65,10 +71,6 @@ tokio-tungstenite = { version = "0.24", default-features = false, features = ["c
6571
[target.'cfg(windows)'.dependencies]
6672
zip = { version = "2", default-features = false, features = ["deflate"], optional = true }
6773

68-
[target.'cfg(not(windows))'.dependencies]
69-
flate2 = { version = "1", optional = true }
70-
tar = { version = "0.4", optional = true }
71-
7274
[dev-dependencies]
7375
rusqlite = { version = "0.35", features = ["bundled"] }
7476
schemars = "1"
@@ -89,8 +91,10 @@ name = "protocol_version_test"
8991
required-features = ["test-support"]
9092

9193
[build-dependencies]
94+
base64 = "0.22"
9295
dirs = "5"
9396
flate2 = "1"
97+
serde_json = "1"
9498
sha2 = "0.10"
9599
tar = "0.4"
96100
ureq = { version = "2", default-features = false, features = ["tls"] }

rust/README.md

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,15 @@ client.stop().await?;
7272

7373
**`ClientOptions`:**
7474

75-
| Field | Type | Description |
76-
| ------------- | --------------------------- | --------------------------------------------------------------- |
77-
| `program` | `CliProgram` | `Resolve` (default: auto-detect) or `Path(PathBuf)` (explicit) |
78-
| `prefix_args` | `Vec<OsString>` | Args before `--server` (e.g. script path for node) |
79-
| `cwd` | `PathBuf` | Working directory for CLI process |
80-
| `env` | `Vec<(OsString, OsString)>` | Environment variables for CLI process |
81-
| `env_remove` | `Vec<OsString>` | Environment variables to remove |
82-
| `extra_args` | `Vec<String>` | Extra CLI flags |
83-
| `transport` | `Transport` | `Stdio` (default), `Tcp { port }`, or `External { host, port }` |
75+
| Field | Type | Description |
76+
| ------------------- | --------------------------- | ----------------------------------------------------------------- |
77+
| `program` | `CliProgram` | `Resolve` (default: auto-detect) or `Path(PathBuf)` (explicit) |
78+
| `prefix_args` | `Vec<OsString>` | Args before `--server` (e.g. script path for node) |
79+
| `working_directory` | `PathBuf` | Working directory for CLI process (empty = host process's cwd) |
80+
| `env` | `Vec<(OsString, OsString)>` | Environment variables for CLI process |
81+
| `env_remove` | `Vec<OsString>` | Environment variables to remove |
82+
| `extra_args` | `Vec<String>` | Extra CLI flags |
83+
| `transport` | `Transport` | `Default`, `Stdio`, `InProcess`, `Tcp`, or `External` |
8484

8585
With the default `CliProgram::Resolve`, `Client::start()` resolves the CLI in this order: an explicit `CliProgram::Path(path)`, the `COPILOT_CLI_PATH` env var, then the bundled CLI that was embedded at build time. There is no PATH scanning — if you've opted out of bundling (`default-features = false`) you must supply either `CliProgram::Path` or `COPILOT_CLI_PATH`.
8686

@@ -749,7 +749,7 @@ none of them are scheduled for removal.
749749
caller-supplied `AsyncRead` / `AsyncWrite`. Useful for testing,
750750
in-process embedding, or custom transports. Other SDKs are spawn-only
751751
or fixed-stdio.
752-
- **`enum Transport { Stdio, Tcp, External }`** — explicit, exhaustive
752+
- **`enum Transport { Default, Stdio, InProcess, Tcp, External }`** — explicit
753753
transport selector on `ClientOptions::transport`. Node/Python/Go rely
754754
on conditional config field combinations instead.
755755
- **Split `prefix_args` / `extra_args`** on `ClientOptions` — separate
@@ -776,7 +776,14 @@ none of them are scheduled for removal.
776776

777777
## Embedded CLI
778778

779-
The SDK provisions the Copilot CLI binary at build time. By default the `bundled-cli` feature embeds the verified binary directly in your compiled crate, so end-user binaries are self-contained — no env var setup, no separate install, just `cargo build`.
779+
The SDK provisions the Copilot CLI binary at build time. By default the
780+
`bundled-cli` feature embeds only the verified CLI executable in your compiled
781+
crate. Enable `bundled-in-process` to additionally embed the native
782+
runtime library and use `Transport::InProcess`:
783+
784+
```toml
785+
github-copilot-sdk = { version = "0.1", features = ["bundled-in-process"] }
786+
```
780787

781788
For builds that prefer a smaller artifact, disable the `bundled-cli` feature:
782789

@@ -795,7 +802,7 @@ github-copilot-sdk = { version = "0.1", default-features = false }
795802
> together.
796803
>
797804
> **Convenience on the build machine only.** As a special case,
798-
> `build.rs` downloads and SHA-verifies the compatible CLI version and
805+
> `build.rs` downloads and integrity-verifies the compatible CLI version and
799806
> drops it into the build machine's per-user cache; the runtime
800807
> resolver on that same machine will pick it up automatically. This
801808
> makes local development and CI ergonomic, but it does **not** carry
@@ -812,8 +819,11 @@ github-copilot-sdk = { version = "0.1", default-features = false }
812819

813820
The resolved version is baked into the crate via `cargo:rustc-env=COPILOT_SDK_CLI_VERSION` regardless of mode. The runtime resolver consumes it to recompute the on-disk path by convention, so no absolute paths leak into the rlib.
814821

815-
2. **Build time:** `build.rs` downloads the platform-appropriate archive from the [`github/copilot-cli` GitHub Releases](https://github.com/github/copilot-cli/releases) (`copilot-{platform}.tar.gz` on macOS/Linux, `.zip` on Windows), live-fetches the matching `SHA256SUMS.txt`, and verifies the archive hash. Then:
816-
- **`bundled-cli` on (default, release):** embeds the raw archive bytes via `include_bytes!()`. Runtime extracts on first `Client::start()`.
822+
2. **Build time:** `build.rs` downloads the platform-specific npm package and
823+
verifies its `sha512` integrity against the lockfile or publish snapshot.
824+
Then:
825+
- **`bundled-cli` on (default):** creates and embeds a minimal archive containing only the CLI executable.
826+
- **`bundled-in-process` on:** the minimal archive additionally contains the platform-native runtime library (`.dll`, `.so`, or `.dylib`); no other npm package files are embedded.
817827
- **`bundled-cli` off:** extracts the binary directly into the platform cache (staging file + atomic rename), idempotent across rebuilds. If the extracted binary is already present at the expected path, the download is skipped entirely — the extracted binary *is* the cache.
818828

819829
3. **Runtime:** in both modes the binary lives at:
@@ -899,10 +909,11 @@ Supported: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64`
899909

900910
## Features
901911

902-
| Feature | Default | Description |
903-
| -------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
904-
| `bundled-cli` || Build-time CLI embedding. Pulls in `tar`+`flate2` (Linux/macOS) or `zip` (Windows). Disable via `default-features = false` to opt out (e.g. when shipping a smaller binary or when always supplying the CLI via `CliProgram::Path` / `COPILOT_CLI_PATH`). |
905-
| `derive` || `schema_for::<T>()` for generating JSON Schema from Rust types (adds `schemars`). Enable when defining [tool parameters](#tool-registration). |
912+
| Feature | Default | Description |
913+
| ------- | ------- | ----------- |
914+
| `bundled-cli` || Embeds only the CLI executable. Disable via `default-features = false` when supplying the CLI via `CliProgram::Path` or `COPILOT_CLI_PATH`. |
915+
| `bundled-in-process` || Enables `Transport::InProcess`, implies `bundled-cli`, and additionally embeds only the platform-native runtime library. |
916+
| `derive` || `schema_for::<T>()` for generating JSON Schema from Rust types (adds `schemars`). |
906917

907918
```toml
908919
# These examples use registry syntax for illustration; until the crate is
@@ -911,7 +922,10 @@ Supported: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64`
911922
# Default — bundles the Copilot CLI in your binary.
912923
github-copilot-sdk = "0.1"
913924

914-
# Opt out of bundling — resolve CLI from COPILOT_CLI_PATH or system PATH instead.
925+
# Enable the in-process transport and bundle its native runtime library.
926+
github-copilot-sdk = { version = "0.1", features = ["bundled-in-process"] }
927+
928+
# Opt out of bundling — supply the CLI explicitly at runtime.
915929
github-copilot-sdk = { version = "0.1", default-features = false }
916930

917931
# Derive JSON Schema for tool parameters (adds to default bundled-cli).

0 commit comments

Comments
 (0)