2026-02-04 07:24:32 +01:00
/**
* @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' ;
2026-06-09 20:02:17 +02:00
import { SAFE_PATH_PATTERN , isSafePushEndpoint } from '../utils/index.js' ;
2026-07-27 17:06:36 +02:00
import { isValidWebviewUrl } from './webview-proxy.js' ;
2026-09-04 15:21:12 +02:00
import { isBlockedWebviewUrl } from './webview-egress-policy.js' ;
2026-06-29 17:49:39 -04:00
import {
MAX_TERMINAL_BUFFER_BYTES ,
MAX_TERMINAL_SCROLLBACK_LINES ,
MIN_TERMINAL_BUFFER_BYTES ,
MIN_TERMINAL_SCROLLBACK_LINES ,
} from '../config/terminal-history.js' ;
2026-08-05 08:44:47 +02:00
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js' ;
2026-10-01 16:59:46 +02:00
import { CODEX_REASONING_EFFORTS } from '../types/session.js' ;
2026-10-04 23:42:47 +02:00
import { WEBHOOK_KINDS , WEBHOOK_SCOPES } from '../types/push.js' ;
2026-10-30 18:26:35 +08:00
import { DISCORD_ON_DELETE , DISCORD_PING_MODES , DISCORD_THREAD_CREATION } from '../types/discord.js' ;
2026-08-09 01:18:03 +02:00
import { MIN_MATCH_LENGTH , MAX_MATCH_LENGTH } from '../config/agent-wait.js' ;
2026-09-19 12:07:57 +02:00
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js' ;
2026-09-24 18:17:06 +02:00
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js' ;
2026-10-24 14:15:49 +02:00
import { MAX_MESSAGE_BYTES } from './agent-inbox.js' ;
2026-09-02 08:26:45 +08:00
import { enabledCliIds , enabledClis } from '../config/cli-registry/registry.js' ;
import type { SessionMode } from '../types.js' ;
2026-10-04 19:05:43 +02:00
import { isAdvisorModel } from '../types/session.js' ;
2026-02-04 07:24:32 +01:00
2026-02-18 10:23:10 +01:00
// ========== 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 ;
2026-02-27 13:16:23 +01:00
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' )
) {
2026-02-18 10:23:10 +01:00
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' ,
});
2026-07-20 19:11:31 +08:00
/**
* 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' });
2026-08-09 16:16:54 +02:00
/**
* 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 ();
2026-10-31 18:26:00 +08:00
/**
2026-10-31 19:44:02 +08:00
* Body for the path picker's "New folder": one folder name created directly inside `path`. The name is held
* to the character set `safePathSchema` accepts (letters, digits, `_ - . ~` and space, no `..`), so every
2026-10-31 21:12:46 +08:00
* name it accepts is one Link Existing and a session working directory accept (the parent's own path is
* judged there separately), and never a control character. It is also one path segment, not dot-prefixed (the picker hides those by default) and not tilde-prefixed (a folder named `~` is an `rm -rf ~` accident), and at
2026-10-31 19:44:02 +08:00
* most 255 BYTES, the usual filesystem limit.
2026-10-31 18:26:00 +08:00
*/
export const FilesystemMkdirSchema = z . object ({
path : filesystemPickerPathSchema ,
name : z
. string ()
. trim ()
. min ( 1 , 'Folder name is required' )
2026-10-31 19:44:02 +08:00
. refine (( n ) => Buffer . byteLength ( n ) <= 255 , { message : 'Folder name is too long' })
. refine (( n ) => /^[\p{L}\p{N}_\-. ~]+$/u.test(n), {
message : 'Folder name can only use letters, numbers, spaces and _ - . ~' ,
})
2026-10-31 21:12:46 +08:00
. refine (( n ) => ! n . includes ( '..' ) && ! n . startsWith ( '.' ) && ! n . startsWith ( '~' ), {
message : 'Folder name cannot contain "..", or start with a dot or a tilde' ,
2026-10-31 18:26:00 +08:00
}),
sessionId : z
. string ()
. max ( 100 )
. regex ( /^[a-zA-Z0-9_-]+$/ , 'Invalid session id' )
. optional (),
2026-10-31 19:44:02 +08:00
/** Same opt-in flag as browse: lets the picker create inside a hidden folder it is showing. */
showHidden : showHiddenQuerySchema ,
2026-10-31 18:26:00 +08:00
});
2026-07-20 19:11:31 +08:00
/** 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 (),
2026-08-09 16:16:54 +02:00
showHidden : showHiddenQuerySchema ,
2026-07-20 19:11:31 +08:00
});
2026-07-20 19:50:20 +08:00
/** 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 (),
2026-08-09 16:16:54 +02:00
showHidden : showHiddenQuerySchema ,
2026-07-20 19:50:20 +08:00
});
2026-08-05 08:44:47 +02:00
/**
* 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 ();
2026-10-13 17:10:24 +02:00
/**
* Body validation for `POST /api/sessions/:id/html-view` (render a local HTML file
* as a sandboxed page). `path` is absolute or relative to the session workspace;
* the extension, containment, guard and broad-root checks are the route's
* (html-view-routes.ts), after parsing.
*/
export const HtmlViewOpenSchema = z
. object ({
path : z
. string ({ error : 'Missing path parameter' })
. min ( 1 , 'Missing path parameter' )
. max ( 4096 )
. refine (( p ) => ! p . includes ( '\0' ) && ! p . includes ( '\n' ) && ! p . includes ( '\r' ), {
message : 'Invalid path' ,
}),
})
. strict ();
2026-09-02 08:26:45 +08:00
/**
* The run-mode ids the API currently accepts: every ENABLED registry entry.
*
* Exported so anything needing the authoritative list derives it from here rather than
* restating the nine names (which is how the old literal enum drifted from the run menu).
*/
export function sessionModeIds () : string [] {
return enabledCliIds ();
}
2026-02-18 10:23:10 +01:00
2026-08-09 22:39:07 +02:00
/**
2026-09-02 08:26:45 +08:00
* Validation for a run mode, resolved AT PARSE TIME.
*
* ⚠️ Deliberately not a `z.enum([...])`. An enum has to be handed its members when the
* SCHEMA OBJECT is built, which happens once at module import — so a CLI enabled while the
* server was running kept failing validation with INVALID_INPUT until a restart, even
* though the run menu already offered it. Checking membership inside the refinement moves
* the question to when the request is actually validated.
*
* The cast is because callers type this field as `SessionMode`; the runtime check above is
* what actually constrains it.
2026-08-09 22:39:07 +02:00
*/
2026-09-02 08:26:45 +08:00
function sessionModeSchema () : z . ZodType < SessionMode > {
return (
z
. string ()
// Bounded BEFORE the membership check, and before the failure message quotes the value
// back. `.max(24)` matches the `cliId` pattern in cli-registry/schema.ts — no id longer
// than that can ever be registered, so nothing legitimate is rejected — and it means a
// rejected mode cannot echo a body-limit-sized string into an error string and a log
// line. Without it the only bound on either was the HTTP body limit.
. max ( 24 )
. superRefine (( value , ctx ) => {
const allowed = sessionModeIds ();
if ( ! allowed . includes ( value )) {
ctx . addIssue ({
code : 'custom' ,
message : `Invalid run mode ${ JSON . stringify ( value ) } . Enabled modes: ${ allowed . join ( ', ' ) } ` ,
});
}
}) as unknown as z . ZodType < SessionMode >
);
}
// ========== Env Var Allowlist ==========
/**
* Allowlisted env var key prefixes, contributed by the ENABLED CLIs in the registry
* (`env.allowedPrefixes`) — `CLAUDE_CODE_`, `OPENCODE_`, `CODEX_`, `GEMINI_`, `GOOGLE_`,
2026-10-18 08:09:39 +02:00
* `ANTIGRAVITY_`, `PI_`, `GROK_`, `XAI_`, `DSH_`, `DEEPSEEK_`, `OMP_`, `COPILOT_` as shipped.
2026-09-02 08:26:45 +08:00
*
* ⚠️ Resolved AT PARSE TIME, not at module load. This used to be a frozen array computed
* once when the module was imported, which meant a CLI enabled while the server was running
* had its env prefix rejected until a restart — validation and the run menu disagreeing
* about which CLIs exist. Reading the registry per call costs a memoized array lookup.
*
* ⚠️ This is ONE GLOBAL LIST applied with no mode context, so admitting a prefix for one CLI
* widens it for every mode at once. That is why an entry only ever contributes its own
* VENDOR namespace: pi's ~34 provider keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, HF_TOKEN, …)
* share no prefix and stay out, and a dsh `settings.yaml` can nominate ANY env var as a
* provider credential — same problem, same answer. Those CLIs authenticate via their own
* `/login` or the server process's own environment.
*/
function allowedEnvPrefixes () : string [] {
return enabledClis (). flatMap (( entry ) => entry . env . allowedPrefixes );
}
/**
* Allowlisted exact env var keys (checked alongside the prefixes), likewise contributed by
* enabled registry entries via `env.allowedKeys`.
*
* As shipped this is claude's CLAUDE_CONFIG_DIR, which relocates the Claude CLI's user
* config (credentials, settings, stats) so a case can run on a separate Claude subscription
* (#255). Exact match only — CLAUDE_CONFIG_DIR_EXTRA etc. stay rejected.
*/
function allowedEnvKeys () : Set < string > {
return new Set ( enabledClis (). flatMap (( entry ) => entry . env . allowedKeys ));
}
2026-08-09 22:39:07 +02:00
2026-02-18 10:23:10 +01:00
/** Env var keys that are always blocked (security-sensitive) */
const BLOCKED_ENV_KEYS = new Set ([
2026-02-27 13:16:23 +01:00
'PATH' ,
'LD_PRELOAD' ,
'LD_LIBRARY_PATH' ,
'NODE_OPTIONS' ,
'CODEMAN_MUX_NAME' ,
'CODEMAN_TMUX' ,
'OPENCODE_SERVER_PASSWORD' , // Security-sensitive: server auth password
2026-02-18 10:23:10 +01:00
]);
2026-09-02 08:26:45 +08:00
/**
* Validate that an env var key is allowed.
*
* ⚠️ `BLOCKED_ENV_KEYS` is checked FIRST and is deliberately NOT registry-driven. It is a
* hard floor: a rogue or fat-fingered `allowedPrefixes` entry (say `''`, which prefixes
* everything) still cannot unblock PATH or LD_PRELOAD.
*/
2026-02-18 10:23:10 +01:00
function isAllowedEnvKey ( key : string ) : boolean {
if ( BLOCKED_ENV_KEYS . has ( key )) return false ;
2026-09-02 08:26:45 +08:00
if ( allowedEnvKeys (). has ( key )) return true ;
return allowedEnvPrefixes (). some (( prefix ) => key . startsWith ( prefix ));
2026-02-18 10:23:10 +01:00
}
/** Zod schema for env overrides with allowlist enforcement */
2026-02-27 13:16:23 +01:00
const safeEnvOverridesSchema = z
. record ( z . string (), z . string ())
. optional ()
. refine (
( val ) => {
if ( ! val ) return true ;
return Object . keys ( val ). every ( isAllowedEnvKey );
},
{
message :
2026-10-11 12:50:14 +08:00
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_*, OMP_*, COPILOT_* keys and CLAUDE_CONFIG_DIR are allowed.' ,
2026-02-27 13:16:23 +01:00
}
);
2026-02-18 10:23:10 +01:00
2026-06-07 10:33:11 +08:00
// ========== 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 ();
2026-10-04 19:05:43 +02:00
/**
* Claude advisor model for new sessions: `fable`/`opus`/`sonnet` or a full model id in one of
* those families (isAdvisorModel). Merged into the launch `--settings` JSON as `advisorModel`,
* a soft default that /advisor still switches in-session. The allowlist is also the injection
* guard for the single-quoted `--settings` argument.
*/
const advisorModelSchema = z
. string ()
. max ( 64 )
. refine (( value ) => isAdvisorModel ( value ), {
message : 'advisorModel must be fable, opus, sonnet or a full claude-fable/opus/sonnet model id' ,
})
. optional ();
2026-02-04 07:24:32 +01:00
// ========== Session Routes ==========
/**
* Schema for POST /api/sessions
* Creates a new session with optional working directory, mode, and name.
*/
2026-02-26 10:16:20 +01:00
/** Schema for OpenCode-specific configuration */
2026-02-27 13:16:23 +01:00
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 ();
2026-02-26 10:16:20 +01:00
2026-06-10 09:31:58 -04:00
/** Schema for Codex (OpenAI CLI)-specific configuration */
const CodexConfigSchema = z
. object ({
model : z
. string ()
. max ( 100 )
. regex ( /^[a-zA-Z0-9._\-/]+$/ )
. optional (),
2026-10-01 16:59:46 +02:00
reasoningEffort : z.enum ( CODEX_REASONING_EFFORTS ). optional (),
2026-06-10 09:31:58 -04:00
resumeSessionId : z
. string ()
. max ( 100 )
. regex ( /^[a-zA-Z0-9_-]+$/ )
. optional (),
dangerouslyBypassApprovals : z.boolean (). optional (),
2026-07-29 03:39:07 +03:00
animations : z.boolean (). optional (),
2026-06-10 09:31:58 -04:00
renderMode : z
. enum ([ 'scrollback' , 'hybrid' ])
. optional ()
. transform (() => 'hybrid' as const ),
})
. optional ();
2026-05-16 20:10:49 -04:00
/** 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 ();
2026-08-04 12:59:40 +02:00
/** 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 ();
2026-08-13 13:54:47 +02:00
/**
* Schema for Pi CLI (pi.dev)-specific configuration.
*
* No bypass field exists on purpose: pi has no permission prompts. The one
* privilege-shaped knob is the TRI-STATE `approveProjectTrust` (see PiConfig),
* which the multi-user clamp MATERIALIZES to `false` for non-granted owners.
*/
const PiConfigSchema = z
. object ({
// `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`.
model : z
. string ()
. max ( 100 )
. regex ( /^[a-zA-Z0-9._\-/:]+$/ )
. optional (),
provider : z
. string ()
. max ( 50 )
. regex ( /^[a-z0-9-]+$/ )
. optional (),
thinking : z.enum ([ 'off' , 'minimal' , 'low' , 'medium' , 'high' , 'xhigh' , 'max' ]). optional (),
continueSession : z.boolean (). optional (),
resumeSessionId : z
. string ()
. max ( 100 )
. regex ( /^[a-zA-Z0-9._-]+$/ )
. optional (),
approveProjectTrust : z.boolean (). optional (),
})
. optional ();
2026-08-23 08:39:03 +02:00
/**
* Schema for Grok Build CLI (xAI `grok`)-specific configuration.
*
* `alwaysApprove` maps to `--always-approve` (grok's bypassPermissions mode).
* An ABSENT config spawns bare `grok` = grok's own ask-mode default, so the
* multi-user clamp only needs the only-if-sent branch (like codex/antigravity).
*/
const GrokConfigSchema = z
. object ({
model : z
. string ()
. max ( 100 )
. regex ( /^[a-zA-Z0-9._\-/]+$/ )
. optional (),
alwaysApprove : z.boolean (). optional (),
continueSession : z.boolean (). optional (),
// Ids only: grok's --resume also matches session TITLES (arbitrary user
// strings), which this regex deliberately cannot express.
resumeSessionId : z
. string ()
. max ( 100 )
. regex ( /^[a-zA-Z0-9._-]+$/ )
. optional (),
})
. optional ();
2026-10-10 14:06:04 +08:00
const CopilotConfigSchema = z
. object ({
2026-10-17 04:39:54 +08:00
// A leading alphanumeric, like resumeSessionId below: a value like `--yolo` would otherwise be read
// as its own flag after `--model` (bypassing the allowAll clamp). `/` stays for provider/id.
2026-10-10 14:06:04 +08:00
model : z
. string ()
. max ( 100 )
2026-10-17 04:39:54 +08:00
. regex ( /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/ )
2026-10-10 14:06:04 +08:00
. optional (),
allowAll : z.boolean (). optional (),
continueSession : z.boolean (). optional (),
2026-10-11 10:59:33 +08:00
// A single plain word with a leading alphanumeric: --resume also matches session NAMES, so
// `my-feature` is accepted, but a value like `--yolo` must not be, because --resume's value is
// optional and the next token would be read as its own flag (bypassing the allowAll clamp).
2026-10-10 14:06:04 +08:00
resumeSessionId : z
. string ()
. max ( 100 )
2026-10-11 10:59:33 +08:00
. regex ( /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/ )
2026-10-10 14:06:04 +08:00
. optional (),
})
. optional ();
2026-08-18 23:17:58 -05:00
/**
* Schema for OMP CLI-specific configuration.
*/
const OmpConfigSchema = z
. object ({
model : z
. string ()
. max ( 100 )
. regex ( /^[a-zA-Z0-9._\-/]+$/ )
. optional (),
resumeSessionId : z
. string ()
. max ( 100 )
. regex ( /^[a-zA-Z0-9._-]+$/ )
. optional (),
2026-08-26 21:09:01 -05:00
continueSession : z.boolean (). optional (),
2026-08-18 23:17:58 -05:00
})
. optional ();
2026-08-24 03:37:56 +02:00
/**
* Schema for DeepSeek Harness (`dsh`)-specific configuration.
*
* `permissionMode` maps to the `DSH_PERMISSION_MODE` env export, NOT to a flag —
* the harness has no command-line permission switch. An ABSENT config spawns the
* profile under the harness's own `workspace-write` default, which still asks
* for approval, so the multi-user clamp only needs the only-if-sent branch (like
* codex/antigravity/grok).
*
* `profile` is a directory name under `$DSH_HOME/profiles`, so it is constrained
* to a single path SEGMENT: no separators, no dots-only names. It is interpolated
* into the `bash -c "…"` spawn line and joined into a filesystem path, and this
* regex is what keeps both safe.
*/
const DeepSeekConfigSchema = z
. object ({
profile : z
. string ()
. min ( 1 )
. max ( 64 )
. regex ( /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/ )
. optional (),
permissionMode : z.enum ([ 'read-only' , 'workspace-write' , 'danger-full-access' ]). optional (),
resumeSession : z.boolean (). optional (),
resumeSessionId : z
. string ()
. max ( 100 )
. regex ( /^[a-zA-Z0-9._-]+$/ )
. optional (),
statusReporting : z.boolean (). optional (),
})
. optional ();
/**
* Body of POST /api/deepseek/install-profile.
*
* `package` is a package SPECIFIER handed to `dsh plugin … add`, which runs a
* real package-manager install, so it is the security-relevant field. Two things
* contain it: this regex (an npm name, optionally scoped, optionally with an
* `@version` tail, and NOTHING else — no path, no URL, no git spec, no leading
* dash that could be read as a flag), and the route, which spawns an argv ARRAY
* with no shell. The route additionally requires the privileged grant in
* multi-user mode: installing a plugin is arbitrary code execution on the host,
* the same bar as a `shell` session.
*/
export const DeepSeekInstallProfileSchema = z
. object ({
profile : z
. string ()
. min ( 1 )
. max ( 64 )
. regex ( /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/ )
. optional (),
package : z
. string ()
. min ( 1 )
. max ( 214 )
. regex ( /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:@[a-zA-Z0-9][a-zA-Z0-9._-]*)?$/ )
. optional (),
})
. strict ();
2026-08-25 03:08:15 +02:00
/**
* POST /api/deepseek/web: start the background `dsh web` for one browser authority.
*
* `authority` becomes `--trusted-host`, which is what dsh fences its own `/api`
* behind, so it must be the origin the browser will actually load the tab from
* (`location.host`). It reaches a spawn as one element of an argv ARRAY, never a
* shell string, so this regex is defence in depth rather than the only guard: it
* admits host:port in the shapes a browser authority can take (dotted names,
* IPv4, bracketed IPv6) and nothing that could be read as a second argument.
*/
export const DeepSeekWebStartSchema = z
. object ({
authority : z
. string ()
. min ( 1 )
. max ( 255 )
. regex ( /^(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)(?::\d{1,5})?$/ ),
})
. strict ();
2026-08-12 02:30:08 +02:00
/**
* The session that spawned the one being created — pure UI decoration, drawn as a
* lineage line between the two tabs. Accepted here and, equivalently, as the
* `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared
* curl invocation so every spawn recipe carries it); the body wins when both are
* present. `resolveParentSessionId()` in route-helpers.ts re-checks it against live
* sessions and DROPS anything it cannot resolve — a bad value must never fail a
* spawn, and this is never an ownership or permission signal.
*/
const parentSessionIdSchema = z . string (). max ( 100 ). optional ();
2026-10-03 18:06:08 -04:00
/**
* Tab group the new session should be placed in (the group's "New session"
* action). Advisory like parentSessionId: the layout service looks the id up in
* the creating owner's own layout and ignores one it cannot find, so a stale or
* foreign id never fails a spawn. A malformed value (null, empty, over-long) is
* still a 400, because only the browser sends it.
*/
const tabGroupIdSchema = z . string (). min ( 1 ). max ( 100 ). optional ();
2026-02-04 07:24:32 +01:00
export const CreateSessionSchema = z . object ({
2026-02-18 10:23:10 +01:00
workingDir : safePathSchema.optional (),
2026-09-02 08:26:45 +08:00
mode : sessionModeSchema (). optional (),
2026-02-04 07:24:32 +01:00
name : z.string (). max ( 100 ). optional (),
2026-08-12 02:30:08 +02:00
/** Session that spawned this one — see parentSessionIdSchema. */
parentSessionId : parentSessionIdSchema ,
2026-10-03 18:06:08 -04:00
/** Tab group to place the new session in: see tabGroupIdSchema. */
tabGroupId : tabGroupIdSchema ,
2026-02-18 10:23:10 +01:00
envOverrides : safeEnvOverridesSchema ,
2026-06-07 10:33:11 +08:00
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort : effortLevelSchema ,
2026-10-04 19:05:43 +02:00
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel : advisorModelSchema ,
2026-03-28 16:49:55 +01:00
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride : z.string (). max ( 50 ). optional (),
2026-10-01 16:59:45 +02:00
/**
* Claude model for THIS session only, passed as `claude --model <id>`; nothing is written to
2026-10-04 23:42:56 +02:00
* disk. Wins over the app-wide default model. A subset of the registry's `model-claude`
* pattern, so a value accepted here is never rejected at launch. The first character must be
* a letter or digit: the value lands in argv, and no model id opens with `-`, so a
* flag-shaped value is refused here rather than left to the launch quoting. An empty string
* means no per-session model, as it does for `modelOverride`. Claude only: the route refuses
* it for any other CLI and on a remote attach.
2026-10-01 16:59:45 +02:00
*/
model : z
. string ()
. max ( 100 )
2026-10-04 23:42:56 +02:00
. regex ( /^[a-zA-Z0-9][a-zA-Z0-9._\-[\]]*$/ )
2026-10-01 17:24:12 +02:00
. or ( z . literal ( '' ))
2026-10-01 16:59:45 +02:00
. optional (),
2026-02-26 10:16:20 +01:00
openCodeConfig : OpenCodeConfigSchema ,
2026-06-10 09:31:58 -04:00
codexConfig : CodexConfigSchema ,
2026-05-16 20:10:49 -04:00
geminiConfig : GeminiConfigSchema ,
2026-08-04 12:59:40 +02:00
antigravityConfig : AntigravityConfigSchema ,
2026-08-13 13:54:47 +02:00
piConfig : PiConfigSchema ,
2026-08-23 08:39:03 +02:00
grokConfig : GrokConfigSchema ,
2026-10-10 14:06:04 +08:00
copilotConfig : CopilotConfigSchema ,
2026-08-24 03:37:56 +02:00
deepSeekConfig : DeepSeekConfigSchema ,
2026-08-18 23:17:58 -05:00
ompConfig : OmpConfigSchema ,
2026-03-10 02:22:53 +08:00
/** 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 (),
2026-06-13 11:04:18 -04:00
/**
* 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 (),
2026-02-04 07:24:32 +01:00
});
/**
* 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 ),
2026-06-09 10:26:09 -04:00
viewportType : z.enum ([ 'mobile' , 'tablet' , 'desktop' ]). optional (),
2026-06-17 23:19:54 +08:00
force : z.boolean (). optional (),
2026-02-04 07:24:32 +01:00
});
2026-06-14 05:00:28 +02:00
/**
* 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.
*/
2026-06-14 07:33:52 +02:00
// 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.
2026-06-14 05:00:28 +02:00
const RateLimitWindowSchema = z
. object ({
2026-06-14 07:33:52 +02:00
used_percentage : z.number (). nullish (),
resets_at : z.number (). nullish (),
2026-06-14 05:00:28 +02:00
})
2026-06-14 07:33:52 +02:00
. nullish ();
2026-06-14 05:00:28 +02:00
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 ,
})
2026-06-14 07:33:52 +02:00
. nullish (),
2026-06-14 05:44:54 +02:00
context_window : z
. object ({
2026-06-14 07:33:52 +02:00
used_percentage : z.number (). nullish (),
total_input_tokens : z.number (). nullish (),
total_output_tokens : z.number (). nullish (),
2026-06-14 05:44:54 +02:00
})
2026-06-14 07:33:52 +02:00
. nullish (),
cost : z.object ({ total_cost_usd : z.number (). nullish () }). nullish (),
model : z.object ({ display_name : z.string (). max ( 100 ). nullish () }). nullish (),
2026-10-10 12:22:00 -04:00
// Main-conversation prompt-cache statistics (Claude Code v2.1.251+). Every documented
// scalar field is listed nullish: `expires_at`/`hit_ratio`/`last_miss_at`/
// `recache_tokens_if_cold` are all documented as possibly null, and the object gains
// fields over CLI versions, so a stricter shape would 400 the whole post and blank the
// footer (the recurring .optional()/null trap). Causes stay `unknown` (the parser keeps
// the strings); `last_miss_cause`'s extra numeric keys and the `miss_causes` record are
// stripped by Zod unread, which keeps the stored object bounded — a hostile localhost
// payload cannot bloat state, since only the normalized subset below is ever stored.
prompt_cache : z
. object ({
warm : z.boolean (). nullish (),
caching_observed : z.boolean (). nullish (),
// No length cap: a future TTL longer than today's `5m`/`1h` must not 400 the whole
// post and blank the footer. parsePromptCache discards any TTL it does not know, so
// the stored value stays bounded to those two regardless.
ttl : z.string (). nullish (),
expires_at : z.number (). nullish (),
requests : z.number (). nullish (),
misses : z.number (). nullish (),
expected_rebuilds : z.number (). nullish (),
hit_ratio : z.number (). nullish (),
cache_write_tokens : z.number (). nullish (),
miss_recache_tokens : z.number (). nullish (),
last_miss_at : z.number (). nullish (),
recache_tokens_if_cold : z.number (). nullish (),
2026-10-13 20:37:32 -04:00
// Keep the first 20 causes instead of 400-ing the whole post (which also drops the
// footer, the plan chip and the model report it carries) when Claude Code reports more. The
// preprocess slices BEFORE the array validates, so an over-long array costs one bounded
// slice, never per-element validation across every entry (parsePromptCache then keeps
// the string ones among those 20).
last_miss_cause : z
. object ({
causes : z.preprocess (( v ) => ( Array . isArray ( v ) ? v . slice ( 0 , 20 ) : v ), z . array ( z . unknown ()). nullish ()),
})
. nullish (),
2026-10-10 12:22:00 -04:00
})
. nullish (),
2026-06-14 05:00:28 +02:00
})
2026-06-14 07:33:52 +02:00
. nullish (),
2026-06-14 05:00:28 +02:00
});
2026-02-04 07:24:32 +01:00
// ========== Case Routes ==========
/**
* Schema for POST /api/cases
* Creates a new case folder.
*/
export const CreateCaseSchema = z . object ({
2026-02-27 13:16:23 +01:00
name : z
. string ()
. regex ( /^[a-zA-Z0-9_-]+$/ , 'Invalid case name format. Use only letters, numbers, hyphens, underscores.' ),
2026-02-04 07:24:32 +01:00
description : z.string (). max ( 1000 ). optional (),
2026-10-03 13:54:16 +08:00
/**
* Create the case in this folder instead of under the cases directory. Absolute, or starting with
* `~`. Only length-bounded here: what makes it acceptable (shape, blocked trees, symlinks, an
* existing folder with contents) is judged by `prepareNewCasePath()` in web/case-path.ts, which
* also produces the user-facing reason.
*/
path : z.string (). min ( 1 ). max ( 1000 ). optional (),
2026-02-04 07:24:32 +01:00
});
2026-08-09 16:33:01 +02:00
/**
* Schema for POST /api/cases/clone — issue #236.
*
* `repository` is only length-bounded here on purpose: what makes an operand safe
* is the transport/shape analysis in `parseGitRepositoryUrl` (which also produces
* the user-facing rejection reason), and duplicating a weaker version of that as a
* regex would be the copy that drifts. The route parses before touching git.
*/
export const CloneCaseSchema = z . object ({
name : z
. string ()
. regex ( /^[a-zA-Z0-9_-]+$/ , 'Invalid case name format. Use only letters, numbers, hyphens, underscores.' ),
repository : z.string (). min ( 1 ). max ( 2048 ),
/** Branch or tag → `--branch <ref> --single-branch`. */
ref : z.string (). min ( 1 ). max ( 200 ). optional (),
/** `--depth 1`. */
shallow : z.boolean (). optional (),
description : z.string (). max ( 1000 ). optional (),
2026-10-31 16:19:20 +08:00
/**
2026-10-31 17:57:51 +08:00
* Clone into this exact folder (the folder itself, no case-name subfolder is added) instead of under the cases directory, and
2026-10-31 16:19:20 +08:00
* register it as a linked case. Absolute or starting with `~`; only length-bounded here, since
* `prepareNewCasePath()` (web/case-path.ts) judges it and produces the user-facing reason.
*/
path : z.string (). min ( 1 ). max ( 1000 ). optional (),
2026-08-09 16:33:01 +02:00
});
/** Schema for POST /api/cases/clone-preflight — ask the remote what it has, clone nothing. */
export const ClonePreflightSchema = z . object ({
repository : z.string (). min ( 1 ). max ( 2048 ),
});
2026-10-14 04:42:51 +02:00
// ========== Git worktrees of a case (src/git-worktrees.ts) ==========
/**
* A worktree name: one segment of letters, digits, `.`, `_`, `-`, starting with a letter or
* digit, at most 64 characters (Claude Code's own `--worktree` limit). The finer rules (no `..`,
* no trailing `.lock`) are `worktreeNameError()`, which every route applies after this.
*/
const worktreeNameSchema = z
. string ()
. min ( 1 )
. max ( 64 )
. regex ( /^[A-Za-z0-9][A-Za-z0-9._-]*$/ , 'Invalid worktree name. Use letters, digits, ".", "_" and "-".' );
/** Schema for POST /api/cases/:name/worktrees: a new worktree on a new branch. */
export const CreateWorktreeSchema = z . object ({
name : worktreeNameSchema ,
/** New branch name; default `worktree-<name>`, Claude Code's convention. */
branch : z.string (). min ( 1 ). max ( 200 ). optional (),
/** Start point (branch, tag or commit); default the case's current HEAD. */
base : z.string (). min ( 1 ). max ( 200 ). optional (),
});
/** Query of DELETE /api/cases/:name/worktrees/:worktree. */
export const RemoveWorktreeQuerySchema = z . object ({
/** Also `git branch -d` the worktree's branch (never `-D`). */
deleteBranch : z.enum ([ 'true' , 'false' , '1' , '0' ]). optional (),
});
2026-06-08 10:40:56 +00:00
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 (),
2026-08-04 12:59:40 +02:00
antigravity : z.string (). min ( 1 ). max ( 300 ). optional (),
2026-08-13 13:54:47 +02:00
pi : z.string (). min ( 1 ). max ( 300 ). optional (),
2026-08-23 08:39:03 +02:00
grok : z.string (). min ( 1 ). max ( 300 ). optional (),
2026-08-24 03:37:56 +02:00
deepseek : z.string (). min ( 1 ). max ( 300 ). optional (),
2026-06-08 10:40:56 +00:00
})
. strict ()
. optional ();
2026-06-13 10:21:56 -04:00
// 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 ( '$(' );
2026-07-12 19:49:39 +02:00
// `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`$]*$/ ;
2026-06-08 10:40:56 +00:00
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 (),
2026-07-12 19:49:39 +02:00
// 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 (),
2026-06-13 10:21:56 -04:00
// 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 (),
2026-06-13 10:37:39 -04:00
// 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.
2026-06-13 10:21:56 -04:00
jumpHost : z
. string ()
. min ( 1 )
. max ( 255 )
2026-06-13 10:37:39 -04:00
. regex (
2026-07-09 12:18:21 -04:00
/^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.:[\]-]+(?::\d{1,5})?(?:,(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.:[\]-]+(?::\d{1,5})?)*$/ ,
2026-06-13 10:37:39 -04:00
'Jump host must be [user@]host[:port] (comma-separated for multiple hops)'
)
2026-06-13 10:21:56 -04:00
. 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 (),
2026-06-08 10:40:56 +00:00
commands : RemoteCommandOverridesSchema ,
2026-09-15 10:25:45 +02:00
// Wake-on-LAN: a single executable path (no arguments, no shell) run to power a
// SLEEPING host back on, e.g. `/home/joe/bin/whuff`. Executed via spawn without
// a shell, so there is no shell layer to escape; the regexes are belt-and-braces
// (and the no-whitespace rule rejects an argument list before it can fail as a
// confusing ENOENT at wake time). See docs/remote-sessions.md §Wake-on-LAN.
wakeCommand : z
. string ()
. min ( 1 )
. max ( 4096 )
. regex ( /^\S+$/ , 'Wake command must be a single executable path (no arguments)' )
. regex ( NO_SHELL_META , 'Invalid characters in wake command' )
. optional (),
2026-09-15 14:20:31 +02:00
// Wake-on-LAN MAC address(es), comma-separated. Structural: only hex pairs with
// `:`/`-` separators, so nothing here can be a shell token even by accident (the
// value never reaches a shell — Codeman builds the magic packet itself).
wakeMac : z
. string ()
. min ( 11 )
. max ( 128 )
. regex (
/^[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5}(\s*,\s*[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5})*$/ ,
'Wake MAC must be one or more MAC addresses, comma-separated'
)
2026-09-19 12:07:57 +02:00
// ⚠ The character cap admits seven MACs while parseMacList takes at most
// MAX_WAKE_MACS, all-or-nothing. Without this the extra ones validated, persisted,
// and then resolved to NO wake target, so the host read as unconfigured.
. refine (( value ) => value . split ( ',' ). length <= MAX_WAKE_MACS , {
message : `Wake MAC accepts at most ${ MAX_WAKE_MACS } comma-separated addresses` ,
})
2026-09-15 14:20:31 +02:00
. optional (),
2026-06-08 10:40:56 +00:00
});
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' ),
2026-07-12 19:49:39 +02:00
remotePath : z
. string ()
. min ( 1 )
. max ( 2000 )
. regex ( /^\// , 'Remote path must be absolute' )
. regex ( NO_SHELL_META , 'Invalid characters in remote path' ),
2026-06-08 10:40:56 +00:00
});
2026-07-19 15:09:48 +02:00
// ========== 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 (),
2026-07-19 21:35:38 +02:00
gpus : z
. string ()
. max ( 128 )
. regex ( /^(all|\d+|device=[a-zA-Z0-9,:._-]+)$/ , 'GPUs must be all / a count / device=...' )
. optional (),
2026-07-19 15:09:48 +02:00
mountCredentials : z.boolean (). optional (),
hooksEnabled : z.boolean (). optional (),
resumeOnStart : z.boolean (). optional (),
2026-08-06 07:21:57 +02:00
commands : RemoteCommandOverridesSchema , // same shell/claude/opencode/codex/gemini/antigravity shape
2026-07-19 15:09:48 +02:00
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' ),
2026-07-20 02:36:02 +02:00
// 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.
2026-07-19 15:09:48 +02:00
hostWorkspacePath : z
. string ()
. min ( 1 )
. max ( 2000 )
. regex ( /^\// , 'Workspace path must be absolute' )
2026-07-20 02:36:02 +02:00
. regex ( /^[^,]*$/ , 'Workspace path must not contain commas (docker --mount is comma-delimited)' )
2026-07-19 15:09:48 +02:00
. regex ( NO_SHELL_META , 'Invalid characters in workspace path' ),
containerWorkdir : z
. string ()
. min ( 1 )
. max ( 2000 )
. regex ( /^\// , 'Container workdir must be absolute' )
2026-07-20 02:36:02 +02:00
. regex ( /^[^,]*$/ , 'Container workdir must not contain commas (docker --mount is comma-delimited)' )
2026-07-19 15:09:48 +02:00
. 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 (),
});
2026-08-29 21:02:19 -07:00
/**
* ADOPT an already-running container the user built and runs themselves. The
* container name is REQUIRED (there is nothing to derive it from — we are not
* creating it), and `hostWorkspacePath` still points at real host bytes so the
* file routes, watchers and transcript correlation keep working exactly as they
* do for an owned case. Everything that only makes sense at container-create
* time (image, network, resources, gpus, credential mounts) is deliberately
* absent: adoption never runs `docker create`.
*/
export const DockerCaseAdoptSchema = 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' ),
container : z
. string ()
. min ( 2 )
. max ( 128 )
. regex ( /^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/ , 'Invalid container name' ),
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 (),
});
/** Read-only adoption preflight: report on an existing container, link nothing. */
export const DockerAdoptPreflightSchema = z . object ({
hostId : z.string (). regex ( /^[a-zA-Z0-9_-]+$/ , 'Invalid docker host id' ),
container : z
. string ()
. min ( 2 )
. max ( 128 )
. regex ( /^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/ , 'Invalid container name' ),
2026-08-29 21:02:19 -07:00
/** Optional: also verify this path exists INSIDE the container. */
containerWorkdir : z
. string ()
. min ( 1 )
. max ( 2000 )
. regex ( /^\// , 'Container workdir must be absolute' )
. regex ( NO_SHELL_META , 'Invalid characters in container workdir' )
. optional (),
2026-08-29 21:02:19 -07:00
});
2026-08-29 23:31:28 -07:00
/** Read-only directory listing inside a container (adoption workdir picker). */
export const DockerBrowseSchema = z . object ({
hostId : z.string (). regex ( /^[a-zA-Z0-9_-]+$/ , 'Invalid docker host id' ),
container : z
. string ()
. min ( 2 )
. max ( 128 )
. regex ( /^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/ , 'Invalid container name' ),
path : z
. string ()
. max ( 2000 )
. regex ( /^\// , 'Path must be absolute' )
. regex ( NO_SHELL_META , 'Invalid characters in path' )
. optional (),
});
2026-07-19 17:38:01 +02:00
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' )
2026-07-20 02:36:02 +02:00
. regex ( /^[^,]*$/ , 'Destination path must not contain commas (docker --mount is comma-delimited)' )
2026-07-19 17:38:01 +02:00
. regex ( NO_SHELL_META , 'Invalid characters in destination path' ),
});
2026-07-19 21:35:38 +02:00
// 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 (),
});
2026-02-04 07:24:32 +01:00
// ========== Quick Start ==========
/**
* Schema for POST /api/quick-start
* Creates case (if needed) and starts interactive session.
*/
export const QuickStartSchema = z . object ({
2026-02-27 13:16:23 +01:00
caseName : z
. string ()
. regex ( /^[a-zA-Z0-9_-]+$/ , 'Invalid case name format. Use only letters, numbers, hyphens, underscores.' )
. optional (),
2026-07-20 01:28:53 +02:00
/** 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 (),
2026-08-12 02:30:08 +02:00
/** Session that spawned this one — see parentSessionIdSchema. */
parentSessionId : parentSessionIdSchema ,
2026-10-03 18:06:08 -04:00
/** Tab group to place the new session in: see tabGroupIdSchema. */
tabGroupId : tabGroupIdSchema ,
2026-10-14 04:42:51 +02:00
/**
* Start the session in this git worktree of the case (`<case>/.claude/worktrees/<worktree>`,
* created through POST /api/cases/:name/worktrees) instead of the case folder. Local cases
* only: refused (400) for remote and Docker cases. The worktree must be a registered worktree
* of the case's repository; it is never created here.
*/
worktree : worktreeNameSchema.optional (),
2026-07-20 02:36:02 +02:00
/** 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 (),
2026-09-02 08:26:45 +08:00
mode : sessionModeSchema (). optional (),
2026-02-26 10:16:20 +01:00
openCodeConfig : OpenCodeConfigSchema ,
2026-06-10 09:31:58 -04:00
codexConfig : CodexConfigSchema ,
2026-05-16 20:10:49 -04:00
geminiConfig : GeminiConfigSchema ,
2026-08-04 12:59:40 +02:00
antigravityConfig : AntigravityConfigSchema ,
2026-08-13 13:54:47 +02:00
piConfig : PiConfigSchema ,
2026-08-23 08:39:03 +02:00
grokConfig : GrokConfigSchema ,
2026-10-10 14:06:04 +08:00
copilotConfig : CopilotConfigSchema ,
2026-08-24 03:37:56 +02:00
deepSeekConfig : DeepSeekConfigSchema ,
2026-08-18 23:17:58 -05:00
ompConfig : OmpConfigSchema ,
2026-04-22 00:01:35 +08:00
envOverrides : safeEnvOverridesSchema ,
2026-06-07 10:33:11 +08:00
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort : effortLevelSchema ,
2026-10-04 19:05:43 +02:00
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel : advisorModelSchema ,
2026-09-07 19:09:24 +02:00
/**
* Who is spawning this worker (`codeman-skill` from the packaged agent skill), or,
* equivalently, the `X-Codeman-Agent-Origin` header; the body wins when both are
* present. Used ONLY to label a case directory this request CREATES as an agent
* scratch workspace, so it can be found and cleaned up later — see
* `src/agent-case-marker.ts`. Never a permission signal, and an unrecognised token
* is dropped rather than rejected. `POST /api/sessions` has no equivalent field
* because it takes an existing `workingDir` and so never creates a directory to label.
*/
agentOrigin : z.string (). max ( 64 ). optional (),
2026-09-16 15:04:10 +08:00
/**
* Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): launches directly
* on this saved endpoint/model instead of the mode's native backend, computed server-side
* from the admin-configured endpoint store the same way `POST /api/sessions/:id/custom-
* model` does — never trusting raw env values from the client. One-shot, launch-time
* equivalent of that route: no restart, so no visible relaunch (that route's restart-in-
* place is still what an ALREADY-RUNNING session uses to switch later). Rejected for
2026-09-19 12:32:50 +02:00
* remote/docker cases, same reasoning as `envOverrides` above. The three confirmation
* flags mirror that route's fields; see `SessionCustomModelSchema` for why there are
* two specific ones rather than the single legacy `confirmed`.
2026-09-16 15:04:10 +08:00
*/
customModel : z
. object ({
endpointId : z.string (). regex ( /^[a-zA-Z0-9_-]+$/ , 'Invalid endpoint id' ),
modelId : z.string (). min ( 1 ). max ( 200 ),
confirmed : z.boolean (). optional (),
2026-09-19 12:32:50 +02:00
confirmedContext : z.boolean (). optional (),
confirmedSwap : z.boolean (). optional (),
2026-09-16 15:04:10 +08:00
})
. strict ()
. optional (),
2026-02-04 07:24:32 +01:00
});
// ========== Hook Events ==========
/**
* Schema for POST /api/hook-event
* Receives Claude Code hook events.
*/
export const HookEventSchema = z . object ({
2026-08-09 14:04:34 +02:00
event : z.enum ([
'permission_prompt' ,
'elicitation_dialog' ,
'elicitation_complete' ,
'elicitation_response' ,
'idle_prompt' ,
'stop' ,
'teammate_idle' ,
'task_completed' ,
2026-09-01 12:33:24 +08:00
// Claude Code's UserPromptSubmit: a first-hand report of the pane's live
// conversation id. Keep in step with HookEventType in types/api.ts.
'prompt_submitted' ,
2026-08-24 03:37:56 +02:00
// A turn STARTED. Unlike the others this one has no Claude Code hook behind
// it: it is reported by the DeepSeek Harness status shim, and exists so a
// dialog answered in the terminal resolves its Approvals Inbox item at once
// instead of lingering red until the next `stop`.
'agent_working' ,
2026-08-09 14:04:34 +02:00
]),
2026-02-04 07:24:32 +01:00
sessionId : z.string (). min ( 1 ),
2026-02-12 20:14:01 +01:00
data : z.record ( z . string (), z . unknown ()). nullable (). optional (),
2026-02-04 07:24:32 +01:00
});
2026-08-09 14:04:34 +02:00
/**
* Body of POST /api/approvals/:id/answer (Approvals Inbox).
* `option` digits are additionally validated against the item's PARSED options
2026-08-09 15:55:51 +02:00
* in the route; the schema alone must not authorize blind digit-poking.
2026-08-09 14:04:34 +02:00
*/
export const ApprovalAnswerSchema = z
. object ({
action : z.enum ([ 'approve' , 'deny' , 'option' , 'text' ]),
option : z.number (). int (). min ( 1 ). max ( 9 ). optional (),
text : z.string (). min ( 1 ). max ( 4000 ). optional (),
})
. strict ();
2026-08-09 18:03:13 +02:00
/**
* Body of PUT /api/sessions/:id/intent (Read My Mind). The 8192 cap mirrors
* MAX_GOALS_CHARS in intent-store.ts.
*/
export const IntentGoalsSchema = z
. object ({
goals : z.string (). max ( 8192 ),
})
. strict ();
2026-08-09 23:05:11 +02:00
/**
* Body of POST /api/sessions/:id/readmymind (Read My Mind predict). Both
* fields are the Rethink flow: `rejected` carries suggestions the user
* dismissed (strong negative signal, fed back verbatim), `steer` an optional
* free-text correction ("no, I meant the mobile bug").
*/
export const ReadMyMindPredictSchema = z
. object ({
steer : z.string (). max ( 2000 ). optional (),
rejected : z.array ( z . string (). max ( 1000 )). max ( 10 ). optional (),
})
. strict ();
2026-02-04 07:24:32 +01:00
// ========== 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 (),
2026-02-18 07:06:34 +01:00
autoAcceptPrompts : z.boolean (). optional (),
2026-02-04 07:24:32 +01:00
autoAcceptDelayMs : z.number (). int (). min ( 1000 ). max ( 60000 ). optional (),
2026-02-18 07:06:34 +01:00
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 (),
});
2026-02-04 07:24:32 +01:00
/**
* Schema for PUT /api/config
* Updates application configuration with whitelist of allowed fields.
*/
2026-02-27 13:16:23 +01:00
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 ();
2026-02-04 07:24:32 +01:00
/**
* Schema for PUT /api/settings
2026-02-27 01:13:58 +01:00
* Explicit allowlist of known settings fields — prevents arbitrary key persistence.
2026-02-04 07:24:32 +01:00
*/
2026-02-27 13:16:23 +01:00
const NotificationEventSchema = z
. object ({
enabled : z.boolean (). optional (),
browser : z.boolean (). optional (),
audio : z.boolean (). optional (),
push : z.boolean (). optional (),
})
. optional ();
2026-02-27 01:13:58 +01:00
2026-09-16 08:05:55 +02:00
/**
* Body of `POST /api/reboot-restore/restore`.
*
* `sessionIds` restores a subset, and omitting it restores everything the caller
* can see. The ids are session ids from `GET /api/reboot-restore`, and an id the
* caller does not own is ignored rather than refused, matching how the session
* list scopes rather than 403s.
*/
export const RebootRestoreRequestSchema = z
. object ({
sessionIds : z.array ( z . string (). max ( 128 )). max ( 200 ). optional (),
})
. strict ();
2026-02-27 13:16:23 +01:00
export const SettingsUpdateSchema = z
. object ({
2026-07-21 02:49:25 +08:00
// 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 (),
2026-02-27 13:16:23 +01:00
// 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 (),
2026-06-15 08:40:05 +02:00
/** Ultracode/Workflow run visualization (default OFF). Gates workflowRunWatcher + the master-detail tab. SYNCED. */
showUltracodeAgents : z.boolean (). optional (),
2026-06-15 16:33:54 +02:00
/** Floating ultracode run windows w/ tab connector lines (default OFF). Also starts workflowRunWatcher. SYNCED. */
ultracodeFloatingWindows : z.boolean (). optional (),
2026-02-27 13:16:23 +01:00
imageWatcherEnabled : z.boolean (). optional (),
2026-08-09 03:30:15 +02:00
/**
* 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 (),
2026-08-16 07:05:10 +02:00
/**
* Install Codeman's hooks block into the workspace of every Claude session,
* not only into cases Codeman scaffolded itself. SYNCED, default ON: without
* it a linked case or an existing repo runs with no hooks at all, and each
* hook-driven surface is silently dead there (tab alert, Approvals Inbox,
* push, respawn's definitive idle signals, the wait endpoints' stop/blocked).
* Turning it OFF restores the older, narrower behavior — a Codeman hooks
* block that is already present is still refreshed when stale, but one is
* never added — for a user who wants Codeman to leave their repos alone.
*/
workspaceHooksEnabled : z.boolean (). optional (),
2026-10-18 13:58:09 +08:00
/**
* Launch every Claude session with `--name` set to its tab name (default OFF: only a
* name the user chose is pinned). Nothing acts on it in `PUT /api/settings`: it is read
* fresh from disk by readPinCliSessionNameEnabled() (hooks-config.ts) at every claude
* create and respawn, like readPlanUsageTelemetryEnabled(), so a toggle applies from the
* next spawn with no restart.
*/
pinCliSessionName : z.boolean (). optional (),
2026-08-10 11:19:51 +02:00
/**
* Let browser dictation transcribe through this machine's Claude Code login,
* the same speech-to-text service the CLI's own `/voice` mode uses
* (docs/claude-voice-plan.md). SYNCED, default OFF: enabling it spends the
* operator's Claude subscription on transcription for anyone who can reach
* the UI, and routes microphone audio to Anthropic rather than to whichever
* provider was configured before. The Deepgram and Web Speech paths are
* untouched by this flag.
*/
claudeVoiceEnabled : z.boolean (). optional (),
2026-08-09 14:04:34 +02:00
/**
2026-08-09 16:03:27 +02:00
* Approvals Inbox (header bell + drawer, phone overview answer buttons,
* push Approve/Deny action buttons). SYNCED, default OFF (opt-in): even
* with items pending, no surface renders and push payloads carry no
* actions/approvalId until this is enabled. The server-side store and the
* answer endpoints run regardless, so flipping it ON shows anything
* already pending immediately.
2026-08-09 14:04:34 +02:00
*/
approvalsInboxEnabled : z.boolean (). optional (),
2026-09-15 17:59:16 +02:00
/**
* Auto-name sessions: a placeholder tab (`w3-case`) takes its first real
* prompt as a title (`w3-case: fix the login redirect`). Synced, default
* OFF: the prompt lands in mux-sessions.json, every session:updated
* broadcast and /api/search, which is the user's choice to make.
*/
autoNameSessions : z.boolean (). optional (),
2026-10-18 13:09:06 -04:00
/** Prompt-cache keep-warm default for sessions that have not chosen (Session Options overrides it). */
promptCacheKeepWarm : z.boolean (). optional (),
/** Hours after the last real prompt during which keep-alives are sent (default 2, DEFAULT_KEEP_WARM_MAX_HOURS). */
promptCacheKeepWarmMaxHours : z.number (). min ( 0.5 ). max ( 24 ). optional (),
2026-10-25 17:23:55 +08:00
/**
* Git fetch on an interval (src/git-fetch-scheduler.ts): `git fetch` for the repositories the Git panel
* lists, run on a timer instead of only on the panel's Fetch click. SYNCED, default OFF, because the
2026-10-27 09:11:19 +01:00
* server is what runs it: `git fetch` is the only git command the Git panel runs that writes (cloning
* and worktree management write too, but only when the user asked for them at the moment they asked), and
2026-10-25 22:31:25 +08:00
* the only one that can run later by itself, on a timer, and the only one that can run a repository's own
* hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config
* commands such as `credential.helper`), so nothing runs on a timer until the user turns this on. Cap in seconds between runs (30 to 3600, default 300). A
2026-10-25 20:04:01 +08:00
* session can override both in Session Options → Git; `null` there clears the override, and sessions
* sharing one folder are fetched by one run.
2026-10-25 17:23:55 +08:00
*/
gitFetchEnabled : z.boolean (). optional (),
gitFetchIntervalSeconds : z.number (). int (). min ( 30 ). max ( 3600 ). optional (),
2026-10-09 20:34:37 -04:00
/**
* Spawned tabs follow their parent: a session created by another one keeps
* following its parent's tab (into its group, moving with it) until moved by
* hand. SYNCED, default OFF, because the server does the placement:
* TabLayoutService.sessionCreated() reads it at every creation, and with it
* off the child lands after its parent once and is hand-placed from then on.
*/
spawnedTabsFollowParent : z.boolean (). optional (),
2026-08-09 18:03:13 +02:00
/**
* Read My Mind (docs/readmymind-plan.md): capture the user's submitted
* prompts into per-case intent profiles. SYNCED, default OFF (opt-in:
* captured prompts are sensitive). OFF stops capture immediately; already
* stored profiles stay until DELETE /api/sessions/:id/intent.
*/
readMyMindEnabled : z.boolean (). optional (),
2026-09-05 21:26:04 +08:00
/**
2026-09-14 23:45:42 +02:00
* Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): the toolbar picker that lets a
2026-09-05 21:26:04 +08:00
* session point at a user-configured custom OpenAI-compatible endpoint (local or
* cloud) instead of its native cloud backend. SYNCED, default OFF — endpoint entry,
* discovery, and the extra toolbar surface are all opt-in.
*/
customModelEndpointsEnabled : z.boolean (). optional (),
2026-09-24 07:48:26 +08:00
/**
* CLI management (docs/cli-enable-disable-plan.md): the Settings UI section that
* lets an admin enable/disable a stock CLI, trigger its install, and add/edit/
* remove custom CLI entries — all previously hand-edit-only via ~/.codeman/clis.json.
* SYNCED, default OFF: this is a machine-configuration surface (like Custom Model
* Endpoints), not a display preference, and enabling it is what makes the write
* endpoints (PUT/POST/DELETE /api/clis...) answer instead of refusing outright.
*/
cliManagementEnabled : z.boolean (). optional (),
2026-10-02 21:15:31 +08:00
/**
* MCP server sync (src/mcp-sync.ts): copies each enabled CLI's user-level MCP servers into
* the other CLIs' own config files. SYNCED, default OFF: it writes other tools' config in
* the server user's home (including any env values and headers on the servers), so it is
* opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden.
*/
mcpSyncEnabled : z.boolean (). optional (),
2026-10-11 12:15:21 +08:00
/**
* Config backups (src/config-backup.ts): timestamped snapshots of the customized config files,
* taken at startup and whenever their content changes. SYNCED. Absent means ON (the feature
* protects against an event nobody schedules); the folder defaults to `<data dir>/backups/config`.
*/
configBackupEnabled : z.boolean (). optional (),
/** Absolute folder (a leading `~` is expanded); empty or absent = the default folder. */
configBackupDir : z.string (). max ( 1024 ). optional (),
/** Keep at most this many snapshots. */
2026-10-18 22:31:08 +08:00
configBackupKeepCount : z.number (). int (). min ( 2 ). max ( 500 ). optional (),
2026-10-11 12:15:21 +08:00
/** Also drop snapshots older than this many days; 0 = no age limit. */
configBackupKeepDays : z.number (). int (). min ( 0 ). max ( 3650 ). optional (),
2026-10-15 09:53:17 +02:00
/**
* Agent inbox snapshot on disk (src/web/agent-inbox-persistence.ts). SYNCED, default OFF:
* off means no file is written, and switching it off deletes the snapshot (the in-memory
* inboxes stay). On, pending messages between agents survive a server restart.
*/
agentInboxPersistEnabled : z.boolean (). optional (),
2026-10-24 22:54:17 -04:00
/**
* Experimental agent coordination (src/web/routes/agent-session-routes.ts): bounded
* inspection and the guarded stage/submit routes behind `codeman agent inspect|stage|submit`.
* SYNCED, default OFF: the routes are labeled experimental and may change in a minor or
* patch release, so automation has to opt in. While OFF, all three routes answer 403.
*/
agentCoordinationEnabled : z.boolean (). optional (),
2026-08-09 23:05:11 +02:00
/**
* Read My Mind predictor model override. Empty/absent = the AI-checker
* default (opus: prediction quality is the product and it runs only on an
* explicit press). Shell-safety is validated again at spawn time.
*/
readMyMindModel : z.string (). max ( 100 ). optional (),
2026-02-27 13:16:23 +01:00
tunnelEnabled : z.boolean (). optional (),
2026-06-16 18:44:03 +02:00
// 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
2026-10-26 02:11:16 -04:00
// CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL opt-in. Stripped before persisting.
2026-06-16 18:44:03 +02:00
acknowledgeUnauthTunnel : z.boolean (). optional (),
2026-02-27 13:16:23 +01:00
tabTwoRows : z.boolean (). optional (),
2026-10-09 16:34:09 +02:00
/**
* CLI Logos on Tabs. Display key (per-device), default ON: only an explicit
* false hides the agent logo on session tabs and the desktop home rail
* (`html[data-tab-logos='off']`, a CSS-only switch). Tile and split headers
* and the Run menus keep their logos.
*/
showTabCliLogos : z.boolean (). optional (),
2026-08-23 12:21:02 -04:00
tabOrientation : z.enum ([ 'horizontal' , 'vertical' ]). optional (),
tabRailWidth : z.number (). int (). min ( 208 ). max ( 360 ). optional (),
2026-08-24 04:45:02 +02:00
tabRailDetail : z.enum ([ 'simple' , 'rich' ]). optional (),
2026-09-12 06:09:28 +02:00
/**
* Vertical rail row order. Display key (per-device).
* 'activity' = the home screens' order (CodemanSessionOrder): blocked on a
* human first, then running longest-first, then quiet
* most-recently-quiet first.
* 'manual' = the user's tab order, and the only value that leaves the
* rail drag-reorderable.
*/
tabRailSort : z.enum ([ 'activity' , 'manual' ]). optional (),
2026-10-05 01:57:25 +02:00
/**
2026-10-06 03:53:47 +02:00
* Tab layout, the arrangement of the tab list (Discussion #426). Display key
* (per-device).
2026-10-09 09:35:56 +02:00
* 'classic' = one flat list in tab order, as before. The default.
2026-10-06 03:53:47 +02:00
* 'state' = a row per state in the header strip (needs you, waiting,
* working, idle; option C), sections in the flat side rail and
2026-10-09 09:35:56 +02:00
* the sidebar. Opt-in.
2026-10-06 03:53:47 +02:00
* 'case' = one cluster per case (option A): a labelled box in the strip,
2026-10-09 09:35:56 +02:00
* a section in the side rail and the sidebar. Opt-in.
2026-10-06 03:53:47 +02:00
* 'ledger' = the flat list on an aligned column grid with a status bar
2026-10-09 09:35:56 +02:00
* per cell (option B). Header strip on desktop only. Opt-in.
2026-10-05 01:57:25 +02:00
*/
2026-10-06 03:53:47 +02:00
tabArrangement : z.enum ([ 'state' , 'case' , 'ledger' , 'classic' ]). optional (),
/**
* Which end the state groups start from when `tabArrangement` is 'state'.
* Display key (per-device). 'urgent-first' = needs you on top (the
* default); 'urgent-last' = the other way up, needs you in the bottom row.
*/
tabStateOrder : z.enum ([ 'urgent-first' , 'urgent-last' ]). optional (),
2026-08-18 16:26:34 +02:00
/**
* Session list layout. Display key (per-device).
* 'header' = horizontal tab strip
* 'sidebar' = collapsible left sidebar, one compact row per session
* 'sidebar-rich' = same sidebar, each row carrying the home screen's detail
* (created/idle/working stamps + status pill)
* Both sidebar values render the SAME docked column and set
* data-session-list="sidebar"; they differ only in row detail, which rides
* on data-sidebar-detail. See applySessionListLayout() in app.js.
*/
sessionListLayout : z.enum ([ 'header' , 'sidebar' , 'sidebar-rich' ]). optional (),
2026-08-23 12:21:02 -04:00
/** Session-name text size in vertical navigation. Display key (per-device). */
sessionSidebarFontSize : z.number (). int (). min ( 11 ). max ( 18 ). optional (),
2026-02-27 13:16:23 +01:00
agentTeamsEnabled : z.boolean (). optional (),
2026-06-10 16:47:05 +02:00
/** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */
claudeModel : z.string (). max ( 50 ). optional (),
2026-04-28 03:29:48 +02:00
opusContext1mEnabled : z.boolean (). optional (),
2026-06-13 14:26:06 -04:00
// 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 (),
2026-04-28 02:20:11 +02:00
thinkingEffort : z.string (). max ( 20 ). optional (),
2026-10-04 19:05:43 +02:00
/** Advisor model for new Claude sessions ('' = leave it to the CLI's own /advisor choice). */
claudeAdvisorModel : z
. string ()
. max ( 64 )
. refine (( value ) => value === '' || isAdvisorModel ( value ), { message : 'Invalid advisor model' })
. optional (),
2026-02-27 13:16:23 +01:00
// UI visibility
showFontControls : z.boolean (). optional (),
showSystemStats : z.boolean (). optional (),
2026-10-05 01:57:25 +02:00
/**
* How the header draws its WS / CPU / MEM / plan-usage cluster. Display key
* (per-device), desktop only (the cluster is hidden below 768px).
* 'classic' = the bars and the 5H · 7D chip, as before
2026-10-09 09:35:56 +02:00
* 'compact' = two pills (WS/CPU/MEM, the plan windows), a ring beside every value.
* The default.
* 'tiles' = label over value with a bar underneath, no icons
2026-10-05 01:57:25 +02:00
*/
headerStatsStyle : z.enum ([ 'classic' , 'compact' , 'tiles' ]). optional (),
2026-02-27 13:16:23 +01:00
showTokenCount : z.boolean (). optional (),
showCost : z.boolean (). optional (),
showLifecycleLog : z.boolean (). optional (),
2026-06-10 12:10:31 +02:00
showResponseViewer : z.boolean (). optional (),
2026-02-27 13:16:23 +01:00
showMonitor : z.boolean (). optional (),
showProjectInsights : z.boolean (). optional (),
showFileBrowser : z.boolean (). optional (),
showSubagents : z.boolean (). optional (),
2026-06-08 06:01:15 +02:00
showMultiMonitorButton : z.boolean (). optional (),
2026-09-07 20:37:29 -05:00
// Doubles as the plan-usage telemetry COLLECTION switch, read fresh from
// disk by readPlanUsageTelemetryEnabled() (hooks-config.ts) at every claude
// session create/respawn — not just the chip's DISPLAY preference. See that
2026-09-14 15:53:25 +02:00
// function's doc comment for why one persisted field serves both. Absent
// means ON there, and the client sends it only on a save that flips the
// chip (planUsageCollectionFlip in settings-ui.js), never on every save.
2026-06-14 05:00:28 +02:00
showPlanUsageLimits : z.boolean (). optional (),
2026-06-17 23:19:54 +08:00
showRedrawButton : z.boolean (). optional (),
2026-06-08 06:01:15 +02:00
// Input
gestureControlEnabled : z.boolean (). optional (),
2026-02-27 13:16:23 +01:00
// Claude CLI settings
claudeMode : z.string (). max ( 50 ). optional (),
allowedTools : z.string (). max ( 2000 ). optional (),
2026-06-10 09:31:58 -04:00
// Codex CLI settings
2026-10-07 17:34:25 +08:00
codexModel : z
. string ()
. max ( 100 )
. regex ( /^[a-zA-Z0-9._\-/]*$/ )
. optional (),
codexReasoningEffort : z.enum ([ '' , ... CODEX_REASONING_EFFORTS ]). optional (),
2026-06-10 09:31:58 -04:00
codexDangerouslyBypassApprovals : z.boolean (). optional (),
2026-07-29 03:39:07 +03:00
codexAnimationsEnabled : z.boolean (). optional (),
2026-06-29 17:49:39 -04:00
// 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 (),
2026-02-27 13:16:23 +01:00
// 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 (),
2026-10-10 08:52:38 +08:00
toastDurationMs : z.number (). optional (),
browserAutoCloseMs : z.number (). optional (),
2026-02-27 13:16:23 +01:00
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 ({
2026-08-10 11:19:51 +02:00
/** 'auto' | 'claude' | 'deepgram' | 'webspeech'. Unknown values fall back to auto client-side. */
provider : z.string (). max ( 20 ). optional (),
2026-02-27 13:16:23 +01:00
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 (),
2026-03-10 14:03:19 +08:00
// 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 (),
2026-02-27 13:16:23 +01:00
})
2026-06-29 17:49:39 -04:00
. 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' ,
});
}
});
2026-02-04 07:24:32 +01:00
/**
* Schema for POST /api/sessions/:id/input with length limit
*/
export const SessionInputWithLimitSchema = z . object ({
2026-09-24 18:17:06 +02:00
// One limit for both transports (issue #484): the route's own length check and
// ws-routes.ts read the same constant, so a schema cap above it only hid which
// check refused the input.
input : z.string (). max ( MAX_INPUT_LENGTH ),
2026-02-18 12:51:35 +01:00
useMux : z.boolean (). optional (),
2026-06-19 16:44:18 +02:00
// 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 (),
2026-08-09 01:18:03 +02:00
// 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 (),
2026-02-04 07:24:32 +01:00
});
2026-02-15 05:26:29 +01:00
2026-10-15 09:53:17 +02:00
// ========== Agent Inbox Routes ==========
/**
* Body for `POST /api/sessions/:id/inbox`. The byte cap (`MAX_MESSAGE_BYTES`) is the
* store's, checked there in UTF-8 bytes; this bound is only the outer fence so a
2026-10-15 12:17:45 +02:00
* multi-megabyte body never reaches it. It is 4× the byte cap in characters (a UTF-8
* character is at most 4 bytes), so every over-size message gets the store's answer,
* which tells the sender what to do instead. Multi-line text is fine: it is stored and
2026-10-15 09:53:17 +02:00
* handed back as data, never typed.
*/
export const InboxPostSchema = z
. object ({
2026-10-15 12:17:45 +02:00
text : z
. string ()
. min ( 1 , 'text must not be empty' )
2026-10-24 14:15:49 +02:00
. max ( 4 * MAX_MESSAGE_BYTES ),
2026-10-15 09:53:17 +02:00
})
. strict ();
/** Query for `GET /api/sessions/:id/inbox`. `wait` follows the wait routes' `timeout`: clamped, never a 400 for size. */
export const InboxReadQuerySchema = z . object ({
wait : z.coerce.number (). int (). positive (). optional (),
});
/** Body for `POST /api/sessions/:id/inbox/ack`: the ids a read handed out. */
export const InboxAckSchema = z
. object ({
ids : z.array ( z . string (). min ( 1 ). max ( 64 )). min ( 1 ). max ( 200 ),
})
. strict ();
2026-02-15 05:26:29 +01:00
// ========== 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 (),
2026-06-09 21:03:46 -04:00
maxTodos : z.number (). int (). positive (). max ( 10000 ). optional (),
todoExpirationMinutes : z.number (). int (). positive (). max ( 525600 ). optional (),
2026-03-01 17:17:05 +01:00
reset : z.union ([ z . boolean (), z . literal ( 'full' )]). optional (),
2026-02-15 05:26:29 +01:00
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 (),
});
2026-06-10 20:41:34 +02:00
/** POST /api/sessions/:id/auto-resume */
export const AutoResumeSchema = z . object ({
enabled : z.boolean (),
});
2026-10-19 01:42:00 -04:00
/** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */
export const KeepWarmSchema = z . object ({ enabled : z.boolean (). nullable () });
2026-10-25 17:23:55 +08:00
/**
* `POST /api/sessions/:id/git-fetch-settings`: `null` clears the session's choice so the app-wide default
* applies. Both fields are optional, so a row that saves on its own does not clear the other.
*/
export const GitFetchSettingsSchema = z . object ({
enabled : z.boolean (). nullable (). optional (),
intervalSeconds : z.number (). int (). min ( 30 ). max ( 3600 ). nullable (). optional (),
});
2026-06-20 12:18:07 -04:00
/** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */
export const PinSessionSchema = z . object ({
pinned : z.boolean (),
});
2026-02-15 05:26:29 +01:00
/** 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 ),
2026-02-18 10:23:10 +01:00
workingDir : safePathSchema.optional (),
2026-04-22 00:01:35 +08:00
envOverrides : safeEnvOverridesSchema ,
2026-02-15 05:26:29 +01:00
});
/** POST /api/scheduled */
export const ScheduledRunSchema = z . object ({
prompt : z.string (). min ( 1 ). max ( 100000 ),
2026-02-18 10:23:10 +01:00
workingDir : safePathSchema.optional (),
2026-02-15 05:26:29 +01:00
durationMinutes : z.number (). int (). min ( 1 ). max ( 14400 ). optional (),
});
2026-06-29 11:35:56 +05:30
// ========== Cron Jobs ==========
2026-06-27 08:10:40 +05:30
/** '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)' );
2026-10-21 08:03:55 +02:00
/** Prompt delivery is single-line only (the submit check cannot reliably recognise a multi-line paste) — reject newlines outright. */
2026-07-12 19:25:42 +02:00
const noNewlines = ( v : string ) => ! /[\r\n]/ . test ( v );
2026-06-27 08:10:40 +05:30
/** Shared field shape for creating/updating a scheduled job. */
2026-06-29 11:35:56 +05:30
const CronJobBaseSchema = z . object ({
2026-06-27 08:10:40 +05:30
name : z.string (). min ( 1 ). max ( 200 ),
2026-09-02 08:26:45 +08:00
agentType : sessionModeSchema (),
2026-06-27 08:10:40 +05:30
workingDir : safePathSchema ,
2026-07-12 19:25:42 +02:00
launchCommand : z.string (). max ( 2000 ). refine ( noNewlines , 'launchCommand must be a single line' ). optional (),
2026-06-27 08:10:40 +05:30
promptMode : z.enum ([ 'inline_text' , 'prompt_file_path' ]),
2026-07-12 19:25:42 +02:00
promptText : z
. string ()
. max ( 100000 )
. refine ( noNewlines , 'promptText must be a single line (multi-line prompts are not supported)' )
. optional (),
2026-06-27 08:10:40 +05:30
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' ]),
2026-07-12 19:25:42 +02:00
autoClosePreviousSession : z.boolean (). optional (),
2026-06-27 08:10:40 +05:30
});
/** Cross-field validation: required fields depend on promptMode + scheduleType. */
2026-06-29 11:35:56 +05:30
function refineCronJob ( val : z.infer < typeof CronJobBaseSchema >, ctx : z.RefinementCtx ) : void {
2026-06-27 08:10:40 +05:30
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' );
}
}
2026-06-29 11:35:56 +05:30
/** POST /api/cron/jobs — full job definition. */
export const CronJobSchema = CronJobBaseSchema . superRefine ( refineCronJob );
2026-06-27 08:10:40 +05:30
2026-06-29 11:35:56 +05:30
/** PUT /api/cron/jobs/:id — partial update. */
export const CronJobUpdateSchema = CronJobBaseSchema . partial ();
2026-06-27 08:10:40 +05:30
2026-06-29 11:35:56 +05:30
/** PUT /api/cron/jobs/:id/enabled */
export const CronJobEnabledSchema = z . object ({ enabled : z.boolean () });
2026-06-27 08:10:40 +05:30
2026-02-15 05:26:29 +01:00
/** POST /api/cases/link */
export const LinkCaseSchema = z . object ({
name : z.string (). regex ( /^[a-zA-Z0-9_-]+$/ , 'Invalid case name format' ),
2026-03-01 17:26:31 +01:00
path : safePathSchema ,
});
2026-10-21 14:27:30 +08:00
/** POST /api/cases/:name/rename */
export const RenameCaseSchema = z
. object ({
newName : z
. string ()
. min ( 1 )
. max ( 100 )
. regex ( /^[a-zA-Z0-9_-]+$/ , 'Invalid case name format. Use only letters, numbers, hyphens, underscores.' ),
/** Linked cases only: also rename the real folder on disk (a case under the cases directory always moves). */
renameFolder : z.boolean (). optional (),
/** Stop the live sessions that have the folder open, instead of refusing with the list of them. */
stopSessions : z.boolean (). optional (),
})
. strict ();
2026-04-03 09:45:19 +08:00
/** PUT /api/cases/order */
export const CaseOrderSchema = z . object ({
order : z.array ( z . string (). regex ( /^[a-zA-Z0-9_-]+$/ , 'Invalid case name format' )),
});
2026-10-22 03:42:42 -04:00
/**
* PUT /api/cases/:name/default-group — the tab group new sessions of this case
* land in: one of the caller's own group ids, or null for Ungrouped. Same bounds
* as tabGroupIdSchema; the route checks the id against the caller's layout.
*/
export const CaseDefaultGroupSchema = z . object ({
groupId : z.string (). min ( 1 ). max ( 100 ). nullable (),
});
2026-06-19 18:42:39 -04:00
/** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */
export const SessionOrderUpdateSchema = z . object ({
2026-07-20 14:31:44 +02:00
// Bounded defensively: ids are uuid-ish (<=100 chars) and the client pushes only
2026-10-21 08:59:29 +02:00
// its open-tab order (max sessions is 50 by default, at most 462 via CODEMAN_MAX_SESSIONS,
2026-10-21 08:01:24 +02:00
// see MAX_CONCURRENT_SESSIONS_CEILING) — 500 leaves enough room while
2026-07-20 14:31:44 +02:00
// keeping a hostile/buggy client from persisting megabytes into state.json.
order : z.array ( z . string (). max ( 100 )). max ( 500 ),
2026-06-19 18:42:39 -04:00
});
2026-03-01 17:26:31 +01:00
/** POST /api/auth/revoke */
export const RevokeSessionSchema = z . object ({
sessionToken : z.string (). min ( 1 ). max ( 200 ). optional (),
2026-02-15 05:26:29 +01:00
});
/** 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 (),
});
2026-02-18 07:06:34 +01:00
/** PUT /api/execution/model-config */
export const ModelConfigUpdateSchema = z . record ( z . string (), z . unknown ());
/** PUT /api/subagent-window-states */
2026-02-27 13:16:23 +01:00
export const SubagentWindowStatesSchema = z
. object ({
minimized : z.record ( z . string (), z . boolean ()). optional (),
open : z.array ( z . string ()). optional (),
})
. passthrough ();
2026-02-18 07:06:34 +01:00
/** PUT /api/subagent-parents */
export const SubagentParentMapSchema = z . record ( z . string (), z . string ());
2026-07-12 18:21:42 +02:00
/** 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 (),
});
2026-02-18 07:06:34 +01:00
/** 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 (),
});
2026-02-27 08:28:30 +01:00
// ========== Web Push ==========
/** POST /api/push/subscribe */
export const PushSubscribeSchema = z . object ({
2026-06-09 20:02:17 +02:00
endpoint : z
. string ()
. url ()
. max ( 2000 )
. refine ( isSafePushEndpoint , { message : 'endpoint must be an https URL to a public (non-internal) host' }),
2026-02-27 08:28:30 +01:00
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 ()),
});
2026-02-27 08:53:30 +01:00
2026-10-04 23:42:47 +02:00
/**
* PUT /api/webhook. `.strict()` like every settings-shaped schema; `url` is optional so a change of
* kind or scope never needs the secret re-sent, and an empty string clears it. The kind and scope
* lists are the store's own, so the schema can never accept a value the store would coerce away.
*/
export const WebhookUpdateSchema = z
. object ({
enabled : z.boolean (). optional (),
kind : z.enum ( WEBHOOK_KINDS ). optional (),
scope : z.enum ( WEBHOOK_SCOPES ). optional (),
url : z.string (). max ( 2048 ). optional (),
})
. strict ();
2026-10-29 21:04:18 +08:00
/**
* PUT /api/discord. `.strict()` and every field optional, so a change of one option never needs the
* token re-sent; an empty `token` clears it. Option lists are the bridge's own, so the schema cannot
* accept a value the store would coerce away. Ids are checked again (as snowflakes) by the route.
*/
export const DiscordUpdateSchema = z
. object ({
enabled : z.boolean (). optional (),
token : z.string (). max ( 200 ). optional (),
guildId : z.string (). max ( 25 ). optional (),
parentChannelId : z.string (). max ( 25 ). optional (),
allowedUserIds : z.array ( z . string (). max ( 25 )). max ( 20 ). optional (),
threadCreation : z.enum ( DISCORD_THREAD_CREATION ). optional (),
onSessionDelete : z.enum ( DISCORD_ON_DELETE ). optional (),
2026-10-30 18:26:35 +08:00
staleThreads : z.enum ( DISCORD_ON_DELETE ). optional (),
2026-10-29 21:04:18 +08:00
pingMode : z.enum ( DISCORD_PING_MODES ). optional (),
scope : z.enum ( WEBHOOK_SCOPES ). optional (),
2026-10-29 21:24:56 +08:00
startupHint : z.boolean (). optional (),
2026-10-30 01:38:05 +08:00
typedHint : z.boolean (). optional (),
2026-10-29 21:04:18 +08:00
})
. strict ();
/** POST /api/sessions/:id/discord-notify */
export const DiscordNotifySchema = z
. object ({
message : z.string (). min ( 1 ). max ( 1500 ),
urgent : z.boolean (). optional (),
2026-10-29 21:24:56 +08:00
/** Absolute path of a file inside the session's working folder, uploaded with the post. */
file : z.string (). min ( 1 ). max ( 1024 ). optional (),
2026-10-29 21:04:18 +08:00
})
. strict ();
2026-02-27 08:53:30 +01:00
// ========== Ralph Loop ==========
/** POST /api/ralph-loop/start */
export const RalphLoopStartSchema = z . object ({
2026-02-27 13:16:23 +01:00
caseName : z
. string ()
. regex ( /^[a-zA-Z0-9_-]+$/ , 'Invalid case name format' )
. optional ()
. default ( 'testcase' ),
2026-02-27 08:53:30 +01:00
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 ),
2026-04-22 00:01:35 +08:00
envOverrides : safeEnvOverridesSchema ,
2026-06-07 10:33:11 +08:00
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort : effortLevelSchema ,
2026-10-04 19:05:43 +02:00
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel : advisorModelSchema ,
2026-02-27 13:16:23 +01:00
planItems : z
. array (
z . object ({
content : z.string (),
priority : z.string (). optional (),
enabled : z.boolean (). default ( true ),
})
)
. optional (),
2026-02-27 08:53:30 +01:00
});
2026-02-28 17:39:44 +01:00
2026-03-21 07:20:18 +01:00
// ========== 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 ),
});
2026-06-13 19:03:02 -04:00
// ========== 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 (),
});
2026-07-27 17:06:36 +02:00
// ========== 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' ,
2026-09-04 15:21:12 +02:00
})
// Egress policy (`webview-egress-policy.ts`): no dashboard lives at a link-local
// or cloud-metadata address, while an IAM credential does. Refused at save time
// for the clear message; the proxy re-judges the RESOLVED address at connect time.
. refine (( url ) => ! isBlockedWebviewUrl ( url ), {
message :
'Blocked URL: link-local and cloud-metadata addresses (169.254.0.0/16, metadata.google.internal, ...) cannot be dashboards' ,
2026-07-27 17:06:36 +02:00
});
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 (),
2026-08-25 02:39:57 +02:00
/**
* Marks a record Codeman maintains itself. Declared here because a plain
* `z.object` STRIPS undeclared keys, so an undeclared marker would be dropped
* on the way in and the dedup it drives would never fire.
*/
managed : z.enum ([ 'deepseek-web' ]). optional (),
2026-07-27 17:06:36 +02:00
});
/** 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 });
2026-09-14 23:45:42 +02:00
2026-11-02 17:04:12 +08:00
/**
* Ceiling on a stated context length (tokens), shared between the bounds below and
* discovery (`custom-model-routes.ts`): anything above it reads as nonsense and is
* ignored, because a bogus figure here overflows the smaller server, which is the exact
* failure the discovery field exists to prevent.
*/
export const MAX_DISCOVERED_CONTEXT_TOKENS = 100 _000_000 ;
2026-09-14 23:45:42 +02:00
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — a
// user-configured custom OpenAI-compatible endpoint, local (llama.cpp) or cloud
// (Azure AI Foundry, etc.). Lives below `webviewUrlSchema` because `baseUrl` IS that
// schema: http(s) only, a real hostname, no embedded credentials, and the link-local /
// cloud-metadata refusal, the same bar a saved dashboard URL has to clear.
export const CustomModelHostSchema = z . object ({
id : z.string (). regex ( /^[a-zA-Z0-9_-]+$/ , 'Invalid endpoint id' ),
label : z.string (). min ( 1 ). max ( 100 ),
baseUrl : webviewUrlSchema ,
apiKey : z.string (). max ( 4096 ). optional (),
// No 'both': live-tested against a real server, sending both auth header
// conventions on one request reliably HANGS it — see custom-model-hosts.ts.
authStyle : z.enum ([ 'bearer' , 'api-key' ]). optional (),
models : z.array ( z . string (). max ( 200 )). max ( 200 ). optional (),
lastDiscoveredAt : z.string (). max ( 64 ). optional (),
2026-09-15 10:12:31 +08:00
// The Run-menu picker's per-endpoint default; validated against `models` at the
// route layer (schema-level cross-field checks can't see the array narrowed the
// same way a `.refine()` closure could, and the route already re-reads the stored
// host to apply it, so the check belongs there once, not duplicated into a refine
// that would run on every unrelated field edit too).
defaultModelId : z.string (). max ( 200 ). optional (),
2026-09-16 12:08:26 +08:00
// Server-populated by discovery (custom-model-routes.ts); accepted here only so a client
// round-tripping the GET response back through PUT (edit-save) doesn't drop it.
2026-11-02 17:04:12 +08:00
modelContextLengths : z
. record ( z . string (). max ( 200 ), z . number (). int (). positive (). max ( MAX_DISCOVERED_CONTEXT_TOKENS ))
. optional (),
2026-09-16 19:58:46 +08:00
// Same reasoning as modelContextLengths above.
2026-10-22 19:32:24 +02:00
modelSlotCounts : z.record ( z . string (). max ( 200 ), z . number (). int (). positive (). max ( 4096 )). optional (),
// Same reasoning as modelContextLengths above.
2026-09-16 19:58:46 +08:00
modelSizesGB : z.record ( z . string (). max ( 200 ), z . number (). positive (). max ( 100 _000 )). optional (),
2026-11-01 19:04:29 +08:00
// User-set fallback for models discovery has no context length for (session-routes.ts);
// discovery never writes it. Same bound as the modelContextLengths values above.
2026-11-02 17:04:12 +08:00
defaultContextLength : z.number (). int (). positive (). max ( MAX_DISCOVERED_CONTEXT_TOKENS ). optional (),
2026-09-14 23:45:42 +02:00
});
2026-09-24 07:48:26 +08:00
/**
* A shell-safe bare word, mirroring `config/cli-registry/schema.ts`'s own `shellToken` —
* duplicated rather than imported, since the REAL safety boundary for anything built from
* this is `CliEntrySchema` itself, re-applied server-side once the full entry is assembled
* (`cli-registry-routes.ts`). This is a request-shape sanity check, not the security gate.
*/
const cliShellToken = z
. string ()
. min ( 1 )
. max ( 256 )
. regex ( /^[A-Za-z0-9._:@=+/,-]+$/ , 'must be a plain word with no shell metacharacters' );
/** PUT /api/clis/:id (Phase 3) — enable/disable an existing entry, stock or custom; `enabled` is the ONLY thing this endpoint can flip. */
export const CliEnableSchema = z . object ({ enabled : z.boolean () });
/**
* POST /api/clis + PUT /api/clis/custom/:id (Phase 5) — a deliberately MINIMAL custom-CLI
* shape (docs/cli-enable-disable-plan.md, Phase 6 checklist: "scope the FIRST version to the
* fields most stock entries actually use"), not the full `CliEntry`. `cli-registry-routes.ts`
* assembles the rest with safe, conservative capability defaults and re-validates the whole
* thing through `CliEntrySchema` before ever writing it — this schema exists to bound the
* REQUEST shape, not to BE the safety layer (Decision 3: typed-argv only, no raw shell text).
*/
export const CliCustomEntrySchema = z . object ({
id : z.string (). regex ( /^[a-z][a-z0-9-]{0,23}$/ , 'id must be lowercase, start with a letter, at most 24 chars' ),
label : z.string (). min ( 1 ). max ( 60 ),
shortBadge : z.string (). min ( 1 ). max ( 6 ),
enabled : z.boolean (). optional (),
binaries : z.array ( cliShellToken ). min ( 1 ). max ( 4 ),
/** Bare argv tokens for the single launch variant — no flags-with-values, no params. */
argv : z.array ( cliShellToken ). min ( 1 ). max ( 16 ),
});
2026-09-14 23:45:42 +02:00
/** POST /api/sessions/:id/custom-model — apply or clear a session's custom-model selection. */
export const CustomModelSelectionSchema = z . union ([
z . object ({
endpointId : z.string (). regex ( /^[a-zA-Z0-9_-]+$/ , 'Invalid endpoint id' ),
modelId : z.string (). min ( 1 ). max ( 200 ),
2026-09-19 12:32:50 +02:00
/**
* Two DIFFERENT questions can block a launch, and answering one is not consent to
* the other: `confirmedContext` answers "this model's context window is below the
* floor for this CLI", which affects only the caller, while `confirmedSwap` answers
* "loading this will unload the model another session is using", which affects
* someone else. They were one flag until the context check (which runs first)
* silently spent the swap answer too, so a user clicking "launch anyway" past a
* too-small context evicted another session's model without ever being asked.
*
* `confirmed` is the original single flag and still means BOTH, because it shipped
* in the HTTP-API-only cut of this feature and an existing caller must keep working.
* New callers should send the specific one they actually asked about.
*/
2026-09-16 14:34:47 +08:00
confirmed : z.boolean (). optional (),
2026-09-19 12:32:50 +02:00
confirmedContext : z.boolean (). optional (),
confirmedSwap : z.boolean (). optional (),
2026-09-14 23:45:42 +02:00
}),
z . object ({ clear : z.literal ( true ) }),
]);
2026-10-23 14:15:14 -04:00
/** Agent inspection bounds are bytes of evidence, independent of the JSON envelope. */
export const AgentInspectQuerySchema = z
. object ({
maxBytes : z.coerce.number (). int (). min ( 1 ). max ( 65536 ). default ( 8192 ),
since : z.string (). uuid (). optional (),
})
. strict ();
export const AgentStageSchema = z . object ({ message : z.string (). min ( 1 ). max ( 8192 ) }). strict ();
export const AgentSubmitSchema = z
. object ({ stageId : z.string (). uuid (), confirmSha : z.string (). regex ( /^[a-f0-9]{64}$/ ) })
. strict ();