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 18:39:18 +07:00
parent c5a69a1ad9
commit a17f7d2d20
7 changed files with 63 additions and 9 deletions

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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()

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 pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
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<void> {
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<string, number>();
for (const entry of payload.entries) {
const parsedUpdatedAt = Date.parse(entry.agent.updatedAt);

View File

@@ -176,6 +176,7 @@ type WebSocketLike = {
type SessionConnection = {
session: Session;
clientId: string;
appVersion: string | null;
connectionLogger: pino.Logger;
sockets: Set<WebSocketLike>;
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | 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);

View File

@@ -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(),