From 6567f2297842b252664aec1864266ee06b6fc7e7 Mon Sep 17 00:00:00 2001 From: weishu Date: Fri, 23 Jan 2026 15:05:50 +0800 Subject: [PATCH] refactor: remove passthrough() from zod schemas and make validation explicit - Replace .passthrough() with explicit field definitions across all schemas - Add missing optional fields (homeDir, happyHomeDir, happyLibDir, displayName) - Refactor RawJSONLinesSchema to use structured base schema for clarity - Improve schema validation strictness and type safety - Update Machine interface to reflect explicit fields instead of index signature --- cli/src/api/types.ts | 11 +-- cli/src/claude/types.ts | 83 ++++++++++++++-------- cli/src/codex/codexMcpClient.ts | 2 +- cli/src/codex/utils/codexEventConverter.ts | 2 +- server/src/sync/machineCache.ts | 18 +++-- shared/src/schemas.ts | 16 ++--- 6 files changed, 82 insertions(+), 50 deletions(-) diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index 74f00c92..3ca1b222 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -29,10 +29,11 @@ export const MachineMetadataSchema = z.object({ host: z.string(), platform: z.string(), happyCliVersion: z.string(), + displayName: z.string().optional(), homeDir: z.string(), happyHomeDir: z.string(), happyLibDir: z.string() -}).passthrough() +}) export type MachineMetadata = z.infer @@ -43,7 +44,7 @@ export const RunnerStateSchema = z.object({ startedAt: z.number().optional(), shutdownRequestedAt: z.number().optional(), shutdownSource: z.union([z.enum(['mobile-app', 'cli', 'os-signal', 'unknown']), z.string()]).optional() -}).passthrough() +}) export type RunnerState = z.infer @@ -119,7 +120,7 @@ export const MessageMetaSchema = z.object({ appendSystemPrompt: z.string().nullable().optional(), allowedTools: z.array(z.string()).nullable().optional(), disallowedTools: z.array(z.string()).nullable().optional() -}).passthrough() +}) export type MessageMeta = z.infer @@ -132,7 +133,7 @@ export const UserMessageSchema = z.object({ }), localKey: z.string().optional(), meta: MessageMetaSchema.optional() -}).passthrough() +}) export type UserMessage = z.infer @@ -143,7 +144,7 @@ export const AgentMessageSchema = z.object({ data: z.unknown() }), meta: MessageMetaSchema.optional() -}).passthrough() +}) export type AgentMessage = z.infer diff --git a/cli/src/claude/types.ts b/cli/src/claude/types.ts index a875bbc9..5a61d948 100644 --- a/cli/src/claude/types.ts +++ b/cli/src/claude/types.ts @@ -1,6 +1,6 @@ /** - * Simplified schema that only validates fields actually used in the codebase - * while preserving all other fields through passthrough() + * Schema validates fields used in the codebase and keeps explicit + * log fields required by the CLI and UI. */ import { z } from "zod"; @@ -12,46 +12,69 @@ export const UsageSchema = z.object({ cache_read_input_tokens: z.number().int().nonnegative().optional(), output_tokens: z.number().int().nonnegative(), service_tier: z.string().optional(), -}).passthrough(); +}); -// Main schema with minimal validation for only the fields we use -// NOTE: Schema is intentionally lenient to handle various Claude Code message formats -// including synthetic error messages, API errors, and different SDK versions +const RawMessageSchema = z.object({ + role: z.string().optional(), + content: z.unknown(), + usage: UsageSchema.optional(), +}); + +const RawJSONLinesBaseSchema = z.object({ + uuid: z.string().optional(), + parentUuid: z.string().nullable().optional(), + isSidechain: z.boolean().optional(), + isMeta: z.boolean().optional(), + isCompactSummary: z.boolean().optional(), + userType: z.string().optional(), + cwd: z.string().optional(), + sessionId: z.string().optional(), + version: z.string().optional(), + gitBranch: z.string().optional(), + timestamp: z.string().optional(), +}); + +// Main schema with validation for the fields used in the app +// NOTE: Schema remains lenient on message content to handle SDK variations export const RawJSONLinesSchema = z.discriminatedUnion("type", [ // User message - validates uuid and message.content - z.object({ + RawJSONLinesBaseSchema.extend({ type: z.literal("user"), - isSidechain: z.boolean().optional(), - isMeta: z.boolean().optional(), - uuid: z.string(), // Used in getMessageKey() - message: z.object({ - content: z.union([z.string(), z.any()]) // Used in sessionScanner.ts - }).passthrough() - }).passthrough(), + uuid: z.string(), + message: RawMessageSchema, + mode: z.string().optional(), + toolUseResult: z.unknown().optional(), + }), // Assistant message - only validates uuid and type - // message object is optional to handle synthetic error messages (isApiErrorMessage: true) - // which may have different structure than normal assistant messages - z.object({ + // message object is optional to handle synthetic error messages + RawJSONLinesBaseSchema.extend({ uuid: z.string(), type: z.literal("assistant"), - message: z.object({ - usage: UsageSchema.optional(), // Used in apiSession.ts - }).passthrough().optional() - }).passthrough(), + message: RawMessageSchema.optional(), + requestId: z.string().optional(), + }), // Summary message - validates summary and leafUuid - z.object({ + RawJSONLinesBaseSchema.extend({ type: z.literal("summary"), - summary: z.string(), // Used in apiSession.ts - leafUuid: z.string() // Used in getMessageKey() - }).passthrough(), + summary: z.string(), + leafUuid: z.string(), + }), - // System message - validates uuid - z.object({ + // System message - validates uuid and subtype data used by the UI + RawJSONLinesBaseSchema.extend({ type: z.literal("system"), - uuid: z.string() // Used in getMessageKey() - }).passthrough() + uuid: z.string(), + subtype: z.string().optional(), + model: z.string().optional(), + tools: z.array(z.string()).optional(), + session_id: z.string().optional(), + retryAttempt: z.number().optional(), + maxRetries: z.number().optional(), + error: z.unknown().optional(), + durationMs: z.number().optional(), + }), ]); -export type RawJSONLines = z.infer +export type RawJSONLines = z.infer; diff --git a/cli/src/codex/codexMcpClient.ts b/cli/src/codex/codexMcpClient.ts index 7c82ab74..ffad694c 100644 --- a/cli/src/codex/codexMcpClient.ts +++ b/cli/src/codex/codexMcpClient.ts @@ -182,7 +182,7 @@ export class CodexMcpClient { params: z.object({ msg: z.any() }) - }).passthrough(); + }); const setNotificationHandler = this.client.setNotificationHandler.bind(this.client) as ( diff --git a/cli/src/codex/utils/codexEventConverter.ts b/cli/src/codex/utils/codexEventConverter.ts index 76dbf478..24ecfd24 100644 --- a/cli/src/codex/utils/codexEventConverter.ts +++ b/cli/src/codex/utils/codexEventConverter.ts @@ -6,7 +6,7 @@ const CodexSessionEventSchema = z.object({ timestamp: z.string().optional(), type: z.string(), payload: z.unknown().optional() -}).passthrough(); +}); export type CodexSessionEvent = z.infer; diff --git a/server/src/sync/machineCache.ts b/server/src/sync/machineCache.ts index 87643820..69e00bcd 100644 --- a/server/src/sync/machineCache.ts +++ b/server/src/sync/machineCache.ts @@ -7,8 +7,11 @@ const machineMetadataSchema = z.object({ host: z.string().optional(), platform: z.string().optional(), happyCliVersion: z.string().optional(), - displayName: z.string().optional() -}).passthrough() + displayName: z.string().optional(), + homeDir: z.string().optional(), + happyHomeDir: z.string().optional(), + happyLibDir: z.string().optional() +}) export interface Machine { id: string @@ -23,7 +26,9 @@ export interface Machine { platform: string happyCliVersion: string displayName?: string - [key: string]: unknown + homeDir?: string + happyHomeDir?: string + happyLibDir?: string } | null metadataVersion: number runnerState: unknown | null @@ -88,12 +93,15 @@ export class MachineCache { const metadata = (() => { const parsed = machineMetadataSchema.safeParse(stored.metadata) if (!parsed.success) return null - const data = parsed.data as Record + const data = parsed.data const host = typeof data.host === 'string' ? data.host : 'unknown' const platform = typeof data.platform === 'string' ? data.platform : 'unknown' const happyCliVersion = typeof data.happyCliVersion === 'string' ? data.happyCliVersion : 'unknown' const displayName = typeof data.displayName === 'string' ? data.displayName : undefined - return { host, platform, happyCliVersion, displayName, ...data } + const homeDir = typeof data.homeDir === 'string' ? data.homeDir : undefined + const happyHomeDir = typeof data.happyHomeDir === 'string' ? data.happyHomeDir : undefined + const happyLibDir = typeof data.happyLibDir === 'string' ? data.happyLibDir : undefined + return { host, platform, happyCliVersion, displayName, homeDir, happyHomeDir, happyLibDir } })() const storedActiveAt = stored.activeAt ?? stored.createdAt diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index df273f11..da081cfd 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -15,7 +15,7 @@ export const WorktreeMetadataSchema = z.object({ name: z.string(), worktreePath: z.string().optional(), createdAt: z.number().optional() -}).passthrough() +}) export type WorktreeMetadata = z.infer @@ -45,7 +45,7 @@ export const MetadataSchema = z.object({ archiveReason: z.string().optional(), flavor: z.string().nullish(), worktree: WorktreeMetadataSchema.optional() -}).passthrough() +}) export type Metadata = z.infer @@ -53,7 +53,7 @@ export const AgentStateRequestSchema = z.object({ tool: z.string(), arguments: z.unknown(), createdAt: z.number().nullish() -}).passthrough() +}) export type AgentStateRequest = z.infer @@ -68,7 +68,7 @@ export const AgentStateCompletedRequestSchema = z.object({ decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(), allowTools: z.array(z.string()).optional(), answers: z.record(z.string(), z.array(z.string())).optional() -}).passthrough() +}) export type AgentStateCompletedRequest = z.infer @@ -76,7 +76,7 @@ export const AgentStateSchema = z.object({ controlledByUser: z.boolean().nullish(), requests: z.record(z.string(), AgentStateRequestSchema).nullish(), completedRequests: z.record(z.string(), AgentStateCompletedRequestSchema).nullish() -}).passthrough() +}) export type AgentState = z.infer @@ -85,7 +85,7 @@ export const TodoItemSchema = z.object({ status: z.enum(['pending', 'in_progress', 'completed']), priority: z.enum(['high', 'medium', 'low']), id: z.string() -}).passthrough() +}) export type TodoItem = z.infer @@ -108,7 +108,7 @@ export const DecryptedMessageSchema = z.object({ localId: z.string().nullable(), content: z.unknown(), createdAt: z.number() -}).passthrough() +}) export type DecryptedMessage = z.infer @@ -129,7 +129,7 @@ export const SessionSchema = z.object({ todos: TodosSchema.optional(), permissionMode: PermissionModeSchema.optional(), modelMode: ModelModeSchema.optional() -}).passthrough() +}) export type Session = z.infer