authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-15 15:09:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 15:09:25-07:00
log2c16c1d264173d86fc354e9a4c0c78cd42e514b7
tree19b05adfd3232313e670b96b774b774d32a3cadc
parente6ae764f47d678b96f4a1b2c8c76eedf1a342e26
signature Signed by PGP key B5690EEEBB952194

fix(mobile): block incompatible server connections (#11974)


20 files changed, 234 insertions(+), 22 deletions(-)

apps/mobile/src/features/connection/ConnectionStatusDot.tsx+1
...@@ -35,6 +35,7 @@ function statusDotTone(state: ConnectionStatusDotState): {...@@ -35,6 +35,7 @@ function statusDotTone(state: ConnectionStatusDotState): {
35 haloColor: "rgba(245,158,11,0.5)",35 haloColor: "rgba(245,158,11,0.5)",
36 };36 };
37 case "offline":37 case "offline":
38 case "unsupported":
38 case "error":39 case "error":
39 return {40 return {
40 dotColor: "#ef4444",41 dotColor: "#ef4444",
apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx+6-2
...@@ -16,6 +16,8 @@ function noticeTitle(phase: EnvironmentConnectionPhase, environmentLabel: string...@@ -16,6 +16,8 @@ function noticeTitle(phase: EnvironmentConnectionPhase, environmentLabel: string
16 return `Connecting to ${environmentLabel}...`;16 return `Connecting to ${environmentLabel}...`;
17 case "reconnecting":17 case "reconnecting":
18 return `Reconnecting to ${environmentLabel}...`;18 return `Reconnecting to ${environmentLabel}...`;
19 case "unsupported":
20 return "Client not supported";
19 case "error":21 case "error":
20 return `${environmentLabel} is unavailable`;22 return `${environmentLabel} is unavailable`;
21 case "available":23 case "available":
...@@ -31,7 +33,7 @@ function noticeDetail(...@@ -31,7 +33,7 @@ function noticeDetail(
31 error: string | null,33 error: string | null,
32): string {34): string {
33 if (error) {35 if (error) {
34 return `The app will keep retrying automatically. ${error}`;36 return phase === "reconnecting" ? `The app will keep retrying automatically. ${error}` : error;
35 }37 }
3638
37 switch (phase) {39 switch (phase) {
...@@ -40,6 +42,8 @@ function noticeDetail(...@@ -40,6 +42,8 @@ function noticeDetail(
40 case "connecting":42 case "connecting":
41 case "reconnecting":43 case "reconnecting":
42 return `The ${resourceName} will load as soon as the environment is ready.`;44 return `The ${resourceName} will load as soon as the environment is ready.`;
45 case "unsupported":
46 return "Use compatible versions of the app and server to connect.";
43 case "available":47 case "available":
44 case "error":48 case "error":
45 return `Reconnect the environment to load the ${resourceName}.`;49 return `Reconnect the environment to load the ${resourceName}.`;
...@@ -95,7 +99,7 @@ export function EnvironmentConnectionNotice(props: {...@@ -95,7 +99,7 @@ export function EnvironmentConnectionNotice(props: {
95 ) : null}99 ) : null}
96 </Text>100 </Text>
97101
98 {props.connection.phase !== "offline" ? (102 {props.connection.phase !== "offline" && props.connection.phase !== "unsupported" ? (
99 <Pressable103 <Pressable
100 accessibilityRole="button"104 accessibilityRole="button"
101 className="mt-1 rounded-full bg-subtle px-4 py-2.5 active:opacity-70"105 className="mt-1 rounded-full bg-subtle px-4 py-2.5 active:opacity-70"
apps/mobile/src/features/connection/connectionTone.ts+6
...@@ -21,6 +21,12 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone {...@@ -21,6 +21,12 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone {
21 pillClassName: "bg-primary/10",21 pillClassName: "bg-primary/10",
22 textClassName: "text-foreground-secondary",22 textClassName: "text-foreground-secondary",
23 };23 };
24 case "unsupported":
25 return {
26 label: "Client not supported",
27 pillClassName: "bg-danger",
28 textClassName: "text-danger-foreground",
29 };
24 case "error":30 case "error":
25 return {31 return {
26 label: "Connection failed",32 label: "Connection failed",
apps/mobile/src/features/home/HomeScreen.tsx+6-2
...@@ -169,11 +169,15 @@ function deriveEmptyState(props: {...@@ -169,11 +169,15 @@ function deriveEmptyState(props: {
169 if (169 if (
170 (catalogState.connectionState === "available" ||170 (catalogState.connectionState === "available" ||
171 catalogState.connectionState === "offline" ||171 catalogState.connectionState === "offline" ||
172 catalogState.connectionState === "error") &&172 catalogState.connectionState === "error" ||
173 catalogState.connectionState === "unsupported") &&
173 !catalogState.hasLoadedShellSnapshot174 !catalogState.hasLoadedShellSnapshot
174 ) {175 ) {
175 return {176 return {
176 title: "Environment unavailable",177 title:
178 catalogState.connectionState === "unsupported"
179 ? "Client not supported"
180 : "Environment unavailable",
177 detail:181 detail:
178 catalogState.connectionError ??182 catalogState.connectionError ??
179 "The saved environment is offline. Check the URL or start the environment, then retry.",183 "The saved environment is offline. Check the URL or start the environment, then retry.",
apps/mobile/src/features/threads/floating-working-status.ts+2
...@@ -51,6 +51,8 @@ export function connectionFloatingStatus(input: {...@@ -51,6 +51,8 @@ export function connectionFloatingStatus(input: {
51 };51 };
52 case "offline":52 case "offline":
53 return unavailable("You are offline");53 return unavailable("You are offline");
54 case "unsupported":
55 return unavailable("Client not supported");
54 case "error":56 case "error":
55 return unavailable(57 return unavailable(
56 input.connectionError58 input.connectionError
apps/mobile/src/state/asset-url-state.ts+2-1
...@@ -21,7 +21,8 @@ export function deriveAssetUrlState(input: {...@@ -21,7 +21,8 @@ export function deriveAssetUrlState(input: {
21 if (21 if (
22 input.connectionPhase === "offline" ||22 input.connectionPhase === "offline" ||
23 input.connectionPhase === "reconnecting" ||23 input.connectionPhase === "reconnecting" ||
24 input.connectionPhase === "error"24 input.connectionPhase === "error" ||
25 input.connectionPhase === "unsupported"
25 ) {26 ) {
26 return { _tag: "Failure", reason: "disconnected" };27 return { _tag: "Failure", reason: "disconnected" };
27 }28 }
apps/mobile/src/state/workspaceModel.ts+3
...@@ -65,6 +65,9 @@ function overallConnectionState(...@@ -65,6 +65,9 @@ function overallConnectionState(
65 if (environments.some((environment) => environment.connectionState === "connecting")) {65 if (environments.some((environment) => environment.connectionState === "connecting")) {
66 return "connecting";66 return "connecting";
67 }67 }
68 if (environments.some((environment) => environment.connectionState === "unsupported")) {
69 return "unsupported";
70 }
68 if (environments.some((environment) => environment.connectionState === "error")) {71 if (environments.some((environment) => environment.connectionState === "error")) {
69 return "error";72 return "error";
70 }73 }
apps/server/src/environment/ServerEnvironment.test.ts+2
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
2import { expect, it } from "@effect/vitest";3import { expect, it } from "@effect/vitest";
3import * as Crypto from "effect/Crypto";4import * as Crypto from "effect/Crypto";
4import * as Deferred from "effect/Deferred";5import * as Deferred from "effect/Deferred";
...@@ -163,6 +164,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {...@@ -163,6 +164,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
163 }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir)));164 }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir)));
164165
165 expect(first.environmentId).toBe(second.environmentId);166 expect(first.environmentId).toBe(second.environmentId);
167 expect(first.orchestrationProtocolVersion).toBe(ORCHESTRATION_PROTOCOL_VERSION);
166 expect(second.capabilities.repositoryIdentity).toBe(true);168 expect(second.capabilities.repositoryIdentity).toBe(true);
167 expect(second.capabilities.connectionProbe).toBe(true);169 expect(second.capabilities.connectionProbe).toBe(true);
168 expect(second.capabilities.attachmentUploads).toBe(true);170 expect(second.capabilities.attachmentUploads).toBe(true);
apps/server/src/environment/ServerEnvironment.ts+2
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 EnvironmentId,2 EnvironmentId,
3 ORCHESTRATION_PROTOCOL_VERSION,
3 PROVIDER_SEND_TURN_MAX_FILE_BYTES,4 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
4 type ExecutionEnvironmentDescriptor,5 type ExecutionEnvironmentDescriptor,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
...@@ -212,6 +213,7 @@ export const make = Effect.gen(function* () {...@@ -212,6 +213,7 @@ export const make = Effect.gen(function* () {
212 ...(machine === null ? {} : { machine }),213 ...(machine === null ? {} : { machine }),
213 },214 },
214 serverVersion: packageJson.version,215 serverVersion: packageJson.version,
216 orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION,
215 capabilities: {217 capabilities: {
216 repositoryIdentity: true,218 repositoryIdentity: true,
217 connectionProbe: true,219 connectionProbe: true,
apps/web/src/components/ConnectionStatusDot.tsx+1
...@@ -11,6 +11,7 @@ export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase):...@@ -11,6 +11,7 @@ export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase):
11 case "connecting":11 case "connecting":
12 case "reconnecting":12 case "reconnecting":
13 return "bg-warning";13 return "bg-warning";
14 case "unsupported":
14 case "error":15 case "error":
15 return "bg-destructive";16 return "bg-destructive";
16 default:17 default:
apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts+1
...@@ -29,6 +29,7 @@ function normalizeConnectionState(phase: string | undefined): EnvironmentUpdateC...@@ -29,6 +29,7 @@ function normalizeConnectionState(phase: string | undefined): EnvironmentUpdateC
29 case "connecting":29 case "connecting":
30 case "reconnecting":30 case "reconnecting":
31 return "connecting";31 return "connecting";
32 case "unsupported":
32 case "error":33 case "error":
33 return "error";34 return "error";
34 case "offline":35 case "offline":
apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts+3-1
...@@ -36,9 +36,11 @@ export function presentSavedCloudEnvironmentConnection(...@@ -36,9 +36,11 @@ export function presentSavedCloudEnvironmentConnection(
36 statusText: connectionStatusText(connection),36 statusText: connectionStatusText(connection),
37 tone: "connecting",37 tone: "connecting",
38 };38 };
39 case "unsupported":
39 case "error":40 case "error":
40 return {41 return {
41 buttonLabel: "Connection failed",42 buttonLabel:
43 connection.phase === "unsupported" ? "Client not supported" : "Connection failed",
42 statusText: connectionStatusText(connection),44 statusText: connectionStatusText(connection),
43 tone: "error",45 tone: "error",
44 };46 };
apps/web/src/components/settings/ConnectionsSettings.tsx+2
...@@ -1456,6 +1456,8 @@ function savedBackendStatus(environment: EnvironmentPresentation): {...@@ -1456,6 +1456,8 @@ function savedBackendStatus(environment: EnvironmentPresentation): {
1456 text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting",1456 text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting",
1457 tone: "error",1457 tone: "error",
1458 };1458 };
1459 case "unsupported":
1460 return { text: "Client not supported", tone: "error" };
1459 case "error":1461 case "error":
1460 return {1462 return {
1461 text: connection.error ? `Connection failed: ${connection.error}` : "Connection failed",1463 text: connection.error ? `Connection failed: ${connection.error}` : "Connection failed",
packages/client-runtime/src/connection/compatibility.test.ts created+54
...@@ -0,0 +1,54 @@
1import {
2 EnvironmentId,
3 ORCHESTRATION_PROTOCOL_VERSION,
4 type ExecutionEnvironmentDescriptor,
5} from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";
7
8import {
9 appendOrchestrationProtocol,
10 orchestrationProtocolCompatibilityError,
11} from "./compatibility.ts";
12
13const descriptor = (orchestrationProtocolVersion?: number): ExecutionEnvironmentDescriptor => ({
14 environmentId: EnvironmentId.make("environment-remote"),
15 label: "Build Mac",
16 platform: { os: "darwin", arch: "arm64" },
17 serverVersion: "9.0.0",
18 ...(orchestrationProtocolVersion === undefined ? {} : { orchestrationProtocolVersion }),
19 capabilities: { repositoryIdentity: true },
20});
21
22describe("orchestration protocol compatibility", () => {
23 it("accepts the current protocol and announces it without disturbing socket credentials", () => {
24 expect(
25 orchestrationProtocolCompatibilityError(descriptor(ORCHESTRATION_PROTOCOL_VERSION)),
26 ).toBeNull();
27
28 const socketUrl = new URL(
29 appendOrchestrationProtocol("wss://host.test/ws?wsTicket=secret&connectionMethod=relay"),
30 );
31 expect(socketUrl.searchParams.get("orchestrationProtocol")).toBe(
32 String(ORCHESTRATION_PROTOCOL_VERSION),
33 );
34 expect(socketUrl.searchParams.get("wsTicket")).toBe("secret");
35 expect(socketUrl.searchParams.get("connectionMethod")).toBe("relay");
36 });
37
38 it("treats missing metadata as protocol 1", () => {
39 const error = orchestrationProtocolCompatibilityError(descriptor());
40 if (Number(ORCHESTRATION_PROTOCOL_VERSION) === 1) {
41 expect(error).toBeNull();
42 } else {
43 expect(error).toMatchObject({ reason: "unsupported" });
44 }
45 });
46
47 it("blocks a different protocol before connecting", () => {
48 const error = orchestrationProtocolCompatibilityError(
49 descriptor(ORCHESTRATION_PROTOCOL_VERSION + 1),
50 );
51 expect(error).toMatchObject({ reason: "unsupported" });
52 expect(error?.message).toContain("This client is not supported");
53 });
54});
packages/client-runtime/src/connection/compatibility.ts created+30
...@@ -0,0 +1,30 @@
1import {
2 ORCHESTRATION_PROTOCOL_QUERY_PARAM,
3 ORCHESTRATION_PROTOCOL_VERSION,
4 type ExecutionEnvironmentDescriptor,
5} from "@t3tools/contracts";
6
7import { ConnectionBlockedError } from "./model.ts";
8
9export function orchestrationProtocolCompatibilityError(
10 descriptor: ExecutionEnvironmentDescriptor,
11): ConnectionBlockedError | null {
12 // Servers shipped before negotiation use the original wire protocol.
13 const serverProtocolVersion = descriptor.orchestrationProtocolVersion ?? 1;
14 if (serverProtocolVersion === ORCHESTRATION_PROTOCOL_VERSION) {
15 return null;
16 }
17 return new ConnectionBlockedError({
18 reason: "unsupported",
19 detail:
20 serverProtocolVersion > ORCHESTRATION_PROTOCOL_VERSION
21 ? `This client is not supported by this server. Update your app or use a compatible release to connect to ${descriptor.label}.`
22 : `This client requires a newer server. Update T3 Code on ${descriptor.label} to connect.`,
23 });
24}
25
26export function appendOrchestrationProtocol(socketUrl: string): string {
27 const url = new URL(socketUrl);
28 url.searchParams.set(ORCHESTRATION_PROTOCOL_QUERY_PARAM, String(ORCHESTRATION_PROTOCOL_VERSION));
29 return url.toString();
30}
packages/client-runtime/src/connection/presentation.test.ts+16
...@@ -5,6 +5,7 @@ import * as Option from "effect/Option";...@@ -5,6 +5,7 @@ import * as Option from "effect/Option";
5import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts";5import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts";
6import {6import {
7 BearerConnectionTarget,7 BearerConnectionTarget,
8 ConnectionBlockedError,
8 ConnectionTransientError,9 ConnectionTransientError,
9 type SupervisorConnectionState,10 type SupervisorConnectionState,
10} from "./model.ts";11} from "./model.ts";
...@@ -51,6 +52,21 @@ function supervisorState(overrides: Partial<SupervisorConnectionState>): Supervi...@@ -51,6 +52,21 @@ function supervisorState(overrides: Partial<SupervisorConnectionState>): Supervi
51}52}
5253
53describe("connection presentation", () => {54describe("connection presentation", () => {
55 it("labels a blocked protocol as unsupported", () => {
56 const connection = presentConnectionState(
57 supervisorState({
58 phase: "blocked",
59 lastFailure: new ConnectionBlockedError({
60 reason: "unsupported",
61 detail: "Update your app.",
62 }),
63 }),
64 );
65 expect(connection.phase).toBe("unsupported");
66 expect(connection.error).toBe("Update your app.");
67 expect(connectionStatusText(connection)).toBe("Client not supported");
68 });
69
54 it("preserves profile display information without exposing credentials", () => {70 it("preserves profile display information without exposing credentials", () => {
55 expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test");71 expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test");
56 });72 });
packages/client-runtime/src/connection/presentation.ts+5-2
...@@ -10,7 +10,8 @@ export type EnvironmentConnectionPhase =...@@ -10,7 +10,8 @@ export type EnvironmentConnectionPhase =
10 | "connecting"10 | "connecting"
11 | "reconnecting"11 | "reconnecting"
12 | "connected"12 | "connected"
13 | "error";13 | "error"
14 | "unsupported";
1415
15export interface EnvironmentConnectionPresentation {16export interface EnvironmentConnectionPresentation {
16 readonly phase: EnvironmentConnectionPhase;17 readonly phase: EnvironmentConnectionPhase;
...@@ -48,7 +49,7 @@ export function presentConnectionState(...@@ -48,7 +49,7 @@ export function presentConnectionState(
48 };49 };
49 case "blocked":50 case "blocked":
50 return {51 return {
51 phase: "error",52 phase: state.lastFailure?.reason === "unsupported" ? "unsupported" : "error",
52 error: state.lastFailure?.message ?? null,53 error: state.lastFailure?.message ?? null,
53 traceId: state.lastFailure?.traceId ?? null,54 traceId: state.lastFailure?.traceId ?? null,
54 };55 };
...@@ -69,6 +70,8 @@ export function connectionStatusText(connection: EnvironmentConnectionPresentati...@@ -69,6 +70,8 @@ export function connectionStatusText(connection: EnvironmentConnectionPresentati
69 : "Reconnecting...";70 : "Reconnecting...";
70 case "connected":71 case "connected":
71 return "Connected";72 return "Connected";
73 case "unsupported":
74 return "Client not supported";
72 case "error":75 case "error":
73 return connection.error76 return connection.error
74 ? `Connection failed. Reason: ${connection.error}`77 ? `Connection failed. Reason: ${connection.error}`
packages/client-runtime/src/connection/resolver.test.ts+45-4
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { EnvironmentId, type DesktopSshEnvironmentTarget } from "@t3tools/contracts";1import {
2 EnvironmentId,
3 ORCHESTRATION_PROTOCOL_VERSION,
4 type DesktopSshEnvironmentTarget,
5} from "@t3tools/contracts";
2import { RelayClientTracer } from "@t3tools/shared/relayTracing";6import { RelayClientTracer } from "@t3tools/shared/relayTracing";
3import { describe, expect, it } from "@effect/vitest";7import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
...@@ -28,6 +32,7 @@ import {...@@ -28,6 +32,7 @@ import {
28 type ConnectionTarget,32 type ConnectionTarget,
29} from "./model.ts";33} from "./model.ts";
30import * as ConnectionProfileStore from "./profileStore.ts";34import * as ConnectionProfileStore from "./profileStore.ts";
35import { remoteHttpClientLayer } from "../rpc/http.ts";
31import {36import {
32 GitHubRoutingPermissions,37 GitHubRoutingPermissions,
33 gitHubRoutingConnectionKey,38 gitHubRoutingConnectionKey,
...@@ -75,6 +80,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o...@@ -75,6 +80,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
75 readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"];80 readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"];
76 readonly primaryBearerToken?: string;81 readonly primaryBearerToken?: string;
77 readonly prepareSsh?: ClientCapabilities.SshEnvironmentGateway["Service"]["prepare"];82 readonly prepareSsh?: ClientCapabilities.SshEnvironmentGateway["Service"]["prepare"];
83 readonly descriptorProtocolVersion?: number | null | undefined;
78}) => {84}) => {
79 const profiles = new Map(85 const profiles = new Map(
80 (options?.profiles ?? []).map((profile) => [profile.connectionId, profile]),86 (options?.profiles ?? []).map((profile) => [profile.connectionId, profile]),
...@@ -140,6 +146,21 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o...@@ -140,6 +146,21 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
140 });146 });
141147
142 const dependencies = Layer.mergeAll(148 const dependencies = Layer.mergeAll(
149 remoteHttpClientLayer((() =>
150 Promise.resolve(
151 Response.json({
152 environmentId: ENVIRONMENT_ID,
153 label: "Compatible environment",
154 platform: { os: "linux", arch: "x64" },
155 serverVersion: "0.0.0-test",
156 ...(options?.descriptorProtocolVersion === undefined
157 ? { orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION }
158 : options.descriptorProtocolVersion === null
159 ? {}
160 : { orchestrationProtocolVersion: options.descriptorProtocolVersion }),
161 capabilities: { repositoryIdentity: true },
162 }),
163 )) satisfies typeof fetch),
143 Layer.succeed(164 Layer.succeed(
144 ConnectionProfileStore.ConnectionProfileStore,165 ConnectionProfileStore.ConnectionProfileStore,
145 options?.profileStore ?? profileStore,166 options?.profileStore ?? profileStore,
...@@ -166,6 +187,26 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o...@@ -166,6 +187,26 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
166});187});
167188
168describe("ConnectionResolver", () => {189describe("ConnectionResolver", () => {
190 it.effect("blocks an incompatible host during discovery before opening orchestration RPC", () =>
191 Effect.gen(function* () {
192 const brokerLayer = yield* makeDependencies({
193 descriptorProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION + 1,
194 });
195 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
196 const target = new PrimaryConnectionTarget({
197 environmentId: ENVIRONMENT_ID,
198 label: "Primary",
199 httpBaseUrl: "http://127.0.0.1:3777",
200 wsBaseUrl: "ws://127.0.0.1:3777",
201 });
202
203 const error = yield* Effect.flip(broker.prepare(catalogEntry(target)));
204
205 expect(error).toMatchObject({ reason: "unsupported" });
206 expect(error.message).toContain("This client is not supported");
207 }),
208 );
209
169 it.effect("prepares a primary environment without remote capabilities", () =>210 it.effect("prepares a primary environment without remote capabilities", () =>
170 Effect.gen(function* () {211 Effect.gen(function* () {
171 const brokerLayer = yield* makeDependencies();212 const brokerLayer = yield* makeDependencies();
...@@ -182,7 +223,7 @@ describe("ConnectionResolver", () => {...@@ -182,7 +223,7 @@ describe("ConnectionResolver", () => {
182 label: "Primary",223 label: "Primary",
183 httpBaseUrl: "http://127.0.0.1:3777",224 httpBaseUrl: "http://127.0.0.1:3777",
184 socketUrl:225 socketUrl:
185 "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct",226 "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=1",
186 httpAuthorization: null,227 httpAuthorization: null,
187 target,228 target,
188 });229 });
...@@ -220,7 +261,7 @@ describe("ConnectionResolver", () => {...@@ -220,7 +261,7 @@ describe("ConnectionResolver", () => {
220 });261 });
221262
222 expect(yield* broker.prepare(catalogEntry(target))).toMatchObject({263 expect(yield* broker.prepare(catalogEntry(target))).toMatchObject({
223 socketUrl: "ws://127.0.0.1:3777/ws?wsTicket=desktop",264 socketUrl: "ws://127.0.0.1:3777/ws?wsTicket=desktop&orchestrationProtocol=1",
224 httpAuthorization: { _tag: "Bearer", token: "desktop-bearer" },265 httpAuthorization: { _tag: "Bearer", token: "desktop-bearer" },
225 target,266 target,
226 });267 });
...@@ -284,7 +325,7 @@ describe("ConnectionResolver", () => {...@@ -284,7 +325,7 @@ describe("ConnectionResolver", () => {
284 environmentId: ENVIRONMENT_ID,325 environmentId: ENVIRONMENT_ID,
285 label: "Authorized relay environment",326 label: "Authorized relay environment",
286 httpBaseUrl: ENDPOINT.httpBaseUrl,327 httpBaseUrl: ENDPOINT.httpBaseUrl,
287 socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop",328 socketUrl: `wss://authorized.example.test/ws?wsTicket=dpop&orchestrationProtocol=${ORCHESTRATION_PROTOCOL_VERSION}`,
288 httpAuthorization: {329 httpAuthorization: {
289 _tag: "Dpop",330 _tag: "Dpop",
290 accessToken: "dpop-access-token",331 accessToken: "dpop-access-token",
packages/client-runtime/src/connection/resolver.ts+41-10
...@@ -2,6 +2,7 @@ import type { AuthClientPresentationMetadata } from "@t3tools/contracts";...@@ -2,6 +2,7 @@ import type { AuthClientPresentationMetadata } from "@t3tools/contracts";
2import { withRelayClientTracing } from "@t3tools/shared/relayTracing";2import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
3import * as Context from "effect/Context";3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as HttpClient from "effect/unstable/http/HttpClient";
5import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";7import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
...@@ -16,7 +17,12 @@ import {...@@ -16,7 +17,12 @@ import {
16 SshConnectionProfile,17 SshConnectionProfile,
17} from "./catalog.ts";18} from "./catalog.ts";
18import * as ConnectionCredentialStore from "./credentialStore.ts";19import * as ConnectionCredentialStore from "./credentialStore.ts";
19import { credentialMissingError, environmentMismatchError, profileMissingError } from "./errors.ts";20import {
21 credentialMissingError,
22 environmentMismatchError,
23 mapRemoteEnvironmentError,
24 profileMissingError,
25} from "./errors.ts";
20import {26import {
21 GitHubRoutingPermissions,27 GitHubRoutingPermissions,
22 gitHubRoutingConnectionKey,28 gitHubRoutingConnectionKey,
...@@ -31,6 +37,11 @@ import type {...@@ -31,6 +37,11 @@ import type {
31} from "./model.ts";37} from "./model.ts";
32import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts";38import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts";
33import * as ConnectionProfileStore from "./profileStore.ts";39import * as ConnectionProfileStore from "./profileStore.ts";
40import {
41 appendOrchestrationProtocol,
42 orchestrationProtocolCompatibilityError,
43} from "./compatibility.ts";
44import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
3445
35export class ConnectionResolver extends Context.Service<46export class ConnectionResolver extends Context.Service<
36 ConnectionResolver,47 ConnectionResolver,
...@@ -234,6 +245,7 @@ export const make = Effect.gen(function* () {...@@ -234,6 +245,7 @@ export const make = Effect.gen(function* () {
234 const bearer = yield* makeBearerBroker();245 const bearer = yield* makeBearerBroker();
235 const relay = yield* makeRelayBroker();246 const relay = yield* makeRelayBroker();
236 const ssh = yield* makeSshBroker();247 const ssh = yield* makeSshBroker();
248 const httpClient = yield* HttpClient.HttpClient;
237249
238 const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* (250 const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* (
239 entry: ConnectionCatalogEntry,251 entry: ConnectionCatalogEntry,
...@@ -243,16 +255,35 @@ export const make = Effect.gen(function* () {...@@ -243,16 +255,35 @@ export const make = Effect.gen(function* () {
243 "connection.environment.id": target.environmentId,255 "connection.environment.id": target.environmentId,
244 "connection.target.kind": target._tag,256 "connection.target.kind": target._tag,
245 });257 });
246 switch (target._tag) {258 const prepared = yield* (() => {
247 case "PrimaryConnectionTarget":259 switch (target._tag) {
248 return yield* primary(target);260 case "PrimaryConnectionTarget":
249 case "BearerConnectionTarget":261 return primary(target);
250 return yield* bearer({ ...entry, target });262 case "BearerConnectionTarget":
251 case "RelayConnectionTarget":263 return bearer({ ...entry, target });
252 return yield* relay(target);264 case "RelayConnectionTarget":
253 case "SshConnectionTarget":265 return relay(target);
254 return yield* ssh({ ...entry, target });266 case "SshConnectionTarget":
267 return ssh({ ...entry, target });
268 }
269 })();
270 const descriptor = yield* fetchRemoteEnvironmentDescriptor({
271 httpBaseUrl: prepared.httpBaseUrl,
272 }).pipe(
273 Effect.mapError(mapRemoteEnvironmentError),
274 Effect.provideService(HttpClient.HttpClient, httpClient),
275 );
276 if (descriptor.environmentId !== target.environmentId) {
277 return yield* environmentMismatchError({
278 expected: target.environmentId,
279 actual: descriptor.environmentId,
280 });
281 }
282 const compatibilityError = orchestrationProtocolCompatibilityError(descriptor);
283 if (compatibilityError !== null) {
284 return yield* compatibilityError;
255 }285 }
286 return { ...prepared, socketUrl: appendOrchestrationProtocol(prepared.socketUrl) };
256 });287 });
257288
258 return ConnectionResolver.of({ prepare });289 return ConnectionResolver.of({ prepare });
packages/contracts/src/environment.ts+6
...@@ -9,6 +9,10 @@ import {...@@ -9,6 +9,10 @@ import {
9 TrimmedNonEmptyString,9 TrimmedNonEmptyString,
10} from "./baseSchemas.ts";10} from "./baseSchemas.ts";
1111
12/** Wire version for orchestration snapshots, streams, commands, and RPC payloads. */
13export const ORCHESTRATION_PROTOCOL_VERSION = 1;
14export const ORCHESTRATION_PROTOCOL_QUERY_PARAM = "orchestrationProtocol";
15
12export const ExecutionEnvironmentPlatformOs = Schema.Literals([16export const ExecutionEnvironmentPlatformOs = Schema.Literals([
13 "darwin",17 "darwin",
14 "linux",18 "linux",
...@@ -175,6 +179,8 @@ export const ExecutionEnvironmentDescriptor = Schema.Struct({...@@ -175,6 +179,8 @@ export const ExecutionEnvironmentDescriptor = Schema.Struct({
175 label: TrimmedNonEmptyString,179 label: TrimmedNonEmptyString,
176 platform: ExecutionEnvironmentPlatform,180 platform: ExecutionEnvironmentPlatform,
177 serverVersion: TrimmedNonEmptyString,181 serverVersion: TrimmedNonEmptyString,
182 /** Missing metadata denotes protocol 1. Bump this for breaking wire changes. */
183 orchestrationProtocolVersion: Schema.optionalKey(Schema.Int),
178 capabilities: ExecutionEnvironmentCapabilities,184 capabilities: ExecutionEnvironmentCapabilities,
179});185});
180export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type;186export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type;