| 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 | 35 | haloColor: "rgba(245,158,11,0.5)", |
| 36 | 36 | }; |
| 37 | 37 | case "offline": |
| 38 | case "unsupported": | |
| 38 | 39 | case "error": |
| 39 | 40 | return { |
| 40 | 41 | dotColor: "#ef4444", |
apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx+6-2| ... | ... | @@ -16,6 +16,8 @@ function noticeTitle(phase: EnvironmentConnectionPhase, environmentLabel: string |
| 16 | 16 | return `Connecting to ${environmentLabel}...`; |
| 17 | 17 | case "reconnecting": |
| 18 | 18 | return `Reconnecting to ${environmentLabel}...`; |
| 19 | case "unsupported": | |
| 20 | return "Client not supported"; | |
| 19 | 21 | case "error": |
| 20 | 22 | return `${environmentLabel} is unavailable`; |
| 21 | 23 | case "available": |
| ... | ... | @@ -31,7 +33,7 @@ function noticeDetail( |
| 31 | 33 | error: string | null, |
| 32 | 34 | ): string { |
| 33 | 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 | 39 | switch (phase) { |
| ... | ... | @@ -40,6 +42,8 @@ function noticeDetail( |
| 40 | 42 | case "connecting": |
| 41 | 43 | case "reconnecting": |
| 42 | 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 | 47 | case "available": |
| 44 | 48 | case "error": |
| 45 | 49 | return `Reconnect the environment to load the ${resourceName}.`; |
| ... | ... | @@ -95,7 +99,7 @@ export function EnvironmentConnectionNotice(props: { |
| 95 | 99 | ) : null} |
| 96 | 100 | </Text> |
| 97 | 101 | |
| 98 | {props.connection.phase !== "offline" ? ( | |
| 102 | {props.connection.phase !== "offline" && props.connection.phase !== "unsupported" ? ( | |
| 99 | 103 | <Pressable |
| 100 | 104 | accessibilityRole="button" |
| 101 | 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 | 21 | pillClassName: "bg-primary/10", |
| 22 | 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 | 30 | case "error": |
| 25 | 31 | return { |
| 26 | 32 | label: "Connection failed", |
apps/mobile/src/features/home/HomeScreen.tsx+6-2| ... | ... | @@ -169,11 +169,15 @@ function deriveEmptyState(props: { |
| 169 | 169 | if ( |
| 170 | 170 | (catalogState.connectionState === "available" || |
| 171 | 171 | catalogState.connectionState === "offline" || |
| 172 | catalogState.connectionState === "error") && | |
| 172 | catalogState.connectionState === "error" || | |
| 173 | catalogState.connectionState === "unsupported") && | |
| 173 | 174 | !catalogState.hasLoadedShellSnapshot |
| 174 | 175 | ) { |
| 175 | 176 | return { |
| 176 | title: "Environment unavailable", | |
| 177 | title: | |
| 178 | catalogState.connectionState === "unsupported" | |
| 179 | ? "Client not supported" | |
| 180 | : "Environment unavailable", | |
| 177 | 181 | detail: |
| 178 | 182 | catalogState.connectionError ?? |
| 179 | 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 | 51 | }; |
| 52 | 52 | case "offline": |
| 53 | 53 | return unavailable("You are offline"); |
| 54 | case "unsupported": | |
| 55 | return unavailable("Client not supported"); | |
| 54 | 56 | case "error": |
| 55 | 57 | return unavailable( |
| 56 | 58 | input.connectionError |
apps/mobile/src/state/asset-url-state.ts+2-1| ... | ... | @@ -21,7 +21,8 @@ export function deriveAssetUrlState(input: { |
| 21 | 21 | if ( |
| 22 | 22 | input.connectionPhase === "offline" || |
| 23 | 23 | input.connectionPhase === "reconnecting" || |
| 24 | input.connectionPhase === "error" | |
| 24 | input.connectionPhase === "error" || | |
| 25 | input.connectionPhase === "unsupported" | |
| 25 | 26 | ) { |
| 26 | 27 | return { _tag: "Failure", reason: "disconnected" }; |
| 27 | 28 | } |
apps/mobile/src/state/workspaceModel.ts+3| ... | ... | @@ -65,6 +65,9 @@ function overallConnectionState( |
| 65 | 65 | if (environments.some((environment) => environment.connectionState === "connecting")) { |
| 66 | 66 | return "connecting"; |
| 67 | 67 | } |
| 68 | if (environments.some((environment) => environment.connectionState === "unsupported")) { | |
| 69 | return "unsupported"; | |
| 70 | } | |
| 68 | 71 | if (environments.some((environment) => environment.connectionState === "error")) { |
| 69 | 72 | return "error"; |
| 70 | 73 | } |
apps/server/src/environment/ServerEnvironment.test.ts+2| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; | |
| 2 | 3 | import { expect, it } from "@effect/vitest"; |
| 3 | 4 | import * as Crypto from "effect/Crypto"; |
| 4 | 5 | import * as Deferred from "effect/Deferred"; |
| ... | ... | @@ -163,6 +164,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { |
| 163 | 164 | }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); |
| 164 | 165 | |
| 165 | 166 | expect(first.environmentId).toBe(second.environmentId); |
| 167 | expect(first.orchestrationProtocolVersion).toBe(ORCHESTRATION_PROTOCOL_VERSION); | |
| 166 | 168 | expect(second.capabilities.repositoryIdentity).toBe(true); |
| 167 | 169 | expect(second.capabilities.connectionProbe).toBe(true); |
| 168 | 170 | expect(second.capabilities.attachmentUploads).toBe(true); |
apps/server/src/environment/ServerEnvironment.ts+2| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { |
| 2 | 2 | EnvironmentId, |
| 3 | ORCHESTRATION_PROTOCOL_VERSION, | |
| 3 | 4 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, |
| 4 | 5 | type ExecutionEnvironmentDescriptor, |
| 5 | 6 | } from "@t3tools/contracts"; |
| ... | ... | @@ -212,6 +213,7 @@ export const make = Effect.gen(function* () { |
| 212 | 213 | ...(machine === null ? {} : { machine }), |
| 213 | 214 | }, |
| 214 | 215 | serverVersion: packageJson.version, |
| 216 | orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, | |
| 215 | 217 | capabilities: { |
| 216 | 218 | repositoryIdentity: true, |
| 217 | 219 | connectionProbe: true, |
apps/web/src/components/ConnectionStatusDot.tsx+1| ... | ... | @@ -11,6 +11,7 @@ export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase): |
| 11 | 11 | case "connecting": |
| 12 | 12 | case "reconnecting": |
| 13 | 13 | return "bg-warning"; |
| 14 | case "unsupported": | |
| 14 | 15 | case "error": |
| 15 | 16 | return "bg-destructive"; |
| 16 | 17 | default: |
apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts+1| ... | ... | @@ -29,6 +29,7 @@ function normalizeConnectionState(phase: string | undefined): EnvironmentUpdateC |
| 29 | 29 | case "connecting": |
| 30 | 30 | case "reconnecting": |
| 31 | 31 | return "connecting"; |
| 32 | case "unsupported": | |
| 32 | 33 | case "error": |
| 33 | 34 | return "error"; |
| 34 | 35 | case "offline": |
apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts+3-1| ... | ... | @@ -36,9 +36,11 @@ export function presentSavedCloudEnvironmentConnection( |
| 36 | 36 | statusText: connectionStatusText(connection), |
| 37 | 37 | tone: "connecting", |
| 38 | 38 | }; |
| 39 | case "unsupported": | |
| 39 | 40 | case "error": |
| 40 | 41 | return { |
| 41 | buttonLabel: "Connection failed", | |
| 42 | buttonLabel: | |
| 43 | connection.phase === "unsupported" ? "Client not supported" : "Connection failed", | |
| 42 | 44 | statusText: connectionStatusText(connection), |
| 43 | 45 | tone: "error", |
| 44 | 46 | }; |
apps/web/src/components/settings/ConnectionsSettings.tsx+2| ... | ... | @@ -1456,6 +1456,8 @@ function savedBackendStatus(environment: EnvironmentPresentation): { |
| 1456 | 1456 | text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting", |
| 1457 | 1457 | tone: "error", |
| 1458 | 1458 | }; |
| 1459 | case "unsupported": | |
| 1460 | return { text: "Client not supported", tone: "error" }; | |
| 1459 | 1461 | case "error": |
| 1460 | 1462 | return { |
| 1461 | 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 | 5 | import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts"; |
| 6 | 6 | import { |
| 7 | 7 | BearerConnectionTarget, |
| 8 | ConnectionBlockedError, | |
| 8 | 9 | ConnectionTransientError, |
| 9 | 10 | type SupervisorConnectionState, |
| 10 | 11 | } from "./model.ts"; |
| ... | ... | @@ -51,6 +52,21 @@ function supervisorState(overrides: Partial<SupervisorConnectionState>): Supervi |
| 51 | 52 | } |
| 52 | 53 | |
| 53 | 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 | 70 | it("preserves profile display information without exposing credentials", () => { |
| 55 | 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 | 10 | | "connecting" |
| 11 | 11 | | "reconnecting" |
| 12 | 12 | | "connected" |
| 13 | | "error"; | |
| 13 | | "error" | |
| 14 | | "unsupported"; | |
| 14 | 15 | |
| 15 | 16 | export interface EnvironmentConnectionPresentation { |
| 16 | 17 | readonly phase: EnvironmentConnectionPhase; |
| ... | ... | @@ -48,7 +49,7 @@ export function presentConnectionState( |
| 48 | 49 | }; |
| 49 | 50 | case "blocked": |
| 50 | 51 | return { |
| 51 | phase: "error", | |
| 52 | phase: state.lastFailure?.reason === "unsupported" ? "unsupported" : "error", | |
| 52 | 53 | error: state.lastFailure?.message ?? null, |
| 53 | 54 | traceId: state.lastFailure?.traceId ?? null, |
| 54 | 55 | }; |
| ... | ... | @@ -69,6 +70,8 @@ export function connectionStatusText(connection: EnvironmentConnectionPresentati |
| 69 | 70 | : "Reconnecting..."; |
| 70 | 71 | case "connected": |
| 71 | 72 | return "Connected"; |
| 73 | case "unsupported": | |
| 74 | return "Client not supported"; | |
| 72 | 75 | case "error": |
| 73 | 76 | return connection.error |
| 74 | 77 | ? `Connection failed. Reason: ${connection.error}` |
packages/client-runtime/src/connection/resolver.test.ts+45-4| ... | ... | @@ -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 | 6 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; |
| 3 | 7 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | 8 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -28,6 +32,7 @@ import { |
| 28 | 32 | type ConnectionTarget, |
| 29 | 33 | } from "./model.ts"; |
| 30 | 34 | import * as ConnectionProfileStore from "./profileStore.ts"; |
| 35 | import { remoteHttpClientLayer } from "../rpc/http.ts"; | |
| 31 | 36 | import { |
| 32 | 37 | GitHubRoutingPermissions, |
| 33 | 38 | gitHubRoutingConnectionKey, |
| ... | ... | @@ -75,6 +80,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o |
| 75 | 80 | readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; |
| 76 | 81 | readonly primaryBearerToken?: string; |
| 77 | 82 | readonly prepareSsh?: ClientCapabilities.SshEnvironmentGateway["Service"]["prepare"]; |
| 83 | readonly descriptorProtocolVersion?: number | null | undefined; | |
| 78 | 84 | }) => { |
| 79 | 85 | const profiles = new Map( |
| 80 | 86 | (options?.profiles ?? []).map((profile) => [profile.connectionId, profile]), |
| ... | ... | @@ -140,6 +146,21 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o |
| 140 | 146 | }); |
| 141 | 147 | |
| 142 | 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 | 164 | Layer.succeed( |
| 144 | 165 | ConnectionProfileStore.ConnectionProfileStore, |
| 145 | 166 | options?.profileStore ?? profileStore, |
| ... | ... | @@ -166,6 +187,26 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o |
| 166 | 187 | }); |
| 167 | 188 | |
| 168 | 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 | 210 | it.effect("prepares a primary environment without remote capabilities", () => |
| 170 | 211 | Effect.gen(function* () { |
| 171 | 212 | const brokerLayer = yield* makeDependencies(); |
| ... | ... | @@ -182,7 +223,7 @@ describe("ConnectionResolver", () => { |
| 182 | 223 | label: "Primary", |
| 183 | 224 | httpBaseUrl: "http://127.0.0.1:3777", |
| 184 | 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 | 227 | httpAuthorization: null, |
| 187 | 228 | target, |
| 188 | 229 | }); |
| ... | ... | @@ -220,7 +261,7 @@ describe("ConnectionResolver", () => { |
| 220 | 261 | }); |
| 221 | 262 | |
| 222 | 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 | 265 | httpAuthorization: { _tag: "Bearer", token: "desktop-bearer" }, |
| 225 | 266 | target, |
| 226 | 267 | }); |
| ... | ... | @@ -284,7 +325,7 @@ describe("ConnectionResolver", () => { |
| 284 | 325 | environmentId: ENVIRONMENT_ID, |
| 285 | 326 | label: "Authorized relay environment", |
| 286 | 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 | 329 | httpAuthorization: { |
| 289 | 330 | _tag: "Dpop", |
| 290 | 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 | 2 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; |
| 3 | 3 | import * as Context from "effect/Context"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as HttpClient from "effect/unstable/http/HttpClient"; | |
| 5 | 6 | import * as Layer from "effect/Layer"; |
| 6 | 7 | import * as Option from "effect/Option"; |
| 7 | 8 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -16,7 +17,12 @@ import { |
| 16 | 17 | SshConnectionProfile, |
| 17 | 18 | } from "./catalog.ts"; |
| 18 | 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 | 26 | import { |
| 21 | 27 | GitHubRoutingPermissions, |
| 22 | 28 | gitHubRoutingConnectionKey, |
| ... | ... | @@ -31,6 +37,11 @@ import type { |
| 31 | 37 | } from "./model.ts"; |
| 32 | 38 | import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; |
| 33 | 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 | 46 | export class ConnectionResolver extends Context.Service< |
| 36 | 47 | ConnectionResolver, |
| ... | ... | @@ -234,6 +245,7 @@ export const make = Effect.gen(function* () { |
| 234 | 245 | const bearer = yield* makeBearerBroker(); |
| 235 | 246 | const relay = yield* makeRelayBroker(); |
| 236 | 247 | const ssh = yield* makeSshBroker(); |
| 248 | const httpClient = yield* HttpClient.HttpClient; | |
| 237 | 249 | |
| 238 | 250 | const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( |
| 239 | 251 | entry: ConnectionCatalogEntry, |
| ... | ... | @@ -243,16 +255,35 @@ export const make = Effect.gen(function* () { |
| 243 | 255 | "connection.environment.id": target.environmentId, |
| 244 | 256 | "connection.target.kind": target._tag, |
| 245 | 257 | }); |
| 246 | switch (target._tag) { | |
| 247 | case "PrimaryConnectionTarget": | |
| 248 | return yield* primary(target); | |
| 249 | case "BearerConnectionTarget": | |
| 250 | return yield* bearer({ ...entry, target }); | |
| 251 | case "RelayConnectionTarget": | |
| 252 | return yield* relay(target); | |
| 253 | case "SshConnectionTarget": | |
| 254 | return yield* ssh({ ...entry, target }); | |
| 258 | const prepared = yield* (() => { | |
| 259 | switch (target._tag) { | |
| 260 | case "PrimaryConnectionTarget": | |
| 261 | return primary(target); | |
| 262 | case "BearerConnectionTarget": | |
| 263 | return bearer({ ...entry, target }); | |
| 264 | case "RelayConnectionTarget": | |
| 265 | return relay(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 | 289 | return ConnectionResolver.of({ prepare }); |
packages/contracts/src/environment.ts+6| ... | ... | @@ -9,6 +9,10 @@ import { |
| 9 | 9 | TrimmedNonEmptyString, |
| 10 | 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 | 16 | export const ExecutionEnvironmentPlatformOs = Schema.Literals([ |
| 13 | 17 | "darwin", |
| 14 | 18 | "linux", |
| ... | ... | @@ -175,6 +179,8 @@ export const ExecutionEnvironmentDescriptor = Schema.Struct({ |
| 175 | 179 | label: TrimmedNonEmptyString, |
| 176 | 180 | platform: ExecutionEnvironmentPlatform, |
| 177 | 181 | serverVersion: TrimmedNonEmptyString, |
| 182 | /** Missing metadata denotes protocol 1. Bump this for breaking wire changes. */ | |
| 183 | orchestrationProtocolVersion: Schema.optionalKey(Schema.Int), | |
| 178 | 184 | capabilities: ExecutionEnvironmentCapabilities, |
| 179 | 185 | }); |
| 180 | 186 | export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type; |