fix(review): harden cron security, session lifecycle, skip policy (PR #141)

- Reject multi-line prompts end-to-end: schema refines on promptText/
  launchCommand, runtime check in resolvePrompt (prompt-file content;
  trailing newlines tolerated), matching cron-ui form validation — delivery
  is single-line only, so multi-line was silently corrupted (typed mode
  fused lines, paste mode submitted partials)
- Close the workingDir confinement bypass (arbitrary server-side file read,
  e.g. workingDir=/proc + /proc/self/environ): realpath-resolve workingDir
  before the containment check, reject '/' and blocked/pseudo-fs trees
  (/proc, /sys, /dev + the attachment-guard blocklist) at fire time AND at
  job create/update (workingDir must exist and be a directory)
- Session lifecycle: new per-job autoClosePreviousSession (default true,
  recurring schedules only; ignored for 'once') — the previous run's
  still-open session is closed via the normal cleanupSession path when the
  next run fires; UI switch added; 50-session cap math documented in
  docs/cron-guide.md §8
- skip_if_same_agent_running: count only live sessions (exclude
  stopped/error dead tabs), exclude sessions created by this job's own runs
  (fixes the fire-once-then-skip-forever self-deadlock), and a skipped
  'once' job stays armed and retries next tick instead of being consumed;
  liveness filter mirrored in cron-ui _countActiveAgents
- Wire launchCommand (was accepted+documented but dead): shell mode sends
  it via writeViaMux as the first input line after startShell readiness
  (single-line, schema-enforced); form field shown for shell agent type
- Record delivery failures: a false writeViaMux result now fails the run
  instead of recording a false 'prompt_sent'
- Cap saved jobs at MAX_CRON_JOBS (100) to bound state.json growth
- Surface field-specific schema messages (drop parseBody custom
  errorMessage on cron create/update)
- Tests: workingDir create/update validation, /proc bypass regression,
  single-line enforcement (schema+runtime+trailing-newline tolerance),
  live/own-session skip filtering, once-skip re-arm, auto-close on/off/once,
  job-count cap

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-12 19:25:42 +02:00
parent e77af21107
commit 84ab4ff07b
11 changed files with 599 additions and 139 deletions
+6
View File
@@ -50,6 +50,12 @@ export const MAX_TODOS_PER_SESSION = 500;
*/
export const MAX_CRON_RUN_HISTORY = 500;
/**
* Maximum saved cron jobs. Jobs persist to state.json, so an unbounded count
* would grow it without limit; creation past the cap is rejected with 400.
*/
export const MAX_CRON_JOBS = 100;
// ============================================================================
// Pending Tool Calls Limits
// ============================================================================
+2
View File
@@ -27,4 +27,6 @@ export interface CronJobInput {
enabled: boolean;
notes?: string;
concurrencyPolicy: ConcurrencyPolicy;
/** Default true. Ignored for 'once' schedules. */
autoClosePreviousSession?: boolean;
}
+167 -19
View File
@@ -14,9 +14,13 @@ import { Session } from '../session.js';
import { SseEvent } from '../web/sse-events.js';
import { CronJobSchema } from '../web/schemas.js';
import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api.js';
import { MAX_CONCURRENT_SESSIONS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js';
import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js';
import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../config/attachment-guard.js';
import {
DEFAULT_BLOCKED_TREES,
isBlockedAttachmentPath,
loadAttachmentGuardConfig,
} from '../config/attachment-guard.js';
import { validateSessionFilePath } from '../web/route-helpers.js';
import { computeNextRunAt, dueKeyFor } from './cron-time.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../web/ports/index.js';
@@ -31,6 +35,20 @@ const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
/** Hard ceiling on a prompt-file read (defends against unbounded-read DoS). */
const MAX_PROMPT_FILE_BYTES = 1024 * 1024;
/**
* Pseudo-filesystem trees a cron job may never touch, ON TOP of the shared
* attachment blocklist. `/proc` in particular defeats the workingDir
* confinement trick (`workingDir: '/proc'` + `promptFilePath:
* '/proc/self/environ'` would read the SERVER's own environment).
*/
const CRON_PSEUDO_FS_TREES: readonly string[] = ['/proc', '/sys', '/dev'];
/** Sync blocklist for the create/update workingDir gate (no settings extras). */
const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TREES, ...CRON_PSEUDO_FS_TREES];
/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */
const HAS_NEWLINE = /[\r\n]/;
/** Order-insensitive equality for the weekly-days arrays. */
function sameDays(a: number[] | undefined, b: number[] | undefined): boolean {
const x = [...(a ?? [])].sort((p, q) => p - q);
@@ -61,16 +79,40 @@ export class CronService {
return filtered.sort((a, b) => b.startedAt - a.startedAt);
}
/** Number of active sessions of a given agent type (for the multi-session warning). */
countActiveAgents(agentType: string): number {
/**
* Number of LIVE sessions of a given agent type (for the multi-session
* warning and the skip_if_same_agent_running policy). Sessions whose CLI has
* exited (`stopped`/`error` — the tab is still open but nothing is running)
* don't count. When `excludeJobId` is given, sessions created by that job's
* own runs are also excluded — otherwise a recurring job with the skip
* policy would deadlock on its own previous (never-closed) session and fire
* exactly once, forever skipping after that.
*/
countActiveAgents(agentType: string, excludeJobId?: string): number {
const ownSessionIds = excludeJobId
? new Set(
this.listRuns(excludeJobId)
.map((r) => r.sessionId)
.filter((id): id is string => id !== null)
)
: null;
let n = 0;
for (const s of this.deps.sessions.values()) if (s.mode === agentType) n++;
for (const [id, s] of this.deps.sessions.entries()) {
if (s.mode !== agentType) continue;
if (s.status === 'stopped' || s.status === 'error') continue;
if (ownSessionIds?.has(id)) continue;
n++;
}
return n;
}
// ──────────────────────────── Mutations ───────────────────────────
createJob(input: CronJobInput): CronJob {
if (Object.keys(this.store.getCronJobs()).length >= MAX_CRON_JOBS) {
throw this.badRequest(`Maximum number of cron jobs (${MAX_CRON_JOBS}) reached`);
}
this.assertValidWorkingDir(input.workingDir);
const now = Date.now();
const job: CronJob = {
id: uuidv4(),
@@ -91,6 +133,7 @@ export class CronService {
enabled: input.enabled,
notes: input.notes,
concurrencyPolicy: input.concurrencyPolicy,
autoClosePreviousSession: input.autoClosePreviousSession ?? true,
createdAt: now,
updatedAt: now,
lastRunAt: null,
@@ -141,12 +184,9 @@ export class CronService {
// (e.g. switching to `once` without a `runAt` → a dead `nextRunAt:null`).
const check = CronJobSchema.safeParse(updated);
if (!check.success) {
const msg = check.error.issues[0]?.message ?? 'Invalid cron job update';
throw Object.assign(new Error(msg), {
statusCode: 400,
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, msg),
});
throw this.badRequest(check.error.issues[0]?.message ?? 'Invalid cron job update');
}
if (patch.workingDir !== undefined) this.assertValidWorkingDir(patch.workingDir);
updated.nextRunAt = updated.enabled ? computeNextRunAt(updated, now) : null;
this.store.setCronJob(updated.id, updated);
@@ -199,12 +239,21 @@ export class CronService {
continue;
}
// Optional concurrency policy for AUTOMATIC runs.
if (job.concurrencyPolicy === 'skip_if_same_agent_running' && this.countActiveAgents(job.agentType) > 0) {
job.lastDueKey = key;
// Optional concurrency policy for AUTOMATIC runs. Only LIVE sessions
// block, and this job's own previous sessions never do (see
// countActiveAgents) — otherwise a recurring job would deadlock on the
// session it created last time.
if (job.concurrencyPolicy === 'skip_if_same_agent_running' && this.countActiveAgents(job.agentType, job.id) > 0) {
// Record the skip so the job's run history isn't silently empty when it
// keeps getting skipped (otherwise it looks like the job never ran).
this.recordSkippedRun(job);
if (job.scheduleType === 'once') {
// A skipped one-time job is NOT consumed: leave nextRunAt armed (and
// the due key unconsumed) so the next tick retries once the blocking
// session goes away.
continue;
}
job.lastDueKey = key;
this.advanceAfterFire(job, now);
continue;
}
@@ -279,6 +328,14 @@ export class CronService {
return this.failRun(job, run, 'workingDir does not exist');
}
// Recurring jobs: close the still-open session created by this job's
// previous run before launching the next (default ON, opt-out via
// autoClosePreviousSession:false) — otherwise an unattended interval/daily
// job accumulates a new tab per fire until the global session cap.
if (job.scheduleType !== 'once' && job.autoClosePreviousSession !== false) {
await this.closePreviousRunSessions(job, run.id);
}
// Respect the global session cap.
if (this.deps.sessions.size >= MAX_CONCURRENT_SESSIONS) {
return this.failRun(job, run, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`);
@@ -331,13 +388,31 @@ export class CronService {
return run;
}
/**
* Resolves the prompt text and enforces the single-line constraint: prompt
* delivery rides writeViaMux/PTY writes where a newline is Enter, so a
* multi-line prompt would be silently corrupted (typed mode fuses lines,
* paste mode submits the first line and dribbles the rest in as separate
* messages). Rather than mangle an unattended agent's instructions, fail the
* run with a clear error. A prompt FILE may end with trailing newline(s)
* (every editor writes one) — those are stripped before the check.
*/
private async resolvePrompt(job: CronJob): Promise<string> {
if (job.promptMode === 'prompt_file_path') {
if (!job.promptFilePath) throw new Error('prompt file path is empty');
const safePath = await this.resolveSafePromptPath(job.promptFilePath, job.workingDir);
return readFile(safePath, 'utf-8');
const content = (await readFile(safePath, 'utf-8')).replace(/[\r\n]+$/, '');
if (HAS_NEWLINE.test(content)) {
throw new Error('prompt file must contain a single line — multi-line prompts are not supported');
}
return content;
}
return job.promptText ?? '';
const text = job.promptText ?? '';
if (HAS_NEWLINE.test(text)) {
// Schema-rejected since this check was added; guards legacy persisted jobs.
throw new Error('promptText must be a single line — multi-line prompts are not supported');
}
return text;
}
/**
@@ -364,14 +439,29 @@ export class CronService {
throw new Error('prompt file path could not be resolved');
}
// Defense-in-depth blocklist (secret locations, /etc, /root).
// workingDir is USER-CONTROLLED, so it is not a trust boundary by itself:
// realpath-resolve it (a symlinked workspace must not defeat containment)
// and reject blocked/pseudo-fs trees — otherwise workingDir '/proc' would
// make '/proc/self/environ' pass the containment check below.
let realWorkingDir: string;
try {
realWorkingDir = realpathSync(workingDir);
} catch {
throw new Error('job working directory could not be resolved');
}
const guard = await loadAttachmentGuardConfig();
if (isBlockedAttachmentPath(resolved, guard.blockedTrees)) {
const blockedTrees = [...guard.blockedTrees, ...CRON_PSEUDO_FS_TREES];
if (realWorkingDir === '/' || isBlockedAttachmentPath(realWorkingDir, blockedTrees)) {
throw new Error('job working directory is blocked');
}
// Defense-in-depth blocklist (secret locations, /etc, /root, pseudo-fs).
if (isBlockedAttachmentPath(resolved, blockedTrees)) {
throw new Error('prompt file path is blocked');
}
// Primary gate: the prompt file must live inside the job's workspace.
if (!validateSessionFilePath(workingDir, resolved)) {
if (!validateSessionFilePath(realWorkingDir, resolved)) {
throw new Error('prompt file path must be inside the job working directory');
}
@@ -402,15 +492,33 @@ export class CronService {
await delay(CRON_READY_SETTLE_MS);
} else {
await delay(1000);
// Shell mode: deliver the optional custom launch command as the
// first input line (single-line, schema-enforced), then give it a
// moment to start before the prompt follows.
if (job.launchCommand) {
const shell = this.deps.sessions.get(sessionId);
if (!shell) return;
const sent = await shell.writeViaMux(`${job.launchCommand}\r`);
if (!sent) {
this.failRun(job, run, 'Failed to send launch command: mux write failed');
return;
}
await delay(1000);
}
}
const s = this.deps.sessions.get(sessionId);
if (!s) return;
try {
const payload = prompt.endsWith('\r') ? prompt : `${prompt}\r`;
let delivered = true;
if (job.inputMode === 'paste') {
s.write(payload);
} else {
await s.writeViaMux(payload);
delivered = await s.writeViaMux(payload);
}
if (!delivered) {
this.failRun(job, run, 'Failed to send prompt: mux write failed');
return;
}
run.status = 'prompt_sent';
run.finishedAt = Date.now();
@@ -425,6 +533,46 @@ export class CronService {
});
}
/** 400-shaped error for route handlers (mirrors parseBody's error contract). */
private badRequest(msg: string): Error {
return Object.assign(new Error(msg), {
statusCode: 400,
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, msg),
});
}
/**
* Create/update gate for a job's workingDir: must exist, be a directory, and
* not resolve into a blocked or pseudo-filesystem tree (nor the fs root).
* The user-supplied workingDir doubles as the prompt-file confinement root,
* so an unrestricted value would defeat that boundary (e.g. '/proc').
*/
private assertValidWorkingDir(workingDir: string): void {
let real: string;
try {
real = realpathSync(workingDir);
} catch {
throw this.badRequest('workingDir does not exist');
}
if (!statSync(real).isDirectory()) throw this.badRequest('workingDir is not a directory');
if (real === '/' || isBlockedAttachmentPath(real, CRON_WORKING_DIR_BLOCKED_TREES)) {
throw this.badRequest('workingDir is not allowed (blocked or pseudo-filesystem tree)');
}
}
/** Close still-open sessions created by this job's previous runs (normal cleanup path). */
private async closePreviousRunSessions(job: CronJob, currentRunId: string): Promise<void> {
for (const prev of this.listRuns(job.id)) {
if (prev.id === currentRunId || !prev.sessionId) continue;
if (!this.deps.sessions.has(prev.sessionId)) continue;
try {
await this.deps.cleanupSession(prev.sessionId, true, 'cron: superseded by the next run of this job');
} catch (err) {
console.error(`[cron] failed to auto-close previous session ${prev.sessionId}:`, getErrorMessage(err));
}
}
}
private failRun(job: CronJob, run: CronJobRun, message: string): CronJobRun {
run.status = 'failed';
run.errorMessage = message;
+7
View File
@@ -63,6 +63,13 @@ export interface CronJob {
notes?: string;
/** Applies to automatic (scheduled) runs only. Manual Run Now always warns client-side. */
concurrencyPolicy: ConcurrencyPolicy;
/**
* Close the still-open session created by this job's previous run before the
* next run launches (via the normal session-cleanup path), so unattended
* recurring jobs don't accumulate tabs until the global session cap.
* Default true. Ignored for 'once' schedules.
*/
autoClosePreviousSession?: boolean;
// ── Bookkeeping (server-maintained) ─────────────────────────────────────
createdAt: number;
+27 -3
View File
@@ -126,6 +126,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('schName').value = job ? job.name || '' : '';
document.getElementById('schAgentType').value = job ? job.agentType || 'claude' : 'claude';
document.getElementById('schWorkingDir').value = job ? job.workingDir || '' : '';
document.getElementById('schLaunchCommand').value = job ? job.launchCommand || '' : '';
document.getElementById('schPromptMode').value = job ? job.promptMode || 'inline_text' : 'inline_text';
document.getElementById('schPromptText').value = job ? job.promptText || '' : '';
document.getElementById('schPromptFilePath').value = job ? job.promptFilePath || '' : '';
@@ -140,9 +141,11 @@ Object.assign(CodemanApp.prototype, {
cb.checked = weekly.includes(Number(cb.value));
});
document.getElementById('schConcurrencyPolicy').value = job ? job.concurrencyPolicy || 'warn_only' : 'warn_only';
document.getElementById('schAutoClosePrev').checked = job ? job.autoClosePreviousSession !== false : true;
document.getElementById('schEnabled').checked = job ? !!job.enabled : true;
document.getElementById('schNotes').value = job ? job.notes || '' : '';
this.onCronAgentTypeChange();
this.onCronPromptModeChange();
this.onCronScheduleTypeChange();
form.classList.remove('hidden');
@@ -158,6 +161,12 @@ Object.assign(CodemanApp.prototype, {
if (form) form.classList.add('hidden');
},
onCronAgentTypeChange() {
// Launch command is only meaningful for shell mode (first input line).
const isShell = document.getElementById('schAgentType').value === 'shell';
document.getElementById('schLaunchCommandRow').classList.toggle('hidden', !isShell);
},
onCronPromptModeChange() {
const mode = document.getElementById('schPromptMode').value;
document.getElementById('schPromptTextRow').classList.toggle('hidden', mode !== 'inline_text');
@@ -191,11 +200,18 @@ Object.assign(CodemanApp.prototype, {
inputMode: document.getElementById('schInputMode').value,
scheduleType: t,
concurrencyPolicy: document.getElementById('schConcurrencyPolicy').value,
autoClosePreviousSession: document.getElementById('schAutoClosePrev').checked,
enabled: document.getElementById('schEnabled').checked,
notes: document.getElementById('schNotes').value.trim() || undefined,
};
if (promptMode === 'inline_text') body.promptText = document.getElementById('schPromptText').value;
else body.promptFilePath = document.getElementById('schPromptFilePath').value.trim();
// Always sent for shell (an emptied field must clear a saved command on edit).
if (body.agentType === 'shell') body.launchCommand = document.getElementById('schLaunchCommand').value.trim();
if (promptMode === 'inline_text') {
// Prompt delivery is single-line only; trailing newlines are harmless, strip them.
body.promptText = document.getElementById('schPromptText').value.replace(/[\r\n]+$/, '');
} else {
body.promptFilePath = document.getElementById('schPromptFilePath').value.trim();
}
if (t === 'once') {
const v = document.getElementById('schRunAt').value;
@@ -225,6 +241,10 @@ Object.assign(CodemanApp.prototype, {
errEl.textContent = 'Working directory is required.';
return;
}
if (body.promptText !== undefined && /[\r\n]/.test(body.promptText)) {
errEl.textContent = 'Prompt must be a single line — multi-line prompts are not supported.';
return;
}
const id = document.getElementById('schJobId').value;
const res = id ? await this._apiPut(`/api/cron/jobs/${id}`, body) : await this._apiPost('/api/cron/jobs', body);
if (!res || !res.ok) {
@@ -279,9 +299,13 @@ Object.assign(CodemanApp.prototype, {
},
_countActiveAgents(agentType) {
// Mirrors the server's countActiveAgents: only LIVE sessions count — a
// tab whose CLI already exited (stopped/error) doesn't block anything.
if (!this.sessions) return 0;
let n = 0;
for (const s of this.sessions.values()) if (s && s.mode === agentType) n++;
for (const s of this.sessions.values()) {
if (s && s.mode === agentType && s.status !== 'stopped' && s.status !== 'error') n++;
}
return n;
},
+5 -1
View File
@@ -594,7 +594,7 @@
<input type="hidden" id="schJobId">
<div class="form-row"><label>Name</label><input type="text" id="schName" placeholder="My nightly job"></div>
<div class="form-row"><label>Agent Type</label>
<select id="schAgentType">
<select id="schAgentType" onchange="app.onCronAgentTypeChange()">
<option value="claude">Claude</option>
<option value="shell">Terminal / Shell</option>
<option value="opencode">OpenCode</option>
@@ -603,6 +603,7 @@
</select>
</div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
<div class="form-row hidden" id="schLaunchCommandRow"><label>Launch Command</label><input type="text" id="schLaunchCommand" placeholder="Optional — runs as the first command in the new shell"></div>
<div class="form-row"><label>Prompt Source</label>
<select id="schPromptMode" onchange="app.onCronPromptModeChange()">
<option value="inline_text">Inline text</option>
@@ -646,6 +647,9 @@
<option value="skip_if_same_agent_running">Skip this run</option>
</select>
</div>
<div class="form-row form-row-switch"><label title="Recurring schedules only: when the next run fires, the still-open session created by this job's previous run is closed first">Auto-close previous run's session</label>
<label class="switch"><input type="checkbox" id="schAutoClosePrev" checked><span class="slider"></span></label>
</div>
<div class="form-row form-row-switch"><label>Enabled</label>
<label class="switch"><input type="checkbox" id="schEnabled" checked><span class="slider"></span></label>
</div>
+5 -3
View File
@@ -20,7 +20,9 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
});
app.post('/api/cron/jobs', async (req) => {
const body = parseBody(CronJobSchema, req.body, 'Invalid cron job');
// No custom errorMessage: surface the schema's field-specific messages
// (e.g. "runAt is required for a one-time schedule").
const body = parseBody(CronJobSchema, req.body);
return { job: ctx.cron.createJob(body) };
});
@@ -33,7 +35,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
app.put('/api/cron/jobs/:id', async (req) => {
const { id } = req.params as { id: string };
const body = parseBody(CronJobUpdateSchema, req.body, 'Invalid cron job update');
const body = parseBody(CronJobUpdateSchema, req.body);
const job = ctx.cron.updateJob(id, body);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
return { job };
@@ -62,7 +64,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
const job = ctx.cron.getJob(id);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
const run = await ctx.cron.runNow(id);
return { run, activeAgents: ctx.cron.countActiveAgents(job.agentType) };
return { run, activeAgents: ctx.cron.countActiveAgents(job.agentType, job.id) };
});
// ── Run history ──────────────────────────────────────────────────────────
+10 -2
View File
@@ -579,14 +579,21 @@ export const ScheduledRunSchema = z.object({
/** 'HH:MM' 24-hour time. */
const hhmmSchema = z.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, 'Time must be HH:MM (24-hour)');
/** Prompt delivery is single-line only (writeViaMux/Ink constraint) — reject newlines outright. */
const noNewlines = (v: string) => !/[\r\n]/.test(v);
/** Shared field shape for creating/updating a scheduled job. */
const CronJobBaseSchema = z.object({
name: z.string().min(1).max(200),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']),
workingDir: safePathSchema,
launchCommand: z.string().max(2000).optional(),
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
promptMode: z.enum(['inline_text', 'prompt_file_path']),
promptText: z.string().max(100000).optional(),
promptText: z
.string()
.max(100000)
.refine(noNewlines, 'promptText must be a single line (multi-line prompts are not supported)')
.optional(),
promptFilePath: safePathSchema.optional(),
inputMode: z.enum(['paste', 'typed']),
scheduleType: z.enum(['once', 'interval', 'daily', 'weekly']),
@@ -598,6 +605,7 @@ const CronJobBaseSchema = z.object({
enabled: z.boolean(),
notes: z.string().max(2000).optional(),
concurrencyPolicy: z.enum(['warn_only', 'skip_if_same_agent_running']),
autoClosePreviousSession: z.boolean().optional(),
});
/** Cross-field validation: required fields depend on promptMode + scheduleType. */