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.
This commit is contained in:
Mohamed Boudra
2026-04-04 19:21:12 +07:00
parent c5a69a1ad9
commit a17f7d2d20
7 changed files with 63 additions and 9 deletions
+2
View File
@@ -14,6 +14,7 @@ import {
type HostProfile, type HostProfile,
} from "@/types/host-connection"; } from "@/types/host-connection";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { resolveAppVersion } from "@/utils/app-version";
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer"; import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
import { import {
shouldUseDesktopDaemon, shouldUseDesktopDaemon,
@@ -422,6 +423,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
suppressSendErrors: true, suppressSendErrors: true,
clientId, clientId,
clientType: "mobile" as const, clientType: "mobile" as const,
appVersion: resolveAppVersion() ?? undefined,
runtimeGeneration, runtimeGeneration,
}; };
if (connection.type === "directSocket" || connection.type === "directPipe") { if (connection.type === "directSocket" || connection.type === "directPipe") {
@@ -155,6 +155,7 @@ export type DaemonClientConfig = {
url: string; url: string;
clientId: string; clientId: string;
clientType?: "mobile" | "browser" | "cli" | "mcp"; clientType?: "mobile" | "browser" | "cli" | "mcp";
appVersion?: string;
runtimeGeneration?: number | null; runtimeGeneration?: number | null;
authHeader?: string; authHeader?: string;
suppressSendErrors?: boolean; suppressSendErrors?: boolean;
@@ -3285,6 +3286,7 @@ export class DaemonClient {
clientId: this.config.clientId, clientId: this.config.clientId,
clientType: this.config.clientType ?? "cli", clientType: this.config.clientType ?? "cli",
protocolVersion: 1, protocolVersion: 1,
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
}), }),
); );
} catch (error) { } catch (error) {
@@ -176,12 +176,9 @@ export function getAgentProviderDefinition(provider: string): AgentProviderDefin
return definition; return definition;
} }
export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id) as [ export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id);
string,
...string[],
];
export const AgentProviderSchema = z.enum(AGENT_PROVIDER_IDS); export const AgentProviderSchema = z.string();
export function isValidAgentProvider(value: string): boolean { export function isValidAgentProvider(value: string): boolean {
return AGENT_PROVIDER_IDS.includes(value); return AGENT_PROVIDER_IDS.includes(value);
@@ -1,7 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { z } from "zod"; import { z } from "zod";
import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
import { AgentProviderRuntimeSettingsMapSchema } from "./agent/provider-launch-config.js"; import { AgentProviderRuntimeSettingsMapSchema } from "./agent/provider-launch-config.js";
const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]); const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
@@ -82,7 +82,7 @@ const FeatureVoiceModeSchema = z
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
llm: z llm: z
.object({ .object({
provider: z.enum(AGENT_PROVIDER_IDS as [string, ...string[]]).optional(), provider: z.string().optional(),
model: z.string().min(1).optional(), model: z.string().min(1).optional(),
}) })
.strict() .strict()
+48 -1
View File
@@ -190,6 +190,28 @@ const execFileAsync = promisify(execFile);
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>(); const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; 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_DEBOUNCE_MS = 500;
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024; const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024;
@@ -355,6 +377,7 @@ type VoiceTranscriptionResultPayload = {
export type SessionOptions = { export type SessionOptions = {
clientId: string; clientId: string;
appVersion: string | null;
onMessage: (msg: SessionOutboundMessage) => void; onMessage: (msg: SessionOutboundMessage) => void;
onBinaryMessage?: (frame: Uint8Array) => void; onBinaryMessage?: (frame: Uint8Array) => void;
getBinaryBufferedAmount?: () => number; getBinaryBufferedAmount?: () => number;
@@ -506,6 +529,7 @@ function toAgentPersistenceHandle(
*/ */
export class Session { export class Session {
private readonly clientId: string; private readonly clientId: string;
private readonly appVersion: string | null;
private readonly sessionId: string; private readonly sessionId: string;
private readonly onMessage: (msg: SessionOutboundMessage) => void; private readonly onMessage: (msg: SessionOutboundMessage) => void;
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null; private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
@@ -601,6 +625,7 @@ export class Session {
constructor(options: SessionOptions) { constructor(options: SessionOptions) {
const { const {
clientId, clientId,
appVersion,
onMessage, onMessage,
onBinaryMessage, onBinaryMessage,
getBinaryBufferedAmount, getBinaryBufferedAmount,
@@ -627,6 +652,7 @@ export class Session {
agentProviderRuntimeSettings, agentProviderRuntimeSettings,
} = options; } = options;
this.clientId = clientId; this.clientId = clientId;
this.appVersion = appVersion;
this.sessionId = uuidv4(); this.sessionId = uuidv4();
this.onMessage = onMessage; this.onMessage = onMessage;
this.onBinaryMessage = onBinaryMessage ?? null; 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: { private matchesAgentFilter(options: {
agent: AgentSnapshotPayload; agent: AgentSnapshotPayload;
project: ProjectPlacementPayload; project: ProjectPlacementPayload;
@@ -1198,6 +1230,11 @@ export class Session {
subscription: AgentUpdatesSubscriptionState, subscription: AgentUpdatesSubscriptionState,
payload: AgentUpdatePayload, payload: AgentUpdatePayload,
): void { ): 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) { if (subscription.isBootstrapping) {
subscription.pendingUpdatesByAgentId.set(this.getAgentUpdateTargetId(payload), payload); subscription.pendingUpdatesByAgentId.set(this.getAgentUpdateTargetId(payload), payload);
return; return;
@@ -3179,7 +3216,11 @@ export class Session {
): Promise<void> { ): Promise<void> {
const fetchedAt = new Date().toISOString(); const fetchedAt = new Date().toISOString();
try { 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({ this.emit({
type: "list_available_providers_response", type: "list_available_providers_response",
payload: { payload: {
@@ -5691,6 +5732,12 @@ export class Session {
} }
const payload = await this.listFetchAgentsEntries(request); 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<string, number>(); const snapshotUpdatedAtByAgentId = new Map<string, number>();
for (const entry of payload.entries) { for (const entry of payload.entries) {
const parsedUpdatedAt = Date.parse(entry.agent.updatedAt); const parsedUpdatedAt = Date.parse(entry.agent.updatedAt);
@@ -176,6 +176,7 @@ type WebSocketLike = {
type SessionConnection = { type SessionConnection = {
session: Session; session: Session;
clientId: string; clientId: string;
appVersion: string | null;
connectionLogger: pino.Logger; connectionLogger: pino.Logger;
sockets: Set<WebSocketLike>; sockets: Set<WebSocketLike>;
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null; externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
@@ -595,13 +596,15 @@ export class VoiceAssistantWebSocketServer {
private createSessionConnection(params: { private createSessionConnection(params: {
ws: WebSocketLike; ws: WebSocketLike;
clientId: string; clientId: string;
appVersion: string | null;
connectionLogger: pino.Logger; connectionLogger: pino.Logger;
}): SessionConnection { }): SessionConnection {
const { ws, clientId, connectionLogger } = params; const { ws, clientId, appVersion, connectionLogger } = params;
let connection: SessionConnection | null = null; let connection: SessionConnection | null = null;
const session = new Session({ const session = new Session({
clientId, clientId,
appVersion,
onMessage: (msg) => { onMessage: (msg) => {
if (!connection) { if (!connection) {
return; return;
@@ -677,6 +680,7 @@ export class VoiceAssistantWebSocketServer {
connection = { connection = {
session, session,
clientId, clientId,
appVersion,
connectionLogger, connectionLogger,
sockets: new Set([ws]), sockets: new Set([ws]),
externalDisconnectCleanupTimeout: null, externalDisconnectCleanupTimeout: null,
@@ -760,6 +764,7 @@ export class VoiceAssistantWebSocketServer {
const connection = this.createSessionConnection({ const connection = this.createSessionConnection({
ws, ws,
clientId, clientId,
appVersion: message.appVersion ?? null,
connectionLogger, connectionLogger,
}); });
this.sessions.set(ws, connection); this.sessions.set(ws, connection);
+1
View File
@@ -2653,6 +2653,7 @@ export const WSHelloMessageSchema = z.object({
clientId: z.string().min(1), clientId: z.string().min(1),
clientType: z.enum(["mobile", "browser", "cli", "mcp"]), clientType: z.enum(["mobile", "browser", "cli", "mcp"]),
protocolVersion: z.number().int(), protocolVersion: z.number().int(),
appVersion: z.string().optional(),
capabilities: z capabilities: z
.object({ .object({
voice: z.boolean().optional(), voice: z.boolean().optional(),