From 2c16c1d264173d86fc354e9a4c0c78cd42e514b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 15 Sep 2026 15:09:25 -0700 Subject: [PATCH] fix(mobile): block incompatible server connections (#11974) --- .../connection/ConnectionStatusDot.tsx | 1 + .../EnvironmentConnectionNotice.tsx | 8 ++- .../src/features/connection/connectionTone.ts | 6 +++ apps/mobile/src/features/home/HomeScreen.tsx | 8 ++- .../threads/floating-working-status.ts | 2 + apps/mobile/src/state/asset-url-state.ts | 3 +- apps/mobile/src/state/workspaceModel.ts | 3 ++ .../src/environment/ServerEnvironment.test.ts | 2 + .../src/environment/ServerEnvironment.ts | 2 + .../src/components/ConnectionStatusDot.tsx | 1 + ...erUpdateLaunchNotification.environments.ts | 1 + .../cloudEnvironmentConnectionPresentation.ts | 4 +- .../settings/ConnectionsSettings.tsx | 2 + .../src/connection/compatibility.test.ts | 54 +++++++++++++++++++ .../src/connection/compatibility.ts | 30 +++++++++++ .../src/connection/presentation.test.ts | 16 ++++++ .../src/connection/presentation.ts | 7 ++- .../src/connection/resolver.test.ts | 49 +++++++++++++++-- .../client-runtime/src/connection/resolver.ts | 51 ++++++++++++++---- packages/contracts/src/environment.ts | 6 +++ 20 files changed, 234 insertions(+), 22 deletions(-) create mode 100644 packages/client-runtime/src/connection/compatibility.test.ts create mode 100644 packages/client-runtime/src/connection/compatibility.ts diff --git a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx index ce5c6a6419e1f4b958ab9b55260d6b861ddd701e..beed3f99e1765bc2e4beaf52936284af1c1fb09f 100644 --- a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx +++ b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx @@ -35,6 +35,7 @@ function statusDotTone(state: ConnectionStatusDotState): { haloColor: "rgba(245,158,11,0.5)", }; case "offline": + case "unsupported": case "error": return { dotColor: "#ef4444", diff --git a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx index 4bb15fc9872af8eebcb0bb3d8868f6097ec1d88d..ca799a0c8192015b12c0977bc915c62c4e577889 100644 --- a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx +++ b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx @@ -16,6 +16,8 @@ function noticeTitle(phase: EnvironmentConnectionPhase, environmentLabel: string return `Connecting to ${environmentLabel}...`; case "reconnecting": return `Reconnecting to ${environmentLabel}...`; + case "unsupported": + return "Client not supported"; case "error": return `${environmentLabel} is unavailable`; case "available": @@ -31,7 +33,7 @@ function noticeDetail( error: string | null, ): string { if (error) { - return `The app will keep retrying automatically. ${error}`; + return phase === "reconnecting" ? `The app will keep retrying automatically. ${error}` : error; } switch (phase) { @@ -40,6 +42,8 @@ function noticeDetail( case "connecting": case "reconnecting": return `The ${resourceName} will load as soon as the environment is ready.`; + case "unsupported": + return "Use compatible versions of the app and server to connect."; case "available": case "error": return `Reconnect the environment to load the ${resourceName}.`; @@ -95,7 +99,7 @@ export function EnvironmentConnectionNotice(props: { ) : null} - {props.connection.phase !== "offline" ? ( + {props.connection.phase !== "offline" && props.connection.phase !== "unsupported" ? ( environment.connectionState === "connecting")) { return "connecting"; } + if (environments.some((environment) => environment.connectionState === "unsupported")) { + return "unsupported"; + } if (environments.some((environment) => environment.connectionState === "error")) { return "error"; } diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 2577866838b18937ddb74bba6aa739df7ad0940f..f2299cdd599cb7763b14ed9667f047e42250cc86 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -163,6 +164,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); expect(first.environmentId).toBe(second.environmentId); + expect(first.orchestrationProtocolVersion).toBe(ORCHESTRATION_PROTOCOL_VERSION); expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.attachmentUploads).toBe(true); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 9c25767245df234f9fea3c0414d5f7cbff75fe2a..64d8dfab1733cadba7c900ad08edbd298e9d20bb 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -1,5 +1,6 @@ import { EnvironmentId, + ORCHESTRATION_PROTOCOL_VERSION, PROVIDER_SEND_TURN_MAX_FILE_BYTES, type ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; @@ -212,6 +213,7 @@ export const make = Effect.gen(function* () { ...(machine === null ? {} : { machine }), }, serverVersion: packageJson.version, + orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, capabilities: { repositoryIdentity: true, connectionProbe: true, diff --git a/apps/web/src/components/ConnectionStatusDot.tsx b/apps/web/src/components/ConnectionStatusDot.tsx index 6a23a05328732ee5b0caf435346663cac9424a90..382e6d25b86efecf73d9e8a97e9421e861675b53 100644 --- a/apps/web/src/components/ConnectionStatusDot.tsx +++ b/apps/web/src/components/ConnectionStatusDot.tsx @@ -11,6 +11,7 @@ export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase): case "connecting": case "reconnecting": return "bg-warning"; + case "unsupported": case "error": return "bg-destructive"; default: diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts index 2fb9c16b654a937ba3148fa89c295afbcd7ec156..3c4a443c44a2ef6c0b4a05d60903cc4b58555393 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts @@ -29,6 +29,7 @@ function normalizeConnectionState(phase: string | undefined): EnvironmentUpdateC case "connecting": case "reconnecting": return "connecting"; + case "unsupported": case "error": return "error"; case "offline": diff --git a/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts b/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts index f2f3395f6d548bc66be131253f339ae17857a418..119a3366ac63e85ceca5723b936af231d6dc1fce 100644 --- a/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts +++ b/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts @@ -36,9 +36,11 @@ export function presentSavedCloudEnvironmentConnection( statusText: connectionStatusText(connection), tone: "connecting", }; + case "unsupported": case "error": return { - buttonLabel: "Connection failed", + buttonLabel: + connection.phase === "unsupported" ? "Client not supported" : "Connection failed", statusText: connectionStatusText(connection), tone: "error", }; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index f76e6ef10d64eb7021b187c219029760cec6f30b..28117bf0898b90fc889a9f7bd3b6b975d16331de 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1456,6 +1456,8 @@ function savedBackendStatus(environment: EnvironmentPresentation): { text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting", tone: "error", }; + case "unsupported": + return { text: "Client not supported", tone: "error" }; case "error": return { text: connection.error ? `Connection failed: ${connection.error}` : "Connection failed", diff --git a/packages/client-runtime/src/connection/compatibility.test.ts b/packages/client-runtime/src/connection/compatibility.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6a2cf3ca18f618e2570023bc48426ac624b823b --- /dev/null +++ b/packages/client-runtime/src/connection/compatibility.test.ts @@ -0,0 +1,54 @@ +import { + EnvironmentId, + ORCHESTRATION_PROTOCOL_VERSION, + type ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + appendOrchestrationProtocol, + orchestrationProtocolCompatibilityError, +} from "./compatibility.ts"; + +const descriptor = (orchestrationProtocolVersion?: number): ExecutionEnvironmentDescriptor => ({ + environmentId: EnvironmentId.make("environment-remote"), + label: "Build Mac", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "9.0.0", + ...(orchestrationProtocolVersion === undefined ? {} : { orchestrationProtocolVersion }), + capabilities: { repositoryIdentity: true }, +}); + +describe("orchestration protocol compatibility", () => { + it("accepts the current protocol and announces it without disturbing socket credentials", () => { + expect( + orchestrationProtocolCompatibilityError(descriptor(ORCHESTRATION_PROTOCOL_VERSION)), + ).toBeNull(); + + const socketUrl = new URL( + appendOrchestrationProtocol("wss://host.test/ws?wsTicket=secret&connectionMethod=relay"), + ); + expect(socketUrl.searchParams.get("orchestrationProtocol")).toBe( + String(ORCHESTRATION_PROTOCOL_VERSION), + ); + expect(socketUrl.searchParams.get("wsTicket")).toBe("secret"); + expect(socketUrl.searchParams.get("connectionMethod")).toBe("relay"); + }); + + it("treats missing metadata as protocol 1", () => { + const error = orchestrationProtocolCompatibilityError(descriptor()); + if (Number(ORCHESTRATION_PROTOCOL_VERSION) === 1) { + expect(error).toBeNull(); + } else { + expect(error).toMatchObject({ reason: "unsupported" }); + } + }); + + it("blocks a different protocol before connecting", () => { + const error = orchestrationProtocolCompatibilityError( + descriptor(ORCHESTRATION_PROTOCOL_VERSION + 1), + ); + expect(error).toMatchObject({ reason: "unsupported" }); + expect(error?.message).toContain("This client is not supported"); + }); +}); diff --git a/packages/client-runtime/src/connection/compatibility.ts b/packages/client-runtime/src/connection/compatibility.ts new file mode 100644 index 0000000000000000000000000000000000000000..0a08ddb2acfabfcf16e8e5c84334e6fa316fa0e5 --- /dev/null +++ b/packages/client-runtime/src/connection/compatibility.ts @@ -0,0 +1,30 @@ +import { + ORCHESTRATION_PROTOCOL_QUERY_PARAM, + ORCHESTRATION_PROTOCOL_VERSION, + type ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; + +import { ConnectionBlockedError } from "./model.ts"; + +export function orchestrationProtocolCompatibilityError( + descriptor: ExecutionEnvironmentDescriptor, +): ConnectionBlockedError | null { + // Servers shipped before negotiation use the original wire protocol. + const serverProtocolVersion = descriptor.orchestrationProtocolVersion ?? 1; + if (serverProtocolVersion === ORCHESTRATION_PROTOCOL_VERSION) { + return null; + } + return new ConnectionBlockedError({ + reason: "unsupported", + detail: + serverProtocolVersion > ORCHESTRATION_PROTOCOL_VERSION + ? `This client is not supported by this server. Update your app or use a compatible release to connect to ${descriptor.label}.` + : `This client requires a newer server. Update T3 Code on ${descriptor.label} to connect.`, + }); +} + +export function appendOrchestrationProtocol(socketUrl: string): string { + const url = new URL(socketUrl); + url.searchParams.set(ORCHESTRATION_PROTOCOL_QUERY_PARAM, String(ORCHESTRATION_PROTOCOL_VERSION)); + return url.toString(); +} diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index 979b6adb400303ab57b882517fcf4656e8c4ee8a..fabc4703459922dc1a3fea488ebff06f742d745f 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -5,6 +5,7 @@ import * as Option from "effect/Option"; import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts"; import { BearerConnectionTarget, + ConnectionBlockedError, ConnectionTransientError, type SupervisorConnectionState, } from "./model.ts"; @@ -51,6 +52,21 @@ function supervisorState(overrides: Partial): Supervi } describe("connection presentation", () => { + it("labels a blocked protocol as unsupported", () => { + const connection = presentConnectionState( + supervisorState({ + phase: "blocked", + lastFailure: new ConnectionBlockedError({ + reason: "unsupported", + detail: "Update your app.", + }), + }), + ); + expect(connection.phase).toBe("unsupported"); + expect(connection.error).toBe("Update your app."); + expect(connectionStatusText(connection)).toBe("Client not supported"); + }); + it("preserves profile display information without exposing credentials", () => { expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test"); }); diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index 4093167d333cf6b851bc45f9101f04dd857bcd73..f7586c5e3dbfb9f4d0b577bbc621500322bf437e 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -10,7 +10,8 @@ export type EnvironmentConnectionPhase = | "connecting" | "reconnecting" | "connected" - | "error"; + | "error" + | "unsupported"; export interface EnvironmentConnectionPresentation { readonly phase: EnvironmentConnectionPhase; @@ -48,7 +49,7 @@ export function presentConnectionState( }; case "blocked": return { - phase: "error", + phase: state.lastFailure?.reason === "unsupported" ? "unsupported" : "error", error: state.lastFailure?.message ?? null, traceId: state.lastFailure?.traceId ?? null, }; @@ -69,6 +70,8 @@ export function connectionStatusText(connection: EnvironmentConnectionPresentati : "Reconnecting..."; case "connected": return "Connected"; + case "unsupported": + return "Client not supported"; case "error": return connection.error ? `Connection failed. Reason: ${connection.error}` diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index ecc5d7153914b68887883f7ba1df5f9ed4887f65..faad768811d9b2d5e3818523196a3ce6c5ce09a9 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -1,4 +1,8 @@ -import { EnvironmentId, type DesktopSshEnvironmentTarget } from "@t3tools/contracts"; +import { + EnvironmentId, + ORCHESTRATION_PROTOCOL_VERSION, + type DesktopSshEnvironmentTarget, +} from "@t3tools/contracts"; import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -28,6 +32,7 @@ import { type ConnectionTarget, } from "./model.ts"; import * as ConnectionProfileStore from "./profileStore.ts"; +import { remoteHttpClientLayer } from "../rpc/http.ts"; import { GitHubRoutingPermissions, gitHubRoutingConnectionKey, @@ -75,6 +80,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; readonly primaryBearerToken?: string; readonly prepareSsh?: ClientCapabilities.SshEnvironmentGateway["Service"]["prepare"]; + readonly descriptorProtocolVersion?: number | null | undefined; }) => { const profiles = new Map( (options?.profiles ?? []).map((profile) => [profile.connectionId, profile]), @@ -140,6 +146,21 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o }); const dependencies = Layer.mergeAll( + remoteHttpClientLayer((() => + Promise.resolve( + Response.json({ + environmentId: ENVIRONMENT_ID, + label: "Compatible environment", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + ...(options?.descriptorProtocolVersion === undefined + ? { orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION } + : options.descriptorProtocolVersion === null + ? {} + : { orchestrationProtocolVersion: options.descriptorProtocolVersion }), + capabilities: { repositoryIdentity: true }, + }), + )) satisfies typeof fetch), Layer.succeed( ConnectionProfileStore.ConnectionProfileStore, options?.profileStore ?? profileStore, @@ -166,6 +187,26 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o }); describe("ConnectionResolver", () => { + it.effect("blocks an incompatible host during discovery before opening orchestration RPC", () => + Effect.gen(function* () { + const brokerLayer = yield* makeDependencies({ + descriptorProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION + 1, + }); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const target = new PrimaryConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Primary", + httpBaseUrl: "http://127.0.0.1:3777", + wsBaseUrl: "ws://127.0.0.1:3777", + }); + + const error = yield* Effect.flip(broker.prepare(catalogEntry(target))); + + expect(error).toMatchObject({ reason: "unsupported" }); + expect(error.message).toContain("This client is not supported"); + }), + ); + it.effect("prepares a primary environment without remote capabilities", () => Effect.gen(function* () { const brokerLayer = yield* makeDependencies(); @@ -182,7 +223,7 @@ describe("ConnectionResolver", () => { label: "Primary", httpBaseUrl: "http://127.0.0.1:3777", socketUrl: - "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct", + "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=1", httpAuthorization: null, target, }); @@ -220,7 +261,7 @@ describe("ConnectionResolver", () => { }); expect(yield* broker.prepare(catalogEntry(target))).toMatchObject({ - socketUrl: "ws://127.0.0.1:3777/ws?wsTicket=desktop", + socketUrl: "ws://127.0.0.1:3777/ws?wsTicket=desktop&orchestrationProtocol=1", httpAuthorization: { _tag: "Bearer", token: "desktop-bearer" }, target, }); @@ -284,7 +325,7 @@ describe("ConnectionResolver", () => { environmentId: ENVIRONMENT_ID, label: "Authorized relay environment", httpBaseUrl: ENDPOINT.httpBaseUrl, - socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop", + socketUrl: `wss://authorized.example.test/ws?wsTicket=dpop&orchestrationProtocol=${ORCHESTRATION_PROTOCOL_VERSION}`, httpAuthorization: { _tag: "Dpop", accessToken: "dpop-access-token", diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index f51c6ac607ba055df6ed9e9d088037d466e46f56..af1a417fc594f9a2428ac949735249184fb62f32 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -2,6 +2,7 @@ import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -16,7 +17,12 @@ import { SshConnectionProfile, } from "./catalog.ts"; import * as ConnectionCredentialStore from "./credentialStore.ts"; -import { credentialMissingError, environmentMismatchError, profileMissingError } from "./errors.ts"; +import { + credentialMissingError, + environmentMismatchError, + mapRemoteEnvironmentError, + profileMissingError, +} from "./errors.ts"; import { GitHubRoutingPermissions, gitHubRoutingConnectionKey, @@ -31,6 +37,11 @@ import type { } from "./model.ts"; import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; import * as ConnectionProfileStore from "./profileStore.ts"; +import { + appendOrchestrationProtocol, + orchestrationProtocolCompatibilityError, +} from "./compatibility.ts"; +import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; export class ConnectionResolver extends Context.Service< ConnectionResolver, @@ -234,6 +245,7 @@ export const make = Effect.gen(function* () { const bearer = yield* makeBearerBroker(); const relay = yield* makeRelayBroker(); const ssh = yield* makeSshBroker(); + const httpClient = yield* HttpClient.HttpClient; const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( entry: ConnectionCatalogEntry, @@ -243,16 +255,35 @@ export const make = Effect.gen(function* () { "connection.environment.id": target.environmentId, "connection.target.kind": target._tag, }); - switch (target._tag) { - case "PrimaryConnectionTarget": - return yield* primary(target); - case "BearerConnectionTarget": - return yield* bearer({ ...entry, target }); - case "RelayConnectionTarget": - return yield* relay(target); - case "SshConnectionTarget": - return yield* ssh({ ...entry, target }); + const prepared = yield* (() => { + switch (target._tag) { + case "PrimaryConnectionTarget": + return primary(target); + case "BearerConnectionTarget": + return bearer({ ...entry, target }); + case "RelayConnectionTarget": + return relay(target); + case "SshConnectionTarget": + return ssh({ ...entry, target }); + } + })(); + const descriptor = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: prepared.httpBaseUrl, + }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + if (descriptor.environmentId !== target.environmentId) { + return yield* environmentMismatchError({ + expected: target.environmentId, + actual: descriptor.environmentId, + }); } + const compatibilityError = orchestrationProtocolCompatibilityError(descriptor); + if (compatibilityError !== null) { + return yield* compatibilityError; + } + return { ...prepared, socketUrl: appendOrchestrationProtocol(prepared.socketUrl) }; }); return ConnectionResolver.of({ prepare }); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index c8b8833ead8680d73ce49ed06bcc01da8cbc9770..6d5690187104e0f8bab51d06fa86cb22e6d7c81b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -9,6 +9,10 @@ import { TrimmedNonEmptyString, } from "./baseSchemas.ts"; +/** Wire version for orchestration snapshots, streams, commands, and RPC payloads. */ +export const ORCHESTRATION_PROTOCOL_VERSION = 1; +export const ORCHESTRATION_PROTOCOL_QUERY_PARAM = "orchestrationProtocol"; + export const ExecutionEnvironmentPlatformOs = Schema.Literals([ "darwin", "linux", @@ -175,6 +179,8 @@ export const ExecutionEnvironmentDescriptor = Schema.Struct({ label: TrimmedNonEmptyString, platform: ExecutionEnvironmentPlatform, serverVersion: TrimmedNonEmptyString, + /** Missing metadata denotes protocol 1. Bump this for breaking wire changes. */ + orchestrationProtocolVersion: Schema.optionalKey(Schema.Int), capabilities: ExecutionEnvironmentCapabilities, }); export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type; -- 2.54.0