/** * @fileoverview Zod validation schemas for API routes * * This module contains Zod schemas for validating API request bodies. * Schemas are used in src/web/server.ts route handlers. * * @module web/schemas */ import { z } from 'zod'; import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js'; import { isValidWebviewUrl } from './webview-proxy.js'; import { MAX_TERMINAL_BUFFER_BYTES, MAX_TERMINAL_SCROLLBACK_LINES, MIN_TERMINAL_BUFFER_BYTES, MIN_TERMINAL_SCROLLBACK_LINES, } from '../config/terminal-history.js'; import { MAX_EDITABLE_BYTES } from '../config/file-editing.js'; import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js'; // ========== Path Validation ========== /** Validate a path string: no shell metacharacters, no traversal, must be absolute */ export function isValidWorkingDir(p: string): boolean { if (!p || !p.startsWith('/')) return false; if ( p.includes(';') || p.includes('&') || p.includes('|') || p.includes('$') || p.includes('`') || p.includes('(') || p.includes(')') || p.includes('{') || p.includes('}') || p.includes('<') || p.includes('>') || p.includes("'") || p.includes('"') || p.includes('\n') || p.includes('\r') ) { return false; } if (p.includes('..')) return false; return SAFE_PATH_PATTERN.test(p); } /** Zod refinement for safe absolute path */ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, { message: 'Invalid path: must be absolute, no shell metacharacters or traversal', }); /** * Filesystem picker paths are never interpolated into a shell command, so legal * filename characters such as spaces, quotes, and parentheses are accepted. * Containment and symlink resolution are enforced by the route after parsing. */ const filesystemPickerPathSchema = z .string() .max(4096) .refine((p) => p.startsWith('/') && !p.includes('\0') && !p.includes('\n') && !p.includes('\r'), { message: 'Path must be an absolute filesystem path', }) .refine((p) => !p.split('/').includes('..'), { message: 'Path traversal is not allowed' }); /** Query validation for the lazy, allowlisted filesystem path picker. */ export const FilesystemBrowseQuerySchema = z.object({ path: filesystemPickerPathSchema.optional(), sessionId: z .string() .max(100) .regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id') .optional(), }); /** Query validation for a single allowlisted path-picker file preview. */ export const FilesystemPreviewQuerySchema = z.object({ path: filesystemPickerPathSchema, sessionId: z .string() .max(100) .regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id') .optional(), }); /** * Body validation for `PUT /api/sessions/:id/file-content` (File Viewer edit * mode). `content.max()` counts UTF-16 code units, which for UTF-8 output is * always <= the byte length, so it is a coarse pre-filter that never rejects * valid content; the handler enforces the exact MAX_EDITABLE_BYTES byte cap. * Workspace containment and symlink resolution are enforced by the route via * validateSessionFilePath after parsing. */ export const FileWriteSchema = z .object({ path: z .string() .min(1) .max(4096) .refine((p) => !p.includes('\0') && !p.includes('\n') && !p.includes('\r'), { message: 'Invalid path', }), content: z.string().max(MAX_EDITABLE_BYTES), baseHash: z.string().regex(/^[a-f0-9]{64}$/, 'baseHash must be a sha256 hex digest'), eol: z.enum(['lf', 'crlf']).optional(), force: z.boolean().optional(), }) .strict(); // ========== Env Var Allowlist ========== /** Allowlisted env var key prefixes */ const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'ANTIGRAVITY_']; /** Env var keys that are always blocked (security-sensitive) */ const BLOCKED_ENV_KEYS = new Set([ 'PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_OPTIONS', 'CODEMAN_MUX_NAME', 'CODEMAN_TMUX', 'OPENCODE_SERVER_PASSWORD', // Security-sensitive: server auth password ]); /** Validate that an env var key is allowed */ function isAllowedEnvKey(key: string): boolean { if (BLOCKED_ENV_KEYS.has(key)) return false; return ALLOWED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)); } /** Zod schema for env overrides with allowlist enforcement */ const safeEnvOverridesSchema = z .record(z.string(), z.string()) .optional() .refine( (val) => { if (!val) return true; return Object.keys(val).every(isAllowedEnvKey); }, { message: 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, and ANTIGRAVITY_* keys are allowed.', } ); // ========== Effort Level ========== /** * Claude CLI effort level for new sessions. Injected as a `--settings` soft default * (NOT the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session and * block in-session `/effort` switching). `ultracode` enables dynamic workflow orchestration. */ const effortLevelSchema = z.enum(['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']).optional(); // ========== Session Routes ========== /** * Schema for POST /api/sessions * Creates a new session with optional working directory, mode, and name. */ /** Schema for OpenCode-specific configuration */ const OpenCodeConfigSchema = z .object({ model: z .string() .max(100) .regex(/^[a-zA-Z0-9._\-/]+$/) .optional(), autoAllowTools: z.boolean().optional(), continueSession: z .string() .max(100) .regex(/^[a-zA-Z0-9_-]+$/) .optional(), forkSession: z.boolean().optional(), configContent: z .string() .max(10000) .refine( (val) => { try { JSON.parse(val); return true; } catch { return false; } }, { message: 'configContent must be valid JSON' } ) .optional(), }) .optional(); /** Schema for Codex (OpenAI CLI)-specific configuration */ const CodexConfigSchema = z .object({ model: z .string() .max(100) .regex(/^[a-zA-Z0-9._\-/]+$/) .optional(), resumeSessionId: z .string() .max(100) .regex(/^[a-zA-Z0-9_-]+$/) .optional(), dangerouslyBypassApprovals: z.boolean().optional(), animations: z.boolean().optional(), renderMode: z .enum(['scrollback', 'hybrid']) .optional() .transform(() => 'hybrid' as const), }) .optional(); /** Schema for Gemini CLI-specific configuration */ const GeminiConfigSchema = z .object({ model: z .string() .max(100) .regex(/^[a-zA-Z0-9._\-/]+$/) .optional(), approvalMode: z.enum(['default', 'auto_edit', 'yolo', 'plan']).optional(), resumeSession: z .string() .max(100) .regex(/^[a-zA-Z0-9._-]+$/) .optional(), }) .optional(); /** Schema for Antigravity CLI (agy)-specific configuration */ const AntigravityConfigSchema = z .object({ model: z .string() .max(100) .regex(/^[a-zA-Z0-9._\-/]+$/) .optional(), dangerouslySkipPermissions: z.boolean().optional(), resumeConversationId: z .string() .max(100) .regex(/^[a-zA-Z0-9._-]+$/) .optional(), }) .optional(); export const CreateSessionSchema = z.object({ workingDir: safePathSchema.optional(), mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']).optional(), name: z.string().max(100).optional(), envOverrides: safeEnvOverridesSchema, /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort: effortLevelSchema, /** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */ modelOverride: z.string().max(50).optional(), /** Inject the plan-usage statusLine exporter into the case (App Settings → Display → "Plan Usage Limits"). Claude-only. */ statusLineTelemetry: z.boolean().optional(), openCodeConfig: OpenCodeConfigSchema, codexConfig: CodexConfigSchema, geminiConfig: GeminiConfigSchema, antigravityConfig: AntigravityConfigSchema, /** Resume a previous Claude conversation by its session ID (used for reboot recovery) */ resumeSessionId: z .string() .max(100) .regex(/^[a-f0-9-]+$/, 'resumeSessionId must be a valid UUID') .optional(), /** * COD-105 — attach to an EXISTING remote tmux session discovered via * `GET /api/remote-hosts/:hostId/sessions` (one this Codeman didn't create). * The resulting session is NON-owned (closing it detaches, never kills the * remote). `remoteSessionName` is a discovered `codeman-*` tmux session name. */ attachRemoteSession: z .object({ hostId: z.string().min(1).max(200), remoteSessionName: z .string() .min(1) .max(200) .regex(/^codeman-[a-zA-Z0-9._-]+$/, 'remoteSessionName must be a codeman-* tmux session name'), }) .optional(), }); /** * Schema for POST /api/sessions/:id/run * Runs a prompt in a session. */ export const RunPromptSchema = z.object({ prompt: z.string().min(1).max(100000), }); /** * Schema for POST /api/sessions/:id/resize * Resizes a session's terminal. */ export const ResizeSchema = z.object({ cols: z.number().int().min(1).max(500), rows: z.number().int().min(1).max(200), viewportType: z.enum(['mobile', 'tablet', 'desktop']).optional(), force: z.boolean().optional(), }); /** * Schema for POST /api/status-telemetry * Claude Code statusline payload forwarded by the Codeman-managed statusLine * exporter (see hooks-config.generateStatusLineCommand). Validates only the * subset Codeman displays; unknown keys (session_id, transcript_path, cwd, …) * are stripped by z.object. Auth-exempt like /api/hook-event. */ // NOTE: every modeled field is `.nullish()` (not `.optional()`) on purpose. // Claude's statusline blob is officially shipped but undocumented in exact // shape, and `z.optional()` REJECTS an explicit `null` (accepts only // `undefined`) — a single stray `null` (e.g. `cost:{total_cost_usd:null}`) // would 400 the ENTIRE POST before the deliberately-tolerant parser // (usage-telemetry.ts, which only acts on `typeof === 'number'/'string'`) ever // runs, silently killing the chip's data feed. `.nullish()` keeps the schema // gate as forgiving as the parser it guards. const RateLimitWindowSchema = z .object({ used_percentage: z.number().nullish(), resets_at: z.number().nullish(), }) .nullish(); export const StatusTelemetrySchema = z.object({ sessionId: z.string().min(1).max(100), data: z .object({ rate_limits: z .object({ five_hour: RateLimitWindowSchema, seven_day: RateLimitWindowSchema, }) .nullish(), context_window: z .object({ used_percentage: z.number().nullish(), total_input_tokens: z.number().nullish(), total_output_tokens: z.number().nullish(), }) .nullish(), cost: z.object({ total_cost_usd: z.number().nullish() }).nullish(), model: z.object({ display_name: z.string().max(100).nullish() }).nullish(), }) .nullish(), }); // ========== Case Routes ========== /** * Schema for POST /api/cases * Creates a new case folder. */ export const CreateCaseSchema = z.object({ name: z .string() .regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'), description: z.string().max(1000).optional(), }); const RemoteCommandOverridesSchema = z .object({ shell: z.string().min(1).max(300).optional(), claude: z.string().min(1).max(300).optional(), opencode: z.string().min(1).max(300).optional(), codex: z.string().min(1).max(300).optional(), gemini: z.string().min(1).max(300).optional(), antigravity: z.string().min(1).max(300).optional(), }) .strict() .optional(); // COD-107 — advanced SSH connection options. These ultimately exec as shell // (ProxyCommand etc.), but are OPERATOR-entered host config (never attacker- or // terminal-output-influenced), so we validate as defense-in-depth, not as the // security boundary. Reject newline/NUL/backtick/`$(` shell-injection vectors. const NO_SHELL_INJECTION = /^[^\n\r\0`]*$/; const noCommandSubstitution = (s: string) => !s.includes('$('); // `remotePath`/`identityFile` are shell-escaped, then the whole launch command is // embedded via `JSON.stringify(...)` inside `bash -c "..."` (tmux-manager). That // outer DOUBLE-quote layer re-exposes `$(...)`, backticks, and `$VAR` even though // the inner value is single-quoted — so a `$(cmd)` in the path would run LOCALLY at // launch. Reject `$` and backtick (and newline/CR/NUL) entirely at the boundary. const NO_SHELL_META = /^[^\n\r\0`$]*$/; export const RemoteHostSchema = z.object({ id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid remote host id'), label: z.string().min(1).max(100), host: z .string() .min(1) .max(255) .regex(/^[a-zA-Z0-9._:-]+$/, 'Invalid SSH host'), username: z .string() .min(1) .max(100) .regex(/^[a-zA-Z0-9._-]+$/, 'Invalid SSH username'), port: z.number().int().min(1).max(65535).optional(), // Identity (private-key) file PATH only — never key bytes. Reject shell // metacharacters ($, backtick) that survive into the `bash -c` launch layer. identityFile: z.string().min(1).max(4096).regex(NO_SHELL_META, 'Invalid identity file path').optional(), // SOCKS5 proxy as host:port (e.g. 127.0.0.1:1080). socksProxy: z .string() .regex(/^[\w.-]+:\d{1,5}$/, 'SOCKS proxy must be host:port') .optional(), // SSH jump host: a comma-separated chain of [user@]host[:port] hops. Structural // ALLOWLIST (not an open denylist) — only chars valid in user/host/port/IPv6, // so no shell metacharacter (;, |, &, space, $, quotes, …) can appear. The value // is also shellescaped at command-build time (buildSshConnectionArgs); this is the // belt to that suspenders. jumpHost: z .string() .min(1) .max(255) .regex( /^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.:[\]-]+(?::\d{1,5})?(?:,(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.:[\]-]+(?::\d{1,5})?)*$/, 'Jump host must be [user@]host[:port] (comma-separated for multiple hops)' ) .optional(), // Arbitrary extra -o KEY=VALUE options (escape hatch); each must be KEY=VALUE. extraSshOptions: z .array( z .string() .min(3) .max(1024) .regex(/^[A-Za-z][A-Za-z0-9]*=.+$/, 'Extra SSH option must be KEY=VALUE') .regex(NO_SHELL_INJECTION, 'Invalid characters in SSH option') .refine(noCommandSubstitution, 'Invalid characters in SSH option') ) .max(32) .optional(), commands: RemoteCommandOverridesSchema, }); export const RemoteCaseLinkSchema = z.object({ name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'), hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid remote host id'), remotePath: z .string() .min(1) .max(2000) .regex(/^\//, 'Remote path must be absolute') .regex(NO_SHELL_META, 'Invalid characters in remote path'), }); // ========== Docker cases ========== // // Docker mode is a location overlay on cases (see docs/docker-cases-plan.md), // the analog of the remote-SSH schemas above. `image`, `hostWorkspacePath`, // `containerWorkdir`, and `container` all reach the outer `bash -c "..."` launch // layer, so they carry NO_SHELL_META (rejects `$`/backtick that survive the // double-quote layer) exactly like remotePath/identityFile. `--privileged` and // any docker-socket mount are structurally unrepresentable (never accepted). const DockerResourceLimitsSchema = z .object({ memory: z .string() .regex(/^\d+[bkmg]?$/i, 'Memory must be like 512m / 4g') .optional(), cpus: z .string() .regex(/^\d+(\.\d+)?$/, 'CPUs must be a number') .optional(), pidsLimit: z.number().int().positive().max(100000).optional(), nofile: z .string() .regex(/^\d+:\d+$/, 'nofile must be soft:hard') .optional(), shmSize: z .string() .regex(/^\d+[bkmg]?$/i, 'shm-size must be like 256m') .optional(), }) .strict(); export const DockerHostSchema = z.object({ id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'), label: z.string().min(1).max(100), engine: z.enum(['docker', 'podman']).optional(), image: z .string() .min(1) .max(512) .regex(/^[a-zA-Z0-9][\w./:@-]*$/, 'Invalid image reference') .regex(NO_SHELL_META, 'Invalid characters in image reference'), daemonHost: z.string().max(512).regex(NO_SHELL_META, 'Invalid daemon host').optional(), context: z .string() .max(128) .regex(/^[a-zA-Z0-9._-]+$/, 'Invalid docker context') .optional(), network: z.enum(['bridge', 'none', 'custom']).optional(), networkName: z .string() .max(128) .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid network name') .optional(), resources: DockerResourceLimitsSchema.optional(), gpus: z .string() .max(128) .regex(/^(all|\d+|device=[a-zA-Z0-9,:._-]+)$/, 'GPUs must be all / a count / device=...') .optional(), mountCredentials: z.boolean().optional(), hooksEnabled: z.boolean().optional(), resumeOnStart: z.boolean().optional(), commands: RemoteCommandOverridesSchema, // same shell/claude/opencode/codex/gemini/antigravity shape extraCreateArgs: z .array( z .string() .min(1) .max(1024) .regex(NO_SHELL_INJECTION, 'Invalid characters in create arg') .refine(noCommandSubstitution, 'Invalid characters in create arg') ) .max(32) .optional(), extraExecArgs: z .array( z .string() .min(1) .max(1024) .regex(NO_SHELL_INJECTION, 'Invalid characters in exec arg') .refine(noCommandSubstitution, 'Invalid characters in exec arg') ) .max(32) .optional(), }); export const DockerCaseLinkSchema = z.object({ name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'), hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'), // No commas: the path is embedded in a `--mount type=bind,src=,dst=` // CSV spec, and docker's --mount parser splits fields on commas (shell escaping // cannot protect it). Spaces are fine. hostWorkspacePath: z .string() .min(1) .max(2000) .regex(/^\//, 'Workspace path must be absolute') .regex(/^[^,]*$/, 'Workspace path must not contain commas (docker --mount is comma-delimited)') .regex(NO_SHELL_META, 'Invalid characters in workspace path'), containerWorkdir: z .string() .min(1) .max(2000) .regex(/^\//, 'Container workdir must be absolute') .regex(/^[^,]*$/, 'Container workdir must not contain commas (docker --mount is comma-delimited)') .regex(NO_SHELL_META, 'Invalid characters in container workdir') .optional(), container: z .string() .min(2) .max(128) .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name') .optional(), }); export const DockerExportSchema = z.object({ mode: z.enum(['full', 'workspace']).optional(), }); export const DockerImportSchema = z.object({ // A bare filename resolved WITHIN the exports dir (never an arbitrary path). bundle: z .string() .min(1) .max(300) .regex(/^[a-zA-Z0-9._-]+\.tgz$/, 'Invalid bundle filename'), newCaseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'), destWorkspacePath: z .string() .min(1) .max(2000) .regex(/^\//, 'Destination path must be absolute') .regex(/^[^,]*$/, 'Destination path must not contain commas (docker --mount is comma-delimited)') .regex(NO_SHELL_META, 'Invalid characters in destination path'), }); // One-click "Run in Docker" case creation. name/description behave like a normal // case; the docker fields are OPTIONAL overrides of the predefined defaults (the // checkbox alone, with no overrides, uses the shared `default` host). export const DockerQuickCreateSchema = z.object({ name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'), description: z.string().max(1000).optional(), image: z .string() .min(1) .max(512) .regex(/^[a-zA-Z0-9][\w./:@-]*$/, 'Invalid image reference') .regex(NO_SHELL_META, 'Invalid characters in image reference') .optional(), network: z.enum(['bridge', 'none', 'custom']).optional(), networkName: z .string() .max(128) .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid network name') .optional(), memory: z .string() .regex(/^\d+[bkmg]?$/i, 'Memory must be like 512m / 4g') .optional(), cpus: z .string() .regex(/^\d+(\.\d+)?$/, 'CPUs must be a number') .optional(), gpus: z .string() .max(128) .regex(/^(all|\d+|device=[a-zA-Z0-9,:._-]+)$/, 'GPUs must be all / a count / device=...') .optional(), mountCredentials: z.boolean().optional(), }); // ========== Quick Start ========== /** * Schema for POST /api/quick-start * Creates case (if needed) and starts interactive session. */ export const QuickStartSchema = z.object({ caseName: z .string() .regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.') .optional(), /** Display name for the created session tab (e.g. w1-mycase). Cosmetic; the durable * mux/container names derive from the session id, not this. Defaults server-side. */ sessionName: z.string().max(128).optional(), /** Model override written to /.claude/settings.local.json (e.g. "opus[1m]"). * Empty string clears. Applied for local AND docker cases (the docker workspace is * a real host dir, so the settings file crosses the bind mount); rejected for * remote cases (the file would be written on the WRONG machine). */ modelOverride: z.string().max(50).optional(), mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']).optional(), openCodeConfig: OpenCodeConfigSchema, codexConfig: CodexConfigSchema, geminiConfig: GeminiConfigSchema, antigravityConfig: AntigravityConfigSchema, envOverrides: safeEnvOverridesSchema, /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort: effortLevelSchema, }); // ========== Hook Events ========== /** * Schema for POST /api/hook-event * Receives Claude Code hook events. */ export const HookEventSchema = z.object({ event: z.enum(['permission_prompt', 'elicitation_dialog', 'idle_prompt', 'stop', 'teammate_idle', 'task_completed']), sessionId: z.string().min(1), data: z.record(z.string(), z.unknown()).nullable().optional(), }); // ========== Configuration ========== /** * Schema for respawn configuration (partial updates allowed) * Used in PUT /api/config and respawn endpoints. */ export const RespawnConfigSchema = z.object({ idleTimeoutMs: z.number().int().min(1000).max(600000).optional(), updatePrompt: z.string().max(10000).optional(), interStepDelayMs: z.number().int().min(100).max(60000).optional(), enabled: z.boolean().optional(), sendClear: z.boolean().optional(), sendInit: z.boolean().optional(), kickstartPrompt: z.string().max(10000).optional(), completionConfirmMs: z.number().int().min(1000).max(60000).optional(), noOutputTimeoutMs: z.number().int().min(5000).max(600000).optional(), autoAcceptPrompts: z.boolean().optional(), autoAcceptDelayMs: z.number().int().min(1000).max(60000).optional(), aiIdleCheckEnabled: z.boolean().optional(), aiIdleCheckModel: z.string().max(100).optional(), aiIdleCheckMaxContext: z.number().int().min(1000).max(500000).optional(), aiIdleCheckTimeoutMs: z.number().int().min(10000).max(300000).optional(), aiIdleCheckCooldownMs: z.number().int().min(1000).max(300000).optional(), aiPlanCheckEnabled: z.boolean().optional(), aiPlanCheckModel: z.string().max(100).optional(), aiPlanCheckMaxContext: z.number().int().min(1000).max(500000).optional(), aiPlanCheckTimeoutMs: z.number().int().min(10000).max(300000).optional(), aiPlanCheckCooldownMs: z.number().int().min(1000).max(300000).optional(), adaptiveTimingEnabled: z.boolean().optional(), adaptiveMinConfirmMs: z.number().int().min(1000).max(60000).optional(), adaptiveMaxConfirmMs: z.number().int().min(1000).max(600000).optional(), skipClearWhenLowContext: z.boolean().optional(), skipClearThresholdPercent: z.number().int().min(0).max(100).optional(), }); /** * Schema for PUT /api/config * Updates application configuration with whitelist of allowed fields. */ export const ConfigUpdateSchema = z .object({ pollIntervalMs: z.number().int().min(100).max(60000).optional(), defaultTimeoutMs: z.number().int().min(1000).max(3600000).optional(), maxConcurrentSessions: z.number().int().min(1).max(50).optional(), respawn: RespawnConfigSchema.optional(), }) .strict(); /** * Schema for PUT /api/settings * Explicit allowlist of known settings fields — prevents arbitrary key persistence. */ const NotificationEventSchema = z .object({ enabled: z.boolean().optional(), browser: z.boolean().optional(), audio: z.boolean().optional(), push: z.boolean().optional(), }) .optional(); export const SettingsUpdateSchema = z .object({ // User-facing product branding. This changes browser/UI copy only; package, // CLI, API, storage, and protocol identifiers remain Codeman. displayName: z .string() .trim() .min(1) .max(40) .refine( (value) => Array.from(value).every((character) => { const codePoint = character.codePointAt(0); return codePoint !== undefined && codePoint > 31 && codePoint !== 127; }), 'Display name must not contain control characters' ) .optional(), // Paths defaultClaudeMdPath: z.string().max(500).optional(), defaultWorkingDir: z.string().max(500).optional(), lastUsedCase: z.string().max(200).optional(), // Feature toggles ralphTrackerEnabled: z.boolean().optional(), subagentTrackingEnabled: z.boolean().optional(), subagentActiveTabOnly: z.boolean().optional(), /** Ultracode/Workflow run visualization (default OFF). Gates workflowRunWatcher + the master-detail tab. SYNCED. */ showUltracodeAgents: z.boolean().optional(), /** Floating ultracode run windows w/ tab connector lines (default OFF). Also starts workflowRunWatcher. SYNCED. */ ultracodeFloatingWindows: z.boolean().optional(), imageWatcherEnabled: z.boolean().optional(), tunnelEnabled: z.boolean().optional(), // Action field (NOT persisted): explicit per-request acknowledgment that the // operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD). // Lets the UI enable a tunnel after a confirm dialog without the // CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting. acknowledgeUnauthTunnel: z.boolean().optional(), tabTwoRows: z.boolean().optional(), agentTeamsEnabled: z.boolean().optional(), /** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */ claudeModel: z.string().max(50).optional(), opusContext1mEnabled: z.boolean().optional(), // COD-108 remote-session auto-reconnect kill-switch (default ON). When false, // the TmuxManager watcher does nothing — dropped remote sessions are NOT // auto-reattached. remoteAutoReconnect: z.boolean().optional(), thinkingEffort: z.string().max(20).optional(), // UI visibility showFontControls: z.boolean().optional(), showSystemStats: z.boolean().optional(), showTokenCount: z.boolean().optional(), showCost: z.boolean().optional(), showLifecycleLog: z.boolean().optional(), showResponseViewer: z.boolean().optional(), showMonitor: z.boolean().optional(), showProjectInsights: z.boolean().optional(), showFileBrowser: z.boolean().optional(), showSubagents: z.boolean().optional(), showMultiMonitorButton: z.boolean().optional(), showPlanUsageLimits: z.boolean().optional(), // Action field (NOT persisted as a setting): when true, (re)injects the // plan-usage statusLine exporter into active Claude sessions so live usage % // starts flowing. Sent on ENABLE only — the chip's DISPLAY is per-device // (client-side), but telemetry COLLECTION is server-side, so the per-device // toggle signals it out-of-band here rather than via showPlanUsageLimits. statusLineTelemetry: z.boolean().optional(), showRedrawButton: z.boolean().optional(), // Input gestureControlEnabled: z.boolean().optional(), // Claude CLI settings claudeMode: z.string().max(50).optional(), allowedTools: z.string().max(2000).optional(), // Codex CLI settings codexDangerouslyBypassApprovals: z.boolean().optional(), codexAnimationsEnabled: z.boolean().optional(), // Terminal history and retention terminalScrollbackLines: z .number() .int() .min(MIN_TERMINAL_SCROLLBACK_LINES) .max(MAX_TERMINAL_SCROLLBACK_LINES) .optional(), tmuxHistoryLimit: z.number().int().min(MIN_TERMINAL_SCROLLBACK_LINES).max(MAX_TERMINAL_SCROLLBACK_LINES).optional(), terminalBufferMaxBytes: z.number().int().min(MIN_TERMINAL_BUFFER_BYTES).max(MAX_TERMINAL_BUFFER_BYTES).optional(), terminalBufferTrimBytes: z.number().int().min(MIN_TERMINAL_BUFFER_BYTES).max(MAX_TERMINAL_BUFFER_BYTES).optional(), // CPU priority nice: z .object({ enabled: z.boolean().optional(), niceValue: z.number().int().min(-20).max(19).optional(), }) .optional(), // Notification preferences (cross-device sync) notificationPreferences: z .object({ enabled: z.boolean().optional(), browserNotifications: z.boolean().optional(), audioAlerts: z.boolean().optional(), stuckThresholdMs: z.number().optional(), muteCritical: z.boolean().optional(), muteWarning: z.boolean().optional(), muteInfo: z.boolean().optional(), eventTypes: z .object({ permission_prompt: NotificationEventSchema, elicitation_dialog: NotificationEventSchema, idle_prompt: NotificationEventSchema, stop: NotificationEventSchema, session_error: NotificationEventSchema, respawn_cycle: NotificationEventSchema, token_milestone: NotificationEventSchema, ralph_complete: NotificationEventSchema, subagent_spawn: NotificationEventSchema, subagent_complete: NotificationEventSchema, }) .optional(), _version: z.number().optional(), }) .optional(), // Voice settings (cross-device sync) voiceSettings: z .object({ apiKey: z.string().max(200).optional(), language: z.string().max(20).optional(), keyterms: z.string().max(500).optional(), insertMode: z.string().max(20).optional(), }) .optional(), // Run mode preference (cross-device sync) runMode: z.string().max(20).optional(), // Custom respawn presets (cross-device sync, replaces localStorage-only storage) respawnPresets: z .array( z.object({ id: z.string().max(100), name: z.string().max(100), config: z.object({ idleTimeoutMs: z.number().optional(), updatePrompt: z.string().max(5000).optional(), interStepDelayMs: z.number().optional(), sendClear: z.boolean().optional(), sendInit: z.boolean().optional(), kickstartPrompt: z.string().max(5000).optional(), autoAcceptPrompts: z.boolean().optional(), }), durationMinutes: z.number().optional(), builtIn: z.boolean().optional(), createdAt: z.number().optional(), }) ) .max(20) .optional(), }) .strict() .superRefine((settings, ctx) => { if ( settings.terminalBufferMaxBytes !== undefined && settings.terminalBufferTrimBytes !== undefined && settings.terminalBufferTrimBytes > settings.terminalBufferMaxBytes ) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['terminalBufferTrimBytes'], message: 'terminalBufferTrimBytes must be less than or equal to terminalBufferMaxBytes', }); } }); /** * Schema for POST /api/sessions/:id/input with length limit */ export const SessionInputWithLimitSchema = z.object({ input: z.string().max(100000), // 100KB max input useMux: z.boolean().optional(), // Reliable-delivery dedup (optional; absent for curl/legacy clients). The web // client tags each input with a stable clientId + a monotonic per-session seq // and redelivers anything it hasn't seen ACKed (e.g. a frame silently dropped // by a half-open WebSocket on a flaky link). The server applies each (clientId, // seq) at-most-once via Session.shouldApplyInput so a redelivery can't type the // prompt twice. `.optional()` (not `.nullish()`) — the client omits them when // unset rather than sending null. See docs/reliable-input-delivery.md. seq: z.number().int().nonnegative().optional(), clientId: z.string().max(128).optional(), // Send-and-wait (agent orchestration): `true` for the default signal set, or the // same grammar as `GET .../wait` — a comma string or an array of signals. Absent // means the historical fire-and-forget behavior, byte for byte. // // `.nullish()`, not `.optional()`: a third-party caller building the body with // JSON.stringify keeps an explicit null on the wire, and `.optional()` rejects it // with INVALID_INPUT. That gotcha has shipped as a real bug twice. wait: z.union([z.boolean(), z.string().max(120), z.array(z.string().max(120)).max(8)]).nullish(), // Unbounded above: the effective value is clamped to MAX_WAIT_MS server-side and // returned as `data.wait.timeoutMs`, so a caller that asks for 24h sees what it // actually got. A `.max()` here would turn the same documented clamp into a 400 for // large-enough guesses, which is the one behaviour an agent cannot predict. waitTimeout: z.number().int().positive().nullish(), }); /** * Query validation for `GET /api/sessions/:id/wait` (agent wait primitives). * * Everything arrives as a string. `timeout` is coerced and bounded here, then * clamped again to the operator's ceiling by `clampWaitMs()` — the schema bound * only keeps an absurd number out of the arithmetic. A non-numeric `timeout` is a * 400 rather than a silent fallback, so an agent never believes it asked for a * longer wait than it got; the value actually applied comes back as * `data.wait.timeoutMs`, which is what makes the clamp observable. `until` is * parsed by `parseWaitSignals()`, which reports unknown tokens instead of * dropping them. * * `until` accepts an ARRAY as well as the comma string: `?until=stop&until=exit` * is how most HTTP clients express a list, Fastify's query parser delivers a * repeated parameter as an array, and `parseWaitSignals()` has always handled * both. Rejecting the repeated form left that branch unreachable and 400'd the * more natural spelling. */ export const SessionWaitQuerySchema = z.object({ until: z.union([z.string().max(120), z.array(z.string().max(120)).max(8)]).optional(), // No upper bound on purpose. The contract is "clamped to [MIN_WAIT_MS, MAX_WAIT_MS]", // and a `.max()` here contradicted it: `timeout=99999999` was a 400 mid-fan-out while // `timeout=600001` was silently clamped, so the same documented rule produced two // different outcomes depending on how big the caller's guess was. `clampWaitMs()` // bounds every finite value, and `.int()` still rejects `Infinity`/`1e999` and junk. timeout: z.coerce.number().int().positive().optional(), fresh: z.enum(['0', '1', 'true', 'false']).optional(), }); /** * Query validation for `GET /api/sessions/:id/wait-output`. * * `match` is a LITERAL substring, never a pattern: `search-service.ts` avoids regex * so there is no ReDoS surface, and this endpoint is more exposed still (the pattern * would be caller-supplied and the input is a live stream). The length bound is a * second reason the carry buffer stays small. The route separately rejects a `regex` * parameter outright rather than ignoring it. */ export const SessionWaitOutputQuerySchema = z.object({ match: z.string().min(MIN_MATCH_LENGTH).max(MAX_MATCH_LENGTH), nocase: z.enum(['0', '1', 'true', 'false']).optional(), from: z.enum(['now', 'buffer']).optional(), // Unbounded above for the same reason as SessionWaitQuerySchema.timeout: clamping is // the documented contract, so a large value must clamp rather than 400. timeout: z.coerce.number().int().positive().optional(), }); // ========== Session Mutation Routes ========== /** PUT /api/sessions/:id/name */ export const SessionNameSchema = z.object({ name: z.string().min(0).max(128), }); /** PUT /api/sessions/:id/color */ export const SessionColorSchema = z.object({ color: z.string().max(30), }); /** POST /api/sessions/:id/ralph-config */ export const RalphConfigSchema = z.object({ enabled: z.boolean().optional(), completionPhrase: z.string().max(500).optional(), maxIterations: z.number().int().min(0).max(10000).optional(), maxTodos: z.number().int().positive().max(10000).optional(), todoExpirationMinutes: z.number().int().positive().max(525600).optional(), reset: z.union([z.boolean(), z.literal('full')]).optional(), disableAutoEnable: z.boolean().optional(), }); /** POST /api/sessions/:id/fix-plan/import */ export const FixPlanImportSchema = z.object({ content: z.string().max(500000), }); /** POST /api/sessions/:id/ralph-prompt/write */ export const RalphPromptWriteSchema = z.object({ content: z.string().max(500000), }); /** POST /api/sessions/:id/auto-clear */ export const AutoClearSchema = z.object({ enabled: z.boolean(), threshold: z.number().int().min(0).max(1000000).optional(), }); /** POST /api/sessions/:id/auto-compact */ export const AutoCompactSchema = z.object({ enabled: z.boolean(), threshold: z.number().int().min(0).max(1000000).optional(), prompt: z.string().max(10000).optional(), }); /** POST /api/sessions/:id/auto-resume */ export const AutoResumeSchema = z.object({ enabled: z.boolean(), }); /** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */ export const PinSessionSchema = z.object({ pinned: z.boolean(), }); /** POST /api/sessions/:id/image-watcher */ export const ImageWatcherSchema = z.object({ enabled: z.boolean(), }); /** POST /api/sessions/:id/flicker-filter */ export const FlickerFilterSchema = z.object({ enabled: z.boolean(), }); /** POST /api/run */ export const QuickRunSchema = z.object({ prompt: z.string().min(1).max(100000), workingDir: safePathSchema.optional(), envOverrides: safeEnvOverridesSchema, }); /** POST /api/scheduled */ export const ScheduledRunSchema = z.object({ prompt: z.string().min(1).max(100000), workingDir: safePathSchema.optional(), durationMinutes: z.number().int().min(1).max(14400).optional(), }); // ========== Cron Jobs ========== /** 'HH:MM' 24-hour time. */ const hhmmSchema = z.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, 'Time must be HH:MM (24-hour)'); /** Prompt delivery is single-line only (writeViaMux/Ink constraint) — reject newlines outright. */ const noNewlines = (v: string) => !/[\r\n]/.test(v); /** Shared field shape for creating/updating a scheduled job. */ const CronJobBaseSchema = z.object({ name: z.string().min(1).max(200), agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']), workingDir: safePathSchema, launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(), promptMode: z.enum(['inline_text', 'prompt_file_path']), promptText: z .string() .max(100000) .refine(noNewlines, 'promptText must be a single line (multi-line prompts are not supported)') .optional(), promptFilePath: safePathSchema.optional(), inputMode: z.enum(['paste', 'typed']), scheduleType: z.enum(['once', 'interval', 'daily', 'weekly']), runAt: z.number().int().positive().optional(), intervalMinutes: z.number().int().min(1).max(525600).optional(), dailyTime: hhmmSchema.optional(), weeklyDays: z.array(z.number().int().min(0).max(6)).min(1).max(7).optional(), weeklyTime: hhmmSchema.optional(), enabled: z.boolean(), notes: z.string().max(2000).optional(), concurrencyPolicy: z.enum(['warn_only', 'skip_if_same_agent_running']), autoClosePreviousSession: z.boolean().optional(), }); /** Cross-field validation: required fields depend on promptMode + scheduleType. */ function refineCronJob(val: z.infer, ctx: z.RefinementCtx): void { const add = (message: string, path: string) => ctx.addIssue({ code: 'custom', message, path: [path] }); if (val.promptMode === 'inline_text' && !val.promptText) { add('promptText is required when promptMode is inline_text', 'promptText'); } if (val.promptMode === 'prompt_file_path' && !val.promptFilePath) { add('promptFilePath is required when promptMode is prompt_file_path', 'promptFilePath'); } if (val.scheduleType === 'once' && val.runAt === undefined) { add('runAt is required for a one-time schedule', 'runAt'); } if (val.scheduleType === 'interval' && val.intervalMinutes === undefined) { add('intervalMinutes is required for an interval schedule', 'intervalMinutes'); } if (val.scheduleType === 'daily' && !val.dailyTime) { add('dailyTime is required for a daily schedule', 'dailyTime'); } if (val.scheduleType === 'weekly' && (!val.weeklyTime || !val.weeklyDays?.length)) { add('weeklyDays and weeklyTime are required for a weekly schedule', 'weeklyTime'); } } /** POST /api/cron/jobs — full job definition. */ export const CronJobSchema = CronJobBaseSchema.superRefine(refineCronJob); /** PUT /api/cron/jobs/:id — partial update. */ export const CronJobUpdateSchema = CronJobBaseSchema.partial(); /** PUT /api/cron/jobs/:id/enabled */ export const CronJobEnabledSchema = z.object({ enabled: z.boolean() }); /** POST /api/cases/link */ export const LinkCaseSchema = z.object({ name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'), path: safePathSchema, }); /** PUT /api/cases/order */ export const CaseOrderSchema = z.object({ order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')), }); /** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */ export const SessionOrderUpdateSchema = z.object({ // Bounded defensively: ids are uuid-ish (<=100 chars) and the client pushes only // its open-tab order (max sessions is 50) — 500 leaves ample headroom while // keeping a hostile/buggy client from persisting megabytes into state.json. order: z.array(z.string().max(100)).max(500), }); /** POST /api/auth/revoke */ export const RevokeSessionSchema = z.object({ sessionToken: z.string().min(1).max(200).optional(), }); /** POST /api/generate-plan */ export const GeneratePlanSchema = z.object({ taskDescription: z.string().min(1).max(100000), detailLevel: z.enum(['brief', 'standard', 'detailed']).optional(), }); /** POST /api/generate-plan-detailed */ export const GeneratePlanDetailedSchema = z.object({ taskDescription: z.string().min(1).max(100000), caseName: z.string().max(200).optional(), }); /** POST /api/cancel-plan-generation */ export const CancelPlanSchema = z.object({ orchestratorId: z.string().max(200).optional(), }); /** PATCH /api/sessions/:id/plan/task/:taskId */ export const PlanTaskUpdateSchema = z.object({ status: z.enum(['pending', 'in_progress', 'completed', 'failed', 'blocked']).optional(), error: z.string().max(10000).optional(), incrementAttempts: z.boolean().optional(), }); /** POST /api/sessions/:id/plan/task (add task) */ export const PlanTaskAddSchema = z.object({ content: z.string().min(1).max(10000), priority: z.enum(['P0', 'P1', 'P2']).optional(), verificationCriteria: z.string().max(10000).optional(), dependencies: z.array(z.string().max(200)).optional(), insertAfter: z.string().max(200).optional(), }); /** POST /api/sessions/:id/cpu-limit */ export const CpuLimitSchema = z.object({ cpuLimit: z.number().int().min(0).max(100).optional(), ioClass: z.enum(['idle', 'best-effort', 'realtime']).optional(), ioLevel: z.number().int().min(0).max(7).optional(), }); /** PUT /api/execution/model-config */ export const ModelConfigUpdateSchema = z.record(z.string(), z.unknown()); /** PUT /api/subagent-window-states */ export const SubagentWindowStatesSchema = z .object({ minimized: z.record(z.string(), z.boolean()).optional(), open: z.array(z.string()).optional(), }) .passthrough(); /** PUT /api/subagent-parents */ export const SubagentParentMapSchema = z.record(z.string(), z.string()); /** POST /api/sessions/:id/interactive */ export const InteractiveStartSchema = z.object({ /** * COD-118: explicit user-initiated restart — clears a tripped PTY-exit circuit * breaker before starting. Automatic reconnect/re-attach callers (e.g. the * frontend's selectSession auto-attach) must NOT send this flag. */ clearBreaker: z.boolean().optional(), }); /** POST /api/sessions/:id/interactive-respawn */ export const InteractiveRespawnSchema = z.object({ respawnConfig: RespawnConfigSchema.optional(), durationMinutes: z.number().int().min(1).max(14400).optional(), }); /** POST /api/sessions/:id/respawn/enable */ export const RespawnEnableSchema = z.object({ config: RespawnConfigSchema.optional(), durationMinutes: z.number().int().min(1).max(14400).optional(), }); // ========== Web Push ========== /** POST /api/push/subscribe */ export const PushSubscribeSchema = z.object({ endpoint: z .string() .url() .max(2000) .refine(isSafePushEndpoint, { message: 'endpoint must be an https URL to a public (non-internal) host' }), keys: z.object({ p256dh: z.string().min(1).max(500), auth: z.string().min(1).max(500), }), userAgent: z.string().max(500).optional(), pushPreferences: z.record(z.string(), z.boolean()).optional(), }); /** PUT /api/push/subscribe/:id */ export const PushPreferencesUpdateSchema = z.object({ pushPreferences: z.record(z.string(), z.boolean()), }); // ========== Ralph Loop ========== /** POST /api/ralph-loop/start */ export const RalphLoopStartSchema = z.object({ caseName: z .string() .regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format') .optional() .default('testcase'), taskDescription: z.string().min(1).max(100000), completionPhrase: z.string().max(100).default('COMPLETE'), maxIterations: z.number().int().min(0).max(1000).nullable().default(10), enableRespawn: z.boolean().default(false), envOverrides: safeEnvOverridesSchema, /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort: effortLevelSchema, planItems: z .array( z.object({ content: z.string(), priority: z.string().optional(), enabled: z.boolean().default(true), }) ) .optional(), }); // ========== Orchestrator Loop ========== /** POST /api/orchestrator/start */ export const OrchestratorStartSchema = z.object({ goal: z.string().min(1).max(100000), config: z .object({ plannerModel: z.string().max(100).optional(), researchEnabled: z.boolean().optional(), autoApprove: z.boolean().optional(), maxPhaseRetries: z.number().int().min(1).max(10).optional(), phaseTimeoutMs: z.number().int().min(60000).max(7200000).optional(), enableTeamAgents: z.boolean().optional(), maxParallelSessions: z.number().int().min(1).max(10).optional(), verificationMode: z.enum(['strict', 'moderate', 'lenient']).optional(), compactBetweenPhases: z.boolean().optional(), }) .optional(), }); /** POST /api/orchestrator/reject */ export const OrchestratorRejectSchema = z.object({ feedback: z.string().min(1).max(10000), }); // ========== Cross-Session Search (COD-9) ========== /** Valid federated source kinds for `GET /api/search?types=`. */ export const SEARCH_SOURCE_TYPES = ['session', 'event', 'file'] as const; /** * GET /api/search query validation. * * Query params arrive as strings: `q` is bounded (1..200 chars), `types` is an * optional comma-separated allowlisted CSV, and `limit` is an optional coerced * integer clamped to 1..60. Validation is the first line of defense — a missing * or oversized `q`, an unknown type, or a non-numeric limit is rejected with 400. */ export const SearchQuerySchema = z.object({ q: z.string().trim().min(1, 'Query is required').max(200, 'Query too long (max 200 chars)'), types: z .string() .max(100) .optional() .refine( (v) => v === undefined || v .split(',') .map((t) => t.trim()) .filter(Boolean) .every((t) => (SEARCH_SOURCE_TYPES as readonly string[]).includes(t)), { message: 'Invalid types value' } ), limit: z.coerce.number().int().min(1).max(60).optional(), }); // ========== Web Tabs (dashboard URLs) ========== /** * A dashboard URL. `isValidWebviewUrl` rejects anything that is not plain * http/https, anything carrying embedded credentials, and anything without a * hostname. See `src/web/webview-proxy.ts` for why each of those matters. */ const webviewUrlSchema = z .string() .trim() .min(1, 'URL is required') .max(2000, 'URL too long (max 2000 chars)') .refine(isValidWebviewUrl, { message: 'Invalid URL: must be http(s), with a hostname and no embedded credentials', }); const WebviewBaseSchema = z.object({ name: z.string().trim().min(1, 'Name is required').max(60, 'Name too long (max 60 chars)'), url: webviewUrlSchema, /** A single glyph shown on the tab. Bounded generously: one emoji can be several code units. */ icon: z.string().max(8).optional(), embedMode: z.enum(['proxy', 'direct']).optional(), /** * Opt out of the iframe sandbox. Defaults to false: a proxied page is served * from Codeman's own origin, so `allow-same-origin` would let it read this page * and call the API that spawns agents. */ trusted: z.boolean().optional(), }); /** POST /api/webviews */ export const WebviewCreateSchema = WebviewBaseSchema; /** PATCH /api/webviews/:id, partial update. */ export const WebviewUpdateSchema = WebviewBaseSchema.partial(); /** POST /api/webviews/probe: reachability + framing check for the editor's Test button. */ export const WebviewProbeSchema = z.object({ url: webviewUrlSchema });