From a17f7d2d205d429eb5eb0ebbd2f552dad53b99c8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 18:39:18 +0700 Subject: [PATCH] Fix forward-compatible provider handling for old app clients AgentProviderSchema was z.enum() which caused old clients to reject session messages containing unknown providers (pi, copilot), breaking the entire session. Changed to z.string() for future clients. For currently deployed clients (<0.1.45), the daemon now filters out unknown providers based on the appVersion sent in the WebSocket hello message. Clients that don't send appVersion only see claude/codex/opencode. --- packages/app/src/runtime/host-runtime.ts | 2 + packages/server/src/client/daemon-client.ts | 2 + .../src/server/agent/provider-manifest.ts | 7 +-- .../server/src/server/persisted-config.ts | 4 +- packages/server/src/server/session.ts | 49 ++++++++++++++++++- .../server/src/server/websocket-server.ts | 7 ++- packages/server/src/shared/messages.ts | 1 + 7 files changed, 63 insertions(+), 9 deletions(-) diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index f5405857c..2ad3648e3 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -14,6 +14,7 @@ import { type HostProfile, } from "@/types/host-connection"; import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; +import { resolveAppVersion } from "@/utils/app-version"; import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer"; import { shouldUseDesktopDaemon, @@ -422,6 +423,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { suppressSendErrors: true, clientId, clientType: "mobile" as const, + appVersion: resolveAppVersion() ?? undefined, runtimeGeneration, }; if (connection.type === "directSocket" || connection.type === "directPipe") { diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index dadff3900..9230d5fa1 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -155,6 +155,7 @@ export type DaemonClientConfig = { url: string; clientId: string; clientType?: "mobile" | "browser" | "cli" | "mcp"; + appVersion?: string; runtimeGeneration?: number | null; authHeader?: string; suppressSendErrors?: boolean; @@ -3285,6 +3286,7 @@ export class DaemonClient { clientId: this.config.clientId, clientType: this.config.clientType ?? "cli", protocolVersion: 1, + ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), }), ); } catch (error) { diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index 78eb15e60..34bdfd86b 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -176,12 +176,9 @@ export function getAgentProviderDefinition(provider: string): AgentProviderDefin return definition; } -export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id) as [ - string, - ...string[], -]; +export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id); -export const AgentProviderSchema = z.enum(AGENT_PROVIDER_IDS); +export const AgentProviderSchema = z.string(); export function isValidAgentProvider(value: string): boolean { return AGENT_PROVIDER_IDS.includes(value); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index f80a4ece8..7302fb472 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; -import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; + import { AgentProviderRuntimeSettingsMapSchema } from "./agent/provider-launch-config.js"; const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]); @@ -82,7 +82,7 @@ const FeatureVoiceModeSchema = z enabled: z.boolean().optional(), llm: z .object({ - provider: z.enum(AGENT_PROVIDER_IDS as [string, ...string[]]).optional(), + provider: z.string().optional(), model: z.string().min(1).optional(), }) .strict() diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index cc2096383..eebfbecd1 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -190,6 +190,28 @@ const execFileAsync = promisify(execFile); const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); const pendingAgentInitializations = new Map>(); const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; + +// TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings. +// Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject +// the entire session message if they encounter an unknown provider. +const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]); +const MIN_VERSION_ALL_PROVIDERS = "0.1.45"; + +function clientSupportsAllProviders(appVersion: string | null): boolean { + if (!appVersion) return false; + // Strip RC/prerelease suffix: "0.1.45-rc.4" → "0.1.45" + const base = appVersion.replace(/-.*$/, ""); + const parts = base.split(".").map(Number); + const minParts = MIN_VERSION_ALL_PROVIDERS.split(".").map(Number); + for (let i = 0; i < minParts.length; i++) { + const a = parts[i] ?? 0; + const b = minParts[i] ?? 0; + if (a > b) return true; + if (a < b) return false; + } + return true; +} + const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024; @@ -355,6 +377,7 @@ type VoiceTranscriptionResultPayload = { export type SessionOptions = { clientId: string; + appVersion: string | null; onMessage: (msg: SessionOutboundMessage) => void; onBinaryMessage?: (frame: Uint8Array) => void; getBinaryBufferedAmount?: () => number; @@ -506,6 +529,7 @@ function toAgentPersistenceHandle( */ export class Session { private readonly clientId: string; + private readonly appVersion: string | null; private readonly sessionId: string; private readonly onMessage: (msg: SessionOutboundMessage) => void; private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null; @@ -601,6 +625,7 @@ export class Session { constructor(options: SessionOptions) { const { clientId, + appVersion, onMessage, onBinaryMessage, getBinaryBufferedAmount, @@ -627,6 +652,7 @@ export class Session { agentProviderRuntimeSettings, } = options; this.clientId = clientId; + this.appVersion = appVersion; this.sessionId = uuidv4(); this.onMessage = onMessage; this.onBinaryMessage = onBinaryMessage ?? null; @@ -1130,6 +1156,12 @@ export class Session { } } + // TODO: Remove once all app store clients are on >=0.1.45. + private isProviderVisibleToClient(provider: string): boolean { + if (clientSupportsAllProviders(this.appVersion)) return true; + return LEGACY_PROVIDER_IDS.has(provider); + } + private matchesAgentFilter(options: { agent: AgentSnapshotPayload; project: ProjectPlacementPayload; @@ -1198,6 +1230,11 @@ export class Session { subscription: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload, ): void { + // TODO: Remove once all app store clients are on >=0.1.45. + if (payload.kind === "upsert" && !this.isProviderVisibleToClient(payload.agent.provider)) { + return; + } + if (subscription.isBootstrapping) { subscription.pendingUpdatesByAgentId.set(this.getAgentUpdateTargetId(payload), payload); return; @@ -3179,7 +3216,11 @@ export class Session { ): Promise { const fetchedAt = new Date().toISOString(); try { - const providers = await this.agentManager.listProviderAvailability(); + let providers = await this.agentManager.listProviderAvailability(); + + // TODO: Remove once all app store clients are on >=0.1.45. + providers = providers.filter((p) => this.isProviderVisibleToClient(p.provider)); + this.emit({ type: "list_available_providers_response", payload: { @@ -5691,6 +5732,12 @@ export class Session { } const payload = await this.listFetchAgentsEntries(request); + + // TODO: Remove once all app store clients are on >=0.1.45. + payload.entries = payload.entries.filter((entry) => + this.isProviderVisibleToClient(entry.agent.provider), + ); + const snapshotUpdatedAtByAgentId = new Map(); for (const entry of payload.entries) { const parsedUpdatedAt = Date.parse(entry.agent.updatedAt); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ab9d0f787..0b9fdcf66 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -176,6 +176,7 @@ type WebSocketLike = { type SessionConnection = { session: Session; clientId: string; + appVersion: string | null; connectionLogger: pino.Logger; sockets: Set; externalDisconnectCleanupTimeout: ReturnType | null; @@ -595,13 +596,15 @@ export class VoiceAssistantWebSocketServer { private createSessionConnection(params: { ws: WebSocketLike; clientId: string; + appVersion: string | null; connectionLogger: pino.Logger; }): SessionConnection { - const { ws, clientId, connectionLogger } = params; + const { ws, clientId, appVersion, connectionLogger } = params; let connection: SessionConnection | null = null; const session = new Session({ clientId, + appVersion, onMessage: (msg) => { if (!connection) { return; @@ -677,6 +680,7 @@ export class VoiceAssistantWebSocketServer { connection = { session, clientId, + appVersion, connectionLogger, sockets: new Set([ws]), externalDisconnectCleanupTimeout: null, @@ -760,6 +764,7 @@ export class VoiceAssistantWebSocketServer { const connection = this.createSessionConnection({ ws, clientId, + appVersion: message.appVersion ?? null, connectionLogger, }); this.sessions.set(ws, connection); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 0773bfbc7..5dcd0b00a 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -2653,6 +2653,7 @@ export const WSHelloMessageSchema = z.object({ clientId: z.string().min(1), clientType: z.enum(["mobile", "browser", "cli", "mcp"]), protocolVersion: z.number().int(), + appVersion: z.string().optional(), capabilities: z .object({ voice: z.boolean().optional(),