Files
Codeman/src/web/schemas.ts
T
Codeman maintainer ce22c2a608 feat(path-picker): show hidden files and folders, and harden the secret blocklist
The picker behind Link Existing's "Browse" and the mobile keyboard's Path key
refused every path with a dot-prefixed segment, so `.github/workflows/ci.yml`
could not be selected and a hidden folder could not be opened at all. It gains
the same `.*` toggle as the File Viewer: default OFF, per-device, and applied to
both the listing and the preview endpoint, which re-resolves the path
independently.

That dotfile filter was quietly doing security work. The picker's roots include
Home, so with every hidden path unreachable the shared blocklist never had to
name the credentials that live in dot-directories. Lifting the filter removes
that accident, so `isSensitivePath` now covers them explicitly: SSH keys at any
depth rather than only under $HOME, GPG keyrings, AWS/GCloud/Azure/Docker/
Kubernetes credentials, npm, Yarn, git, gh, netrc, PyPI, RubyGems, Cargo and
Terraform tokens, .pgpass and .my.cnf, and the Claude and Codeman agent
credentials. `~/.codeman/` and `~/.claude/` stay attachable as trees, since the
publish skill and the review-card loop read from them; only their secret-bearing
members are named.

Everything else still applies with the toggle on: blocked trees, sensitive
files, root confinement, ownership scoping and symlink-escape checks. A hidden
entry whose realpath is a secret is dropped from the listing, and opening it is
refused.

Follows #221

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:16:54 +02:00

1386 lines
51 KiB
TypeScript

/**
* @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' });
/**
* Opt-in flag for listing dot-prefixed entries in the path picker. Absent means
* off, so an old client keeps the previous behavior. It is a string rather than
* a boolean because it arrives as a query parameter; `'false'` is accepted (and
* means off) so a client can send the flag unconditionally.
*/
const showHiddenQuerySchema = z.enum(['true', 'false']).optional();
/** 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(),
showHidden: showHiddenQuerySchema,
});
/** 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(),
showHidden: showHiddenQuerySchema,
});
/**
* 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=<path>,dst=<path>`
// 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 <case>/.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(),
/**
* Inject the Codeman agent skill (`skills/codeman`) into `<case>/.claude/skills/`
* on Claude session create, so an agent inside the session can drive the API
* (see docs/agent-control-plan.md §2). SYNCED, default OFF: every skill's
* name+description costs context on every turn, so it is opt-in. Injection is
* add-only at create; a marker keeps user-authored copies untouched.
*/
agentSkillEnabled: 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<typeof CronJobBaseSchema>, 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 });