Skip to content

Support optional connection token for TCP servers#1176

Merged
SteveSandersonMS merged 16 commits into
mainfrom
stevesa/sdk-server-connection-token
May 4, 2026
Merged

Support optional connection token for TCP servers#1176
SteveSandersonMS merged 16 commits into
mainfrom
stevesa/sdk-server-connection-token

Conversation

@SteveSandersonMS
Copy link
Copy Markdown
Contributor

Adds opt-in support for connecting to a Copilot CLI TCP server that requires a shared connection token.

Behaviour

  • New CopilotClientOptions.tcpConnectionToken. Only meaningful in TCP mode (useStdio: false); passing it together with useStdio: true throws.
  • When the SDK spawns its own CLI subprocess in TCP mode, it now auto-generates a UUID connection token by default and forwards it to the subprocess via the COPILOT_CONNECTION_TOKEN environment variable. Callers who set tcpConnectionToken explicitly override that.
  • During connection setup the SDK calls a new connect JSON-RPC method to authenticate. If the server returns MethodNotFound (older runtime without the handler) the SDK falls back to the existing ping-based protocol-version check, so this is fully backwards compatible.

Tests

test/e2e/connection_token.test.ts — four end-to-end tests against a spawned CLI:

  • explicit token round-trips successfully
  • auto-generated UUID round-trips successfully
  • a sibling client connecting with the wrong token is rejected
  • a sibling client connecting with no token is rejected

Manual verification: a client built with these changes can still ping a legacy CLI server that has no connect handler.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

@SteveSandersonMS SteveSandersonMS requested a review from a team as a code owner April 30, 2026 14:25
Copilot AI review requested due to automatic review settings April 30, 2026 14:25
@github-actions

This comment has been minimized.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Comment thread python/e2e/test_connection_token.py Fixed
Comment thread python/e2e/test_connection_token.py Fixed
Comment thread python/e2e/test_connection_token.py Fixed
Comment thread dotnet/test/ConnectionTokenTests.cs Fixed
Comment thread dotnet/test/ConnectionTokenTests.cs Fixed
Comment thread dotnet/test/ConnectionTokenTests.cs Fixed
Comment thread dotnet/test/ConnectionTokenTests.cs Fixed
@github-actions

This comment has been minimized.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by SDK Consistency Review Agent for issue #1176 · ● 1.5M

Comment thread python/copilot/client.py Outdated
if config.use_stdio:
self._effective_connection_token = None
else:
self._effective_connection_token = config.tcp_connection_token or uuid.uuid4().hex
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor cross-SDK inconsistency: The or uuid.uuid4().hex expression silently replaces an empty-string tcp_connection_token with an auto-generated UUID (Python's falsy "" semantics). Node.js and .NET both raise an explicit error for empty strings:

  • Node.js: throw new Error("tcpConnectionToken must be a non-empty string")
  • .NET: throw new ArgumentException("TcpConnectionToken must be a non-empty string")

To stay consistent, consider adding an explicit guard before this line:

if config.tcp_connection_token is not None and config.use_stdio:
    raise ValueError("tcp_connection_token cannot be used with use_stdio=True")
if config.tcp_connection_token == "":
    raise ValueError("tcp_connection_token must be a non-empty string")

This is an edge case (nobody should pass ""), but worth aligning for predictable cross-SDK behaviour.

Comment thread scripts/codegen/python.ts Fixed
@github-actions

This comment has been minimized.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by SDK Consistency Review Agent for issue #1176 · ● 706.2K

Comment thread python/copilot/client.py Outdated
if config.use_stdio:
self._effective_connection_token = None
else:
self._effective_connection_token = config.tcp_connection_token or uuid.uuid4().hex
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-SDK consistency: The auto-generated token uses uuid.uuid4().hex which produces a compact 32-character hex string without dashes (e.g. a3f8b7d2e4c6f8a0b2d4e6f8a0b2d4e6), while the other three SDKs all produce standard UUID format with dashes:

  • Node.js: randomUUID()a3f8b7d2-e4c6-f8a0-b2d4-e6f8a0b2d4e6
  • Go: uuid.NewString()a3f8b7d2-e4c6-f8a0-b2d4-e6f8a0b2d4e6
  • .NET: Guid.NewGuid().ToString()a3f8b7d2-e4c6-f8a0-b2d4-e6f8a0b2d4e6

This is also inconsistent with how the same file generates session IDs on line 1457, which correctly uses str(uuid.uuid4()).

Suggestion: replace uuid.uuid4().hex with str(uuid.uuid4()) for consistency across all SDKs.

@SteveSandersonMS SteveSandersonMS force-pushed the stevesa/sdk-server-connection-token branch from b079ec3 to 5b23c6a Compare May 1, 2026 10:22
@github-actions

This comment has been minimized.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by SDK Consistency Review Agent for issue #1176 · ● 1.2M

Comment thread python/copilot/client.py Outdated
Comment on lines +2184 to +2186
connect_result = await ServerRpc(self._client).connect(
ConnectRequest(token=self._effective_connection_token)
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Bug: wrong RPC class used — ServerRpc has no connect method

ServerRpc only has ping (and sub-APIs for models, tools, etc.). The newly generated InternalServerRpc is the class that exposes connect. As written, every call to start() will raise:

AttributeError: 'ServerRpc' object has no attribute 'connect'

making the Python SDK completely non-functional after this PR.

Fix — two changes needed:

  1. Add InternalServerRpc to the import in the from .generated.rpc import (...) block (around line 35):
from .generated.rpc import (
    ClientSessionApiHandlers,
    ConnectRequest,
    InternalServerRpc,   # ← add this
    ServerRpc,
    register_client_session_api_handlers,
)
  1. Replace ServerRpc with InternalServerRpc here:
connect_result = await InternalServerRpc(self._client).connect(
    ConnectRequest(token=self._effective_connection_token)
)

The pattern mirrors the other SDKs:

  • Node.js uses createInternalServerRpc(this.connection) for the handshake
  • Go uses NewInternalServerRpc(...)
  • .NET calls InvokeRpcAsync<ConnectResult>(connection.Rpc, "connect", ...)

@SteveSandersonMS SteveSandersonMS changed the title Node SDK: support optional connection token for TCP servers Support optional connection token for TCP servers May 1, 2026
@github-actions

This comment has been minimized.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by SDK Consistency Review Agent for issue #1176 · ● 1M

Comment thread python/copilot/client.py Outdated
if config.use_stdio:
self._effective_connection_token = None
else:
self._effective_connection_token = config.tcp_connection_token or uuid.uuid4().hex
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-SDK consistency: UUID format and empty-string validation

Two small inconsistencies compared with the other SDK implementations:

1. Token formatuuid.uuid4().hex produces a 32-character lowercase hex string with no dashes (e.g. "a3b4c5d6e7f8..."). Every other SDK generates a standard UUID string with dashes:

SDK Expression Format
Node.js randomUUID() xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Go uuid.NewString() xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
.NET Guid.NewGuid().ToString() xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Python uuid.uuid4().hex xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

If the CLI server ever validates token format (or if users compare auto-generated tokens across SDKs), this discrepancy can cause confusion or failures. Consider using str(uuid.uuid4()) to stay consistent.

2. Empty-string validation — because of the or short-circuit, passing tcp_connection_token="" will silently fall through to auto-generation rather than raising a ValueError. Both Node.js and .NET reject an explicitly supplied empty string with an error:

# Node.js equivalent check:
if options.tcpConnectionToken is not None and options.tcpConnectionToken.length === 0:
    throw new Error("tcpConnectionToken must be a non-empty string")

# .NET equivalent:
if TcpConnectionToken.Length == 0:
    raise ArgumentException("TcpConnectionToken must be a non-empty string")

Suggested fix covering both points:

token = config.tcp_connection_token
if token is not None:
    if token == "":
        raise ValueError("tcp_connection_token must be a non-empty string")
    self._effective_connection_token = token
else:
    self._effective_connection_token = str(uuid.uuid4())

@github-actions

This comment has been minimized.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by SDK Consistency Review Agent for issue #1176 · ● 749.9K

Comment thread python/copilot/client.py Outdated
Comment on lines +900 to +909
self._effective_connection_token: str | None = config.tcp_connection_token
else:
self._actual_port = None

if config.tcp_connection_token is not None and config.use_stdio:
raise ValueError("tcp_connection_token cannot be used with use_stdio=True")
if config.use_stdio:
self._effective_connection_token = None
else:
self._effective_connection_token = config.tcp_connection_token or uuid.uuid4().hex
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-SDK consistency: empty-string tcp_connection_token is handled differently here than in the Node.js and .NET SDKs.

Node.js and .NET both explicitly reject an empty-string token at construction time:

// Node.js
if (typeof options.tcpConnectionToken !== "string" || options.tcpConnectionToken.length === 0) {
    throw new Error("tcpConnectionToken must be a non-empty string");
}
// .NET
if (_options.TcpConnectionToken.Length == 0)
    throw new ArgumentException("TcpConnectionToken must be a non-empty string");

Python has two divergent behaviours:

  1. ExternalServerConfig path (line 900): self._effective_connection_token = config.tcp_connection_token — an empty string is assigned verbatim and then sent as the token field in the connect RPC, which the server would likely reject with AUTHENTICATION_FAILED at runtime rather than at construction time.

  2. SubprocessConfig path (line 909): config.tcp_connection_token or uuid.uuid4().hex — an empty string silently falls back to an auto-generated UUID, which is a quiet substitution rather than a clear error.

Suggestion: add a shared guard before the isinstance branch so both paths behave consistently with the other SDKs:

if config.tcp_connection_token is not None and len(config.tcp_connection_token) == 0:
    raise ValueError("tcp_connection_token must be a non-empty string")

(Go uses the idiomatic empty-string-as-absent pattern, so there's no mismatch there — but aligning Python with Node.js and .NET would be a small improvement.)

SteveSandersonMS and others added 6 commits May 4, 2026 10:40
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mirrors the Node SDK implementation in .NET, Go, and Python:
- Add tcpConnectionToken option to client config; auto-generate a UUID
  when spawning the CLI in TCP mode and forward via COPILOT_CONNECTION_TOKEN.
- Send the token via a new \connect\ RPC during the handshake; fall back
  to \ping\ against legacy servers without \connect\.
- e2e coverage for explicit token, auto-generated token, wrong token, and
  missing token in each language.

Codegen: fix scripts/codegen so quicktype's Python/Go renderers don't crash
on boolean literal schemas (\const: true\/\�num: [true]\). Adds
stripBooleanLiterals helper applied to the schema fed into quicktype.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The schema can now flag methods and types as internal. The codegen splits
internal RPC methods into parallel structures so they don't appear on the
public client API:

- TypeScript: createInternalServerRpc / createInternalSessionRpc factories
  alongside the existing public ones; client.ts wires connect() through a
  private internalRpc getter.
- C#: ConnectAsync and ConnectResult are emitted with the internal access
  modifier (real assembly-boundary access control).
- Python: parallel InternalServerRpc / InternalSessionRpc classes with
  ':meta private:' docstrings.
- Go: parallel InternalServerRpc / InternalSessionRpc types with their own
  unexported backing struct and NewInternalServerRpc constructor.
- Internal type definitions get a per-language doc-comment marker.
- New filterNodeByVisibility() helper in scripts/codegen/utils.ts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@SteveSandersonMS SteveSandersonMS force-pushed the stevesa/sdk-server-connection-token branch from 3b3d25f to 9c7c2cc Compare May 4, 2026 09:41
Comment thread scripts/codegen/python.ts Fixed
@github-actions

This comment has been minimized.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by SDK Consistency Review Agent for issue #1176 · ● 609K

Comment thread python/copilot/client.py Outdated
@github-actions

This comment has been minimized.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by SDK Consistency Review Agent for issue #1176 · ● 913.5K

Comment thread python/copilot/client.py Outdated
Comment thread nodejs/src/client.ts
finally
{
// Best-effort cleanup; ignore stop errors when the client failed to start.
try { await wrongClient.ForceStopAsync(); } catch (Exception) { }
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't fix — the empty/generic catch is intentional best-effort cleanup after a deliberately-failed StartAsync. Catching narrower types (e.g. InvalidOperationException) risks letting unrelated failures fail the test on the cleanup path. Added an explanatory comment in commit 4f681cd.

finally
{
// Best-effort cleanup; ignore stop errors when the client failed to start.
try { await noTokenClient.ForceStopAsync(); } catch (Exception) { }
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't fix — see reply on the parallel comment for line 80. Best-effort cleanup; intentional broad catch with explanatory comment.

finally
{
// Best-effort cleanup; ignore stop errors when the client failed to start.
try { await wrongClient.ForceStopAsync(); } catch (Exception) { }
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't fix — same reasoning as the duplicate comments above. The catch is intentionally broad for best-effort cleanup; comment in commit 4f681cd explains why.

Comment thread dotnet/test/ConnectionTokenTests.cs Dismissed
SteveSandersonMS and others added 4 commits May 4, 2026 11:19
Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com>
CliUrl/CLIUrl always implies TCP mode (it overrides useStdio/UseStdio
internally), so rejecting the token when useStdio: true is set should
only fire when no cliUrl is provided. Brings Node and Go in line with
the .NET behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The 'cliUrl + useStdio:true' combo is already rejected upfront as
mutually exclusive in both Node (client.ts:310) and Go (client.go:165),
so the extra '&& !cliUrl' guard added in 496ed7d was unreachable.
The bot's suggestion was based on a misreading; .NET only needs the
compound check because it silently coerces UseStdio=false instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Previously the .NET SDK silently coerced UseStdio = true to false when
CliUrl was provided, while Node rejects that combination as mutually
exclusive. Make UseStdio a bool? defaulting to null so we can detect
'explicitly true' vs 'unset', and throw on the mutually-exclusive case
just like Node does. Defaults are unchanged: unset + no CliUrl resolves
to stdio, unset + CliUrl resolves to TCP.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

SteveSandersonMS and others added 2 commits May 4, 2026 11:27
Mirror the fix already applied to .NET/Go/Python: sibling clients
connecting via cliUrl don't have access to the auto-generated token
from the server-spawning client, so the suspend and pending-work-resume
E2E tests need an explicit shared token.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

So callers that pass options.CliUrl don't trip the new mutually-exclusive
guard from CopilotClient. Caller-provided useStdio still wins; null lets
the SDK pick the default.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 4, 2026

Cross-SDK Consistency Review ✅

This PR adds TCP connection token support across all four SDKs simultaneously. The implementation is well-coordinated and highly consistent. Here's the breakdown:

✅ What's consistent across all SDKs

Aspect Node.js Python Go .NET
Option name tcpConnectionToken tcp_connection_token TCPConnectionToken TcpConnectionToken
Auto-generate token in TCP mode ✅ UUID ✅ UUID ✅ UUID ✅ GUID
connect handshake with fallback to ping
COPILOT_CONNECTION_TOKEN env var forwarded to CLI
Token in ExternalServerConfig/external URL mode
4 parallel E2E test scenarios
AUTHENTICATION_FAILED error on wrong/missing token

All naming conventions are idiomatic for each language. The feature parity is complete.

⚠️ Minor validation nuance worth noting

There's a small behavioural difference in how each SDK validates the "token combined with stdio mode" case:

  • Python (SubprocessConfig): use_stdio defaults to True (a concrete default), so the check if config.tcp_connection_token is not None and config.use_stdio fires immediately if you set tcp_connection_token without explicitly setting use_stdio=False. This proactively prevents the silent no-op.

  • Node.js / Go / .NET: useStdio/UseStdio defaults to null/nil/undefined, and validation only fires when explicitly set to true. Setting a token without explicitly opting into TCP mode is silently permitted (though the token would be sent via connect and likely just fall through to ping on a non-token-aware server).

Python's behaviour is arguably more correct here — if a caller provides a token, they almost certainly intend TCP mode, and an early error is more helpful than a silent no-op. It may be worth bringing the other three SDKs into alignment by also raising when the effective transport mode is stdio (not just when useStdio is explicitly true). That said, this is a minor UX nuance rather than a functional bug — the token would simply be unused when the SDK defaults to stdio, causing no security issue.

Summary

This is a thorough and well-implemented cross-SDK feature. No blocking consistency issues found. The optional follow-up above is a suggestion, not a requirement.

Generated by SDK Consistency Review Agent for issue #1176 · ● 707.4K ·

@SteveSandersonMS SteveSandersonMS merged commit 180ca47 into main May 4, 2026
38 of 39 checks passed
@SteveSandersonMS SteveSandersonMS deleted the stevesa/sdk-server-connection-token branch May 4, 2026 11:00
tclem added a commit that referenced this pull request May 4, 2026
Codegen was crashing on the post-1.0.41-0 schema with
`TypeError: s.split is not a function` at toPascalCase. Root cause:
the new `connect` JSON-RPC method's `ConnectResult.ok` field is
declared as `{ "type": "boolean", "enum": [true] }` (a single-value
boolean enum used as a "must-be-true" discriminant). The Rust
generator's `emitRustStringEnum` assumes string values, fed a
boolean, and panicked.

Python and Go generators already pre-process the schema with
`stripBooleanLiterals` (utils.ts:146) for exactly this reason —
upstream PR #1176 added the helper precisely because quicktype's
Python/Go renderers crashed on the same input. The TypeScript and
C# generators handle it natively. Rust's generator wasn't pre-
processing, so it inherited the same crash.

Fix: apply `stripBooleanLiterals` to both `apiSchema` and
`sessionEventsSchema` in `rust.ts` before postProcessSchema /
emit. Mirrors the Python/Go invocation pattern. Boolean
literal narrowing isn't expressible in Rust enums anyway —
the field stays `pub ok: bool`.

Regen produces two new types from the post-1.0.41-0 schema:
- `ConnectRequest` (token: Option<String>)
- `ConnectResult` (ok: bool, protocolVersion: u32)
- rpc_methods::CONNECT constant

These reflect the `connect` JSON-RPC method our hand-coded
verify_protocol_version handshake already invokes via
`client.call("connect", ...)`. We don't switch the call site to
the typed RPC because the existing untyped call matches how
`ping` is invoked next door, and the handshake needs the special
MethodNotFound fallback that doesn't fit the typed RPC abstraction.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants