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
This commit is contained in:
weishu
2026-01-23 16:04:37 +08:00
parent 6f8f44a314
commit 6567f22978
6 changed files with 82 additions and 50 deletions
+6 -5
View File
@@ -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<typeof MachineMetadataSchema>
@@ -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<typeof RunnerStateSchema>
@@ -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<typeof MessageMetaSchema>
@@ -132,7 +133,7 @@ export const UserMessageSchema = z.object({
}),
localKey: z.string().optional(),
meta: MessageMetaSchema.optional()
}).passthrough()
})
export type UserMessage = z.infer<typeof UserMessageSchema>
@@ -143,7 +144,7 @@ export const AgentMessageSchema = z.object({
data: z.unknown()
}),
meta: MessageMetaSchema.optional()
}).passthrough()
})
export type AgentMessage = z.infer<typeof AgentMessageSchema>
+53 -30
View File
@@ -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<typeof RawJSONLinesSchema>
export type RawJSONLines = z.infer<typeof RawJSONLinesSchema>;
+1 -1
View File
@@ -182,7 +182,7 @@ export class CodexMcpClient {
params: z.object({
msg: z.any()
})
}).passthrough();
});
const setNotificationHandler =
this.client.setNotificationHandler.bind(this.client) as (
+1 -1
View File
@@ -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<typeof CodexSessionEventSchema>;
+13 -5
View File
@@ -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<string, unknown>
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
+8 -8
View File
@@ -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<typeof WorktreeMetadataSchema>
@@ -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<typeof MetadataSchema>
@@ -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<typeof AgentStateRequestSchema>
@@ -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<typeof AgentStateCompletedRequestSchema>
@@ -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<typeof AgentStateSchema>
@@ -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<typeof TodoItemSchema>
@@ -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<typeof DecryptedMessageSchema>
@@ -129,7 +129,7 @@ export const SessionSchema = z.object({
todos: TodosSchema.optional(),
permissionMode: PermissionModeSchema.optional(),
modelMode: ModelModeSchema.optional()
}).passthrough()
})
export type Session = z.infer<typeof SessionSchema>