| author | |
| committer | |
| log | 2c16c1d264173d86fc354e9a4c0c78cd42e514b7 |
| tree | 19b05adfd3232313e670b96b774b774d32a3cadc |
| parent | e6ae764f47d678b96f4a1b2c8c76eedf1a342e26 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | } |
| 36 | 38 | ||
| 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> |
| 97 | 101 | ||
| 98 | {props.connection.phase !== "offline" ? ( | 102 | {props.connection.phase !== "offline" && props.connection.phase !== "unsupported" ? ( |
| 99 | <Pressable | 103 | <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.hasLoadedShellSnapshot | 174 | !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.connectionError | 58 | 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 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | 3 | import { expect, it } from "@effect/vitest"; |
| 3 | import * as Crypto from "effect/Crypto"; | 4 | import * as Crypto from "effect/Crypto"; |
| 4 | import * as Deferred from "effect/Deferred"; | 5 | import * 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))); |
| 164 | 165 | ||
| 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 @@ |
| 1 | import { | 1 | import { |
| 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 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | ORCHESTRATION_PROTOCOL_VERSION, | ||
| 4 | type ExecutionEnvironmentDescriptor, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { describe, expect, it } from "vite-plus/test"; | ||
| 7 | |||
| 8 | import { | ||
| 9 | appendOrchestrationProtocol, | ||
| 10 | orchestrationProtocolCompatibilityError, | ||
| 11 | } from "./compatibility.ts"; | ||
| 12 | |||
| 13 | const 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 | |||
| 22 | describe("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 @@ | ||
| 1 | import { | ||
| 2 | ORCHESTRATION_PROTOCOL_QUERY_PARAM, | ||
| 3 | ORCHESTRATION_PROTOCOL_VERSION, | ||
| 4 | type ExecutionEnvironmentDescriptor, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | |||
| 7 | import { ConnectionBlockedError } from "./model.ts"; | ||
| 8 | |||
| 9 | export 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 | |||
| 26 | export 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"; |
| 5 | import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts"; | 5 | import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts"; |
| 6 | import { | 6 | import { |
| 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 | } |
| 52 | 53 | ||
| 53 | describe("connection presentation", () => { | 54 | describe("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"; | ||
| 14 | 15 | ||
| 15 | export interface EnvironmentConnectionPresentation { | 16 | export 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.error | 76 | 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 @@ |
| 1 | import { EnvironmentId, type DesktopSshEnvironmentTarget } from "@t3tools/contracts"; | 1 | import { |
| 2 | EnvironmentId, | ||
| 3 | ORCHESTRATION_PROTOCOL_VERSION, | ||
| 4 | type DesktopSshEnvironmentTarget, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | 6 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; |
| 3 | import { describe, expect, it } from "@effect/vitest"; | 7 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | import * as Effect from "effect/Effect"; | 8 | import * 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"; |
| 30 | import * as ConnectionProfileStore from "./profileStore.ts"; | 34 | import * as ConnectionProfileStore from "./profileStore.ts"; |
| 35 | import { remoteHttpClientLayer } from "../rpc/http.ts"; | ||
| 31 | import { | 36 | import { |
| 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 | }); |
| 141 | 147 | ||
| 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 | }); |
| 167 | 188 | ||
| 168 | describe("ConnectionResolver", () => { | 189 | describe("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 | }); |
| 221 | 262 | ||
| 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"; |
| 2 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | 2 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; |
| 3 | import * as Context from "effect/Context"; | 3 | import * as Context from "effect/Context"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as HttpClient from "effect/unstable/http/HttpClient"; | ||
| 5 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 7 | import * as Schema from "effect/Schema"; | 8 | import * 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"; |
| 18 | import * as ConnectionCredentialStore from "./credentialStore.ts"; | 19 | import * as ConnectionCredentialStore from "./credentialStore.ts"; |
| 19 | import { credentialMissingError, environmentMismatchError, profileMissingError } from "./errors.ts"; | 20 | import { |
| 21 | credentialMissingError, | ||
| 22 | environmentMismatchError, | ||
| 23 | mapRemoteEnvironmentError, | ||
| 24 | profileMissingError, | ||
| 25 | } from "./errors.ts"; | ||
| 20 | import { | 26 | import { |
| 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"; |
| 32 | import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; | 38 | import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; |
| 33 | import * as ConnectionProfileStore from "./profileStore.ts"; | 39 | import * as ConnectionProfileStore from "./profileStore.ts"; |
| 40 | import { | ||
| 41 | appendOrchestrationProtocol, | ||
| 42 | orchestrationProtocolCompatibilityError, | ||
| 43 | } from "./compatibility.ts"; | ||
| 44 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; | ||
| 34 | 45 | ||
| 35 | export class ConnectionResolver extends Context.Service< | 46 | export 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; | ||
| 237 | 249 | ||
| 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 | }); |
| 257 | 288 | ||
| 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"; |
| 11 | 11 | ||
| 12 | /** Wire version for orchestration snapshots, streams, commands, and RPC payloads. */ | ||
| 13 | export const ORCHESTRATION_PROTOCOL_VERSION = 1; | ||
| 14 | export const ORCHESTRATION_PROTOCOL_QUERY_PARAM = "orchestrationProtocol"; | ||
| 15 | |||
| 12 | export const ExecutionEnvironmentPlatformOs = Schema.Literals([ | 16 | export 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 | }); |
| 180 | export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type; | 186 | export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type; |