Skip to content

feat(client): typed client for paired environments - #15874

Open
saphid wants to merge 2 commits into
pingdotgg:mainfrom
saphid:stack/01-external-client
Open

saphid wants to merge 2 commits into
pingdotgg:mainfrom
saphid:stack/01-external-client

Conversation

@saphid

@saphid saphid commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Rebased 2026-10-10 onto main 6d5ea190a4; head 11e28bec50. A follow-up commit adapts the package to main. It uses Effect 4.0.2 module paths and main's layerXyz naming, so nodeRuntimeLayer is now layerNodeRuntime. Main also changed pairing so the pairing link decides a client's scopes. pair() no longer has a scopes option and no longer requests read+operate; create the link with npx t3 auth pairing create --scope ... to narrow it. request() now uses the same tag set as client-runtime's request, so protected source-control and scheduled-task writes are not offered. The lockfile was regenerated with vp i. GPT-6.1 Sol (high) reviewed this port: SHIP; the latest rebase changed only a package.json context line. At this head these pass: package tests (client 3 files / 23 tests, client-runtime 2 files / 72 tests), typecheck (client, client-runtime, web, mobile), lint and fmt on the changed files, and knip. The live-server runs below predate this port and used pair({ scopes }); they were not repeated.

Problem

There is no supported way for a script to talk to a T3 Code environment. Anyone automating T3 Code today (list threads across machines, send a message from a cron job or another tool) has to reverse-engineer the WebSocket RPC, re-implement pairing, ticketed sockets, protocol negotiation and reconnects, and usually ends up holding a broad token. Web and mobile already have all of that in client-runtime, but nothing outside the apps can use it.

Why this qualifies

This implements the direction of Ideas discussion #6977 (local app integration SDK). No maintainer has agreed to that direction yet. We are opening it so the shape can be judged as real code. It does not depend on any other PR.

Who uses this? Honestly: nothing in the apps. The package is private: true, and its only in-repo consumer is the runnable example in packages/client/examples/. The users are people scripting their own environments, which is exactly the question #6977 asks a maintainer to answer. If the answer is no, we close this.

Fix

  • New private workspace package @t3tools/client (no server change, nothing new on the wire):
    • pair() pairs with an ordinary pairing link through the same onboarding path the apps use, and asks for orchestration:read + orchestration:operate only. The result is a normal revocable session that shows in Settings > Connections under its label. The token is Redacted everywhere except encodeCredential.
    • connect(credential) builds one real EnvironmentSupervisor per environment with the existing connection driver, resolver and RPC session. It exposes ready, typed request for unary RPCs and subscribe for durable subscriptions (types come straight from @t3tools/contracts; the four finite stream-command RPCs are not exposed), shell, sendMessage, and retryNow/disconnect/reconnect. ready waits for a live session, not just a connected phase, so a request made while a dropped session is still cleaning up waits for the replacement. Blocked states (auth, scope, server too old) fail at once; transient ones retry with the shared backoff. Mutations are never replayed after a reconnect.
    • layerNodeRuntime (global fetch, WebSocket, Web Crypto; Node 22+).
    • examples/list-threads-and-send.ts: pair, list (several environments at once), send. Credential files are created exclusively with mode 0600.
  • client-runtime (needed by the package, so it stays in this PR):
    • namespace exports for ConnectionDriver, ConnectionResolver, RpcSessionFactory and RemoteEnvironmentAuthorization;
    • subscribeArchivedShell and subscribeBackgroundPolicy are now classified as subscriptions in the shared RPC tag union (they are stream: true in the contract but were typed as unary); no app calls either through these helpers, and web/mobile typecheck unchanged;
    • an additive EnvironmentSupervisor.make(...).control(request) that returns once the run loop has taken a connect/disconnect/retry request, with the state it replaces. Without it, a request made right after reconnect or retryNow can run on the old session or report the old block. Existing connect/disconnect/retryNow, the retry ladder and probes are unchanged; web and mobile do not call it.
  • knip.jsonc / knip:check: the package is included in the export audit, with the example as its entry.

Size: 22 files, +1613 / −30. Source is +577 (client +472, client-runtime +105), the example +106, tests +854; the rest is package config and the lockfile.

Evidence

Environment: macOS 26.5 arm64, Node 24. This PR's head is 027363fb3e on upstream main de09c7566c.

Remote proof over the tailnet, at the pre-rebase head (41ad831057 on main eac52f0087). An isolated vp run dev --share server from this PR's checkout, on fresh local state, paired only through its own startup pairing link. Every request below went to the https://<tailnet-origin> share (wss for the socket), not localhost. Output is sanitized: no tokens, links, hostnames or home paths.

Before (main): packages/client and the example don't exist (git cat-file -e eac52f0087:packages/client/examples/list-threads-and-send.ts → fatal: … not in 'eac52f0087', rc 128; still absent on de09c7566c). A script would have to rebuild pairing, the ticketed socket, protocol negotiation and reconnect from the wire format itself.

After:

$ T3_PAIRING_URL=<server's own link> node examples/list-threads-and-send.ts pair operate.json
Paired with <environment-label> (80e1e9db-…).                        rc=0   (file mode -rw-------)

$ node examples/list-threads-and-send.ts list operate.json readonly.json
<environment-label> (80e1e9db-…) server 0.0.45, protocol 2, scopes orchestration:read orchestration:operate (project default "New thread" row omitted below)
  a865f33d-…  Evidence thread
<environment-label> (80e1e9db-…) server 0.0.45, protocol 2, scopes orchestration:read
  a865f33d-…  Evidence thread                                         rc=0

$ node examples/list-threads-and-send.ts send operate.json a865f33d-… "hello from the external client over the tailnet"
Sent message 1acef39e-… (command c2cf9645-…).                          rc=0

$ node examples/list-threads-and-send.ts send readonly.json a865f33d-… "this must be refused"
EnvironmentAuthorizationError: The authenticated token is missing required scope: orchestration:operate.   rc=1

Transport interruption: a long-lived client listed threads, then the server was restarted under it (its node --watch entry was touched). sendMessage was called right after the session dropped. It waited through backoff and ran once on the new session:

+0.18s state phase=connected
+0.19s shell before interruption: 2 thread(s)
+1.46s state phase=backoff lastFailure=ConnectionTransientError
+1.46s session dropped; issuing sendMessage now (should wait, then run once)
+2.60s … +4.98s connecting / backoff (server restarting)
+5.25s state phase=connected
+5.30s sendMessage succeeded: message c9142f5f-…
+5.34s shell after recovery: 2 thread(s); same environment object

The server's store had each sent message exactly once, under one command id (message.updated + turn-item.updated). It had no event for the refused send, and a Claude reply followed each accepted message.

Setup notes: the fresh fixture's project and thread were created through this client's own request (projects.mutate, thread.create). The read-only credential came from pair({ scopes: ["orchestration:read"] }) on a second one-time link minted by that server (t3 auth pairing create). The example has no scope flag, so a small driver did those two steps and the restart run, using only the public API.

Re-run at this head (027363fb3e) after the rebase, on localhost. The rebase merged control into the supervisor's new multi-route loop (#15467), so the same sequence ran again against an isolated vp run dev server from this checkout, on fresh local state: pair (file mode -rw-------), list with an operate and a read-only credential (scopes as above, both list the thread), send (rc 0), the read-only send refused with the same EnvironmentAuthorizationError (rc 1), and the restart run: connected → backoff → connected in about 6 s, with sendMessage issued while the session was down, then running once on the new session (+7.45s sendMessage succeeded). The store again had one message.updated per accepted message under its own command id, and nothing for the refused send.

Earlier local proof on the pre-port branch (two isolated servers) also covered list across two different environments.

Checks at this head (027363fb3e), run 2026-10-05 (CI=true, all exit 0):

  • cd packages/client && vp test run: 3 files, 24/24 pass. On main with only supervisor.ts reverted, the environment tests fail with supervisor.control is not a function; the credential and credential-file tests cover code that is new in this PR. The lease-wait regression (Deferred-gated cleanup of a dropped session) fails on the previous head with the request failing at once instead of waiting.
  • cd packages/client-runtime && vp test run src/connection/supervisor.test.ts src/rpc/client.test.ts: 2 files, 76/76 pass; the new control test fails on main (recorded during development). A compile-time check in client.test.ts fails typecheck if any stream: true RPC is missing from the stream tag unions (it fails on the previous union).
  • vp run --filter <pkg> typecheck for @t3tools/client, @t3tools/client-runtime, @t3tools/web and @t3tools/mobile (the supervisor's return type gained a member); vp lint --report-unused-disable-directives (0 diagnostics) and vp fmt --check on the touched files; vp run knip:check; web build; vp run build:desktop; node scripts/release-smoke.ts. All pass.

Surfaces

  • Entry points: a script/CLI only. No chat, Settings, command palette or keybinding change. The resulting session appears in the existing Settings > Connections list and is revoked there.
  • Clients: web, desktop and mobile unchanged; they share the additive supervisor change but do not call control.
  • Providers: none. sendMessage goes through the same startThreadTurn path as the apps for every provider.
  • Contracts: unchanged. sendMessage is attributed as web because OrchestrationV2CreationSource has no api value; adding one needs a capability flag (left out).
  • Reverse states: pair ↔ revoke in Settings > Connections; disconnect ↔ reconnect.
  • Connection modes: local and direct remote (LAN/Tailscale) use bearer + WS tickets. T3 Connect relay (DPoP sign-in) is refused with a clear unsupported block for now.
  • Docs: none. The package is private with no user-facing surface; the example's header documents usage.

Not verified

  • The tailnet pass ran at the pre-rebase head. At this head the same flow ran on localhost only; the rebase changed no client source: it added two stub methods to a test fake and merged control into the supervisor's new route-switch loop.
  • The remote pass used one environment over the tailnet. Listing two different environments at once was shown only in the earlier pre-port local proof. T3 Connect relay is intentionally unsupported and was not exercised.
  • The interruption was a server restart, not a network drop between client and tailnet. Recovery when a mutation is in flight at the moment of the drop was not exercised; such requests are not replayed by design.
  • Not published to npm: the package exports .ts sources, so it runs inside the monorepo on Node 22+ type stripping only.

Claude Opus 5.5 (build) and GPT-6.1 Sol (review) via T3 Code
🤖 Generated with Claude Code

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Oct 5, 2026
@macroscopeapp

macroscopeapp Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces a substantial external-client capability with pairing, bearer-token storage, remote WebSocket/RPC connectivity, reconnect supervision, and message-sending workflows. It also changes shared connection infrastructure and authentication-related exports, warranting human review despite the extensive tests.

You can add or adjust custom eligibility rules. Learn more.

@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: pingdotgg/t3code/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 71580960-3a28-40cb-9530-94a14f84dd06


📥 Commits

Reviewing files that changed from the base of the PR and between a1d9d72 and 027363f.



⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml


📒 Files selected for processing (21)
  • knip.jsonc
  • package.json
  • packages/client-runtime/src/authorization/index.ts
  • packages/client-runtime/src/connection/index.ts
  • packages/client-runtime/src/connection/supervisor.test.ts
  • packages/client-runtime/src/connection/supervisor.ts
  • packages/client-runtime/src/rpc/client.test.ts
  • packages/client-runtime/src/rpc/client.ts
  • packages/client-runtime/src/rpc/index.ts
  • packages/client/examples/credential-file.test.ts
  • packages/client/examples/credential-file.ts
  • packages/client/examples/list-threads-and-send.ts
  • packages/client/package.json
  • packages/client/src/credential.test.ts
  • packages/client/src/credential.ts
  • packages/client/src/environment.test.ts
  • packages/client/src/environment.ts
  • packages/client/src/index.ts
  • packages/client/src/runtime.ts
  • packages/client/tsconfig.json
  • packages/client/vite.config.ts


Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.




📝 Walkthrough
📝 Walkthrough

Walkthrough

Adds an external-client package with credential pairing, connection and environment APIs, RPC operations, and Node.js examples. The connection supervisor gains acknowledged lifecycle controls. The package exports a Node runtime layer and adds tests and workspace analysis configuration.

Changes

External client

Layer / File(s) Summary
Supervisor controls and RPC contracts
packages/client-runtime/src/connection/*, packages/client-runtime/src/authorization/index.ts, packages/client-runtime/src/rpc/*
The supervisor adds acknowledged connect, disconnect, and retry controls that return the replaced connection state when applicable. Runtime exports and subscription RPC tags are expanded. Tests cover control results and streaming RPC tag classification.
Credential API and package exports
packages/client/src/credential.ts, packages/client/src/credential.test.ts, packages/client/src/runtime.ts, packages/client/src/index.ts, packages/client/package.json
Adds credential encoding and decoding, pairing with configurable labels and scopes, and the nodeRuntimeLayer. The package entry point exports the client APIs. Pairing tests cover defaults, custom scopes, redacted tokens, encoding, and unsupported protocol versions.
Environment connection and operations
packages/client/src/environment.ts, packages/client/src/environment.test.ts
Adds bearer-authenticated connections, readiness and negotiation handling, unary and streaming RPC operations, shell snapshots, message sending, and lifecycle controls. Tests cover connection states, reconnection, session changes, message delivery, and environment independence.
Client examples and workspace checks
packages/client/examples/*, packages/client/tsconfig.json, packages/client/vite.config.ts, knip.jsonc, package.json
Adds commands to pair, list threads, and send messages. Credential files use exclusive creation with mode 0o600; tests cover file permissions and existing files or symlinks. Workspace and test configuration includes the client package and examples.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant connect
  participant ConnectionDriver
  participant RemoteEnvironmentAuthorization
  participant makeEnvironment
  Caller->>connect: Provide credential
  connect->>ConnectionDriver: Build connection layers
  ConnectionDriver->>RemoteEnvironmentAuthorization: Resolve bearer connection target
  RemoteEnvironmentAuthorization-->>ConnectionDriver: Return validated environment and WebSocket URL
  connect->>makeEnvironment: Provide connection context
  makeEnvironment-->>Caller: Return environment client
Loading

Suggested reviewers: juliusmarminge



Merge Risk: ⚪ Minimal · up to 02736

This adds a new private client package and acknowledged lifecycle controls without changing existing app behavior. No actionable merge-blocking issue was found, and the included tests cover the main behaviors.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 02736

The new automation API retains scoped pairing and protected credential-file creation. No new privilege bypass was established, but concurrent shutdown, mutation recovery, and credential persistence remain partly unverified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — Possession of an encoded credential exposes the associated environment to the session's granted authority. Default scopes permit orchestration reading and operation, not merely the convenience send method; additional scope requests remain bounded by the bootstrap grant. Scripts can hold several independent environment credentials.

Trust Boundaries and Controls

  • observed — The existing server rejects scope escalation during token issuance. Persisted pairing grants are consumed with a conditional database update requiring an unused, unrevoked, unexpired credential and any applicable proof-key match. These controls predate the new client and remain in its pairing path.

Resilience and Maintainability Implications

  • observed — Readiness rejects the acknowledged replaced state and requires a live session for a connected outcome. Unary requests nevertheless capture a session separately from readiness. That capture behavior is unchanged from the PR base; with one credential per environment, the inspected race does not establish a new identity or privilege transition.

Hardening Proposals

  • proposed — Define recovery when pairing succeeds but credential persistence fails, including how the caller identifies and revokes an unretained session. Preserve exclusive, owner-only file creation rather than weakening it to make retries succeed.
  • proposed — Specify that lifecycle acknowledgement is not credential revocation or proof of completed socket teardown. Document ambiguous mutation outcomes and establish the server's same-identifier retry contract before promising safe application-level replay.

Pre-merge checks | Passed 3 | Failed 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 17 files. (4 skipped: 4… Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check Warning The description provides detailed problem, change, verification, scope, limitations, and test evidence. However, the required scope and approval information is incomplete because it states that no mai… Obtain explicit maintainer approval for the feature direction and link the approval comment in a dedicated Scope and approval section. If approval is not available, do not merge the feature under the repository requirements; keep it as a pr…
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly and concisely identifies the main change: a typed client for paired environments.

Full details: Docstring Coverage

Explanation

Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 17 files. (4 skipped: 4 unsupported.)


Full details: Description check

Explanation

The description provides detailed problem, change, verification, scope, limitations, and test evidence. However, the required scope and approval information is incomplete because it states that no maintainer has approved the proposed direction.

Resolution

Obtain explicit maintainer approval for the feature direction and link the approval comment in a dedicated Scope and approval section. If approval is not available, do not merge the feature under the repository requirements; keep it as a proposal for maintainer review instead.


  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR



  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@saphid

saphid commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Review requested

Date (UTC) Reviewer Where
2026-10-06 Julius Discord DM

Logged so this PR shows when a maintainer was asked to review it.

@saphid
saphid force-pushed the stack/01-external-client branch from 027363f to 9f934ce Compare October 10, 2026 00:19
saphid and others added 2 commits October 10, 2026 18:35
Add the private @t3tools/client workspace package. A script pairs with a
T3 Code environment using an ordinary pairing link, stores a revocable
read+operate credential, and talks to one or more environments over the
existing authenticated HTTP and WebSocket RPC: typed request/subscribe over
the full WsRpcGroup, the shell snapshot, and sendMessage. Transport, retry,
negotiation and operations are the ones web and mobile already use.

client-runtime gains namespace exports for the connection driver,
resolver, RPC session factory and remote authorization, plus an additive
EnvironmentSupervisor `control` that returns once the run loop has taken a
connect/disconnect/retry request. The client needs it so a request made
right after reconnect, retry or disconnect reports that request's outcome
instead of the state it replaced. Existing supervisor methods are unchanged.

examples/list-threads-and-send.ts is the runnable consumer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Effect 4.0.2 moved the http and socket modules out of unstable, and layers
are now named layerXyz. Pairing no longer sends client-requested scopes:
the pairing link decides them, so pair() drops its scopes option. request()
leaves out protected writes, matching client-runtime's request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@saphid
saphid force-pushed the stack/01-external-client branch from 9f934ce to 11e28be Compare October 10, 2026 07:38

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant