Skip to content

Support optional connection token for TCP servers#1176

Open
SteveSandersonMS wants to merge 6 commits intomainfrom
stevesa/sdk-server-connection-token
Open

Support optional connection token for TCP servers#1176
SteveSandersonMS wants to merge 6 commits intomainfrom
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.

if self._client:
try:
await self._client.stop()
except Exception:
finally:
try:
await wrong.force_stop()
except Exception:
finally:
try:
await no_token.force_stop()
except Exception:
}
finally
{
try { await wrongClient.ForceStopAsync(); } catch { }
}
finally
{
try { await noTokenClient.ForceStopAsync(); } catch { }
}
finally
{
try { await wrongClient.ForceStopAsync(); } catch { }
}
finally
{
try { await noTokenClient.ForceStopAsync(); } catch { }
@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
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
lines.push(` self._session_id = session_id`);
for (const [groupName] of groups) {
lines.push(` self.${toSnakeCase(groupName)} = ${toPascalCase(groupName)}Api(client, session_id)`);
const prefix = classPrefix + (isSession ? "" : "Server");
@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
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 and others added 4 commits May 1, 2026 11:11
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 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
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
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 1, 2026

Cross-SDK Consistency Review ✅ (with one minor note)

This PR implements the TCP connection token feature across all four SDKs. Overall consistency is very good:

Aspect Node.js Python Go .NET
Option name tcpConnectionToken tcp_connection_token TCPConnectionToken TcpConnectionToken
Auto-UUID in subprocess TCP mode
No auto-UUID for external server
stdio + token validation
connectping fallback
E2E tests

One minor inconsistency flagged (inline comment on python/copilot/client.py lines 900–909): Python does not validate that an explicitly-supplied token is non-empty, whereas Node.js and .NET both throw immediately at construction time if an empty string is passed. In the ExternalServerConfig path, an empty string would be forwarded as the token field in the connect RPC call (likely causing a runtime AUTHENTICATION_FAILED); in the SubprocessConfig path it silently generates a UUID instead. A small upfront guard matching the other SDKs would close this gap.

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

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
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.)

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