import { z } from "zod"; import { parseDurationMs } from "../cli/parse-duration.js"; import { GroupChatSchema, HumanDelaySchema, IdentitySchema, ToolsLinksSchema, ToolsMediaSchema, } from "./zod-schema.core.js"; export const HeartbeatSchema = z .object({ every: z.string().optional(), activeHours: z .object({ start: z.string().optional(), end: z.string().optional(), timezone: z.string().optional(), }) .strict() .optional(), model: z.string().optional(), session: z.string().optional(), includeReasoning: z.boolean().optional(), target: z.string().optional(), to: z.string().optional(), prompt: z.string().optional(), ackMaxChars: z.number().int().nonnegative().optional(), }) .strict() .superRefine((val, ctx) => { if (!val.every) return; try { parseDurationMs(val.every, { defaultUnit: "m" }); } catch { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["every"], message: "invalid duration (use ms, s, m, h)", }); } const active = val.activeHours; if (!active) return; const timePattern = /^([01]\d|2[0-3]|24):([0-5]\d)$/; const validateTime = (raw: string | undefined, opts: { allow24: boolean }, path: string) => { if (!raw) return; if (!timePattern.test(raw)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["activeHours", path], message: 'invalid time (use "HH:MM" 24h format)', }); return; } const [hourStr, minuteStr] = raw.split(":"); const hour = Number(hourStr); const minute = Number(minuteStr); if (hour === 24 && minute !== 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["activeHours", path], message: "invalid time (24:00 is the only allowed 24:xx value)", }); return; } if (hour === 24 && !opts.allow24) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["activeHours", path], message: "invalid time (start cannot be 24:00)", }); } }; validateTime(active.start, { allow24: false }, "start"); validateTime(active.end, { allow24: true }, "end"); }) .optional(); export const SandboxDockerSchema = z .object({ image: z.string().optional(), containerPrefix: z.string().optional(), workdir: z.string().optional(), readOnlyRoot: z.boolean().optional(), tmpfs: z.array(z.string()).optional(), network: z.string().optional(), user: z.string().optional(), capDrop: z.array(z.string()).optional(), env: z.record(z.string(), z.string()).optional(), setupCommand: z.string().optional(), pidsLimit: z.number().int().positive().optional(), memory: z.union([z.string(), z.number()]).optional(), memorySwap: z.union([z.string(), z.number()]).optional(), cpus: z.number().positive().optional(), ulimits: z .record( z.string(), z.union([ z.string(), z.number(), z .object({ soft: z.number().int().nonnegative().optional(), hard: z.number().int().nonnegative().optional(), }) .strict(), ]), ) .optional(), seccompProfile: z.string().optional(), apparmorProfile: z.string().optional(), dns: z.array(z.string()).optional(), extraHosts: z.array(z.string()).optional(), binds: z.array(z.string()).optional(), }) .strict() .optional(); export const SandboxBrowserSchema = z .object({ enabled: z.boolean().optional(), image: z.string().optional(), containerPrefix: z.string().optional(), cdpPort: z.number().int().positive().optional(), vncPort: z.number().int().positive().optional(), noVncPort: z.number().int().positive().optional(), headless: z.boolean().optional(), enableNoVnc: z.boolean().optional(), allowHostControl: z.boolean().optional(), allowedControlUrls: z.array(z.string()).optional(), allowedControlHosts: z.array(z.string()).optional(), allowedControlPorts: z.array(z.number().int().positive()).optional(), autoStart: z.boolean().optional(), autoStartTimeoutMs: z.number().int().positive().optional(), }) .strict() .optional(); export const SandboxPruneSchema = z .object({ idleHours: z.number().int().nonnegative().optional(), maxAgeDays: z.number().int().nonnegative().optional(), }) .strict() .optional(); const ToolPolicyBaseSchema = z .object({ allow: z.array(z.string()).optional(), alsoAllow: z.array(z.string()).optional(), deny: z.array(z.string()).optional(), }) .strict(); export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) => { if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "tools policy cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)", }); } }).optional(); export const ToolsWebSearchSchema = z .object({ enabled: z.boolean().optional(), provider: z.union([z.literal("brave"), z.literal("perplexity")]).optional(), apiKey: z.string().optional(), maxResults: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(), cacheTtlMinutes: z.number().nonnegative().optional(), perplexity: z .object({ apiKey: z.string().optional(), baseUrl: z.string().optional(), model: z.string().optional(), }) .strict() .optional(), }) .strict() .optional(); export const ToolsWebFetchSchema = z .object({ enabled: z.boolean().optional(), maxChars: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(), cacheTtlMinutes: z.number().nonnegative().optional(), maxRedirects: z.number().int().nonnegative().optional(), userAgent: z.string().optional(), }) .strict() .optional(); export const ToolsWebSchema = z .object({ search: ToolsWebSearchSchema, fetch: ToolsWebFetchSchema, }) .strict() .optional(); export const ToolProfileSchema = z .union([z.literal("minimal"), z.literal("coding"), z.literal("messaging"), z.literal("full")]) .optional(); export const ToolPolicyWithProfileSchema = z .object({ allow: z.array(z.string()).optional(), alsoAllow: z.array(z.string()).optional(), deny: z.array(z.string()).optional(), profile: ToolProfileSchema, }) .strict() .superRefine((value, ctx) => { if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "tools.byProvider policy cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)", }); } }); // Provider docking: allowlists keyed by provider id (no schema updates when adding providers). export const ElevatedAllowFromSchema = z .record(z.string(), z.array(z.union([z.string(), z.number()]))) .optional(); export const AgentSandboxSchema = z .object({ mode: z.union([z.literal("off"), z.literal("non-main"), z.literal("all")]).optional(), workspaceAccess: z.union([z.literal("none"), z.literal("ro"), z.literal("rw")]).optional(), sessionToolsVisibility: z.union([z.literal("spawned"), z.literal("all")]).optional(), scope: z.union([z.literal("session"), z.literal("agent"), z.literal("shared")]).optional(), perSession: z.boolean().optional(), workspaceRoot: z.string().optional(), docker: SandboxDockerSchema, browser: SandboxBrowserSchema, prune: SandboxPruneSchema, }) .strict() .optional(); export const AgentToolsSchema = z .object({ profile: ToolProfileSchema, allow: z.array(z.string()).optional(), alsoAllow: z.array(z.string()).optional(), deny: z.array(z.string()).optional(), byProvider: z.record(z.string(), ToolPolicyWithProfileSchema).optional(), elevated: z .object({ enabled: z.boolean().optional(), allowFrom: ElevatedAllowFromSchema, }) .strict() .optional(), exec: z .object({ host: z.enum(["sandbox", "gateway", "node"]).optional(), security: z.enum(["deny", "allowlist", "full"]).optional(), ask: z.enum(["off", "on-miss", "always"]).optional(), node: z.string().optional(), pathPrepend: z.array(z.string()).optional(), safeBins: z.array(z.string()).optional(), backgroundMs: z.number().int().positive().optional(), timeoutSec: z.number().int().positive().optional(), approvalRunningNoticeMs: z.number().int().nonnegative().optional(), cleanupMs: z.number().int().positive().optional(), notifyOnExit: z.boolean().optional(), applyPatch: z .object({ enabled: z.boolean().optional(), allowModels: z.array(z.string()).optional(), }) .strict() .optional(), }) .strict() .optional(), sandbox: z .object({ tools: ToolPolicySchema, }) .strict() .optional(), }) .strict() .superRefine((value, ctx) => { if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "agent tools cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)", }); } }) .optional(); export const MemorySearchSchema = z .object({ enabled: z.boolean().optional(), sources: z.array(z.union([z.literal("memory"), z.literal("sessions")])).optional(), experimental: z .object({ sessionMemory: z.boolean().optional(), }) .strict() .optional(), provider: z.union([z.literal("openai"), z.literal("local"), z.literal("gemini")]).optional(), remote: z .object({ baseUrl: z.string().optional(), apiKey: z.string().optional(), headers: z.record(z.string(), z.string()).optional(), batch: z .object({ enabled: z.boolean().optional(), wait: z.boolean().optional(), concurrency: z.number().int().positive().optional(), pollIntervalMs: z.number().int().nonnegative().optional(), timeoutMinutes: z.number().int().positive().optional(), }) .strict() .optional(), }) .strict() .optional(), fallback: z .union([z.literal("openai"), z.literal("gemini"), z.literal("local"), z.literal("none")]) .optional(), model: z.string().optional(), local: z .object({ modelPath: z.string().optional(), modelCacheDir: z.string().optional(), }) .strict() .optional(), store: z .object({ driver: z.literal("sqlite").optional(), path: z.string().optional(), vector: z .object({ enabled: z.boolean().optional(), extensionPath: z.string().optional(), }) .strict() .optional(), }) .strict() .optional(), chunking: z .object({ tokens: z.number().int().positive().optional(), overlap: z.number().int().nonnegative().optional(), }) .strict() .optional(), sync: z .object({ onSessionStart: z.boolean().optional(), onSearch: z.boolean().optional(), watch: z.boolean().optional(), watchDebounceMs: z.number().int().nonnegative().optional(), intervalMinutes: z.number().int().nonnegative().optional(), sessions: z .object({ deltaBytes: z.number().int().nonnegative().optional(), deltaMessages: z.number().int().nonnegative().optional(), }) .strict() .optional(), }) .strict() .optional(), query: z .object({ maxResults: z.number().int().positive().optional(), minScore: z.number().min(0).max(1).optional(), hybrid: z .object({ enabled: z.boolean().optional(), vectorWeight: z.number().min(0).max(1).optional(), textWeight: z.number().min(0).max(1).optional(), candidateMultiplier: z.number().int().positive().optional(), }) .strict() .optional(), }) .strict() .optional(), cache: z .object({ enabled: z.boolean().optional(), maxEntries: z.number().int().positive().optional(), }) .strict() .optional(), }) .strict() .optional(); export const AgentModelSchema = z.union([ z.string(), z .object({ primary: z.string().optional(), fallbacks: z.array(z.string()).optional(), }) .strict(), ]); export const AgentEntrySchema = z .object({ id: z.string(), default: z.boolean().optional(), name: z.string().optional(), workspace: z.string().optional(), agentDir: z.string().optional(), model: AgentModelSchema.optional(), memorySearch: MemorySearchSchema, humanDelay: HumanDelaySchema.optional(), heartbeat: HeartbeatSchema, identity: IdentitySchema, groupChat: GroupChatSchema, subagents: z .object({ allowAgents: z.array(z.string()).optional(), model: z .union([ z.string(), z .object({ primary: z.string().optional(), fallbacks: z.array(z.string()).optional(), }) .strict(), ]) .optional(), }) .strict() .optional(), sandbox: AgentSandboxSchema, tools: AgentToolsSchema, }) .strict(); export const ToolsSchema = z .object({ profile: ToolProfileSchema, allow: z.array(z.string()).optional(), alsoAllow: z.array(z.string()).optional(), deny: z.array(z.string()).optional(), byProvider: z.record(z.string(), ToolPolicyWithProfileSchema).optional(), web: ToolsWebSchema, media: ToolsMediaSchema, links: ToolsLinksSchema, message: z .object({ allowCrossContextSend: z.boolean().optional(), crossContext: z .object({ allowWithinProvider: z.boolean().optional(), allowAcrossProviders: z.boolean().optional(), marker: z .object({ enabled: z.boolean().optional(), prefix: z.string().optional(), suffix: z.string().optional(), }) .strict() .optional(), }) .strict() .optional(), broadcast: z .object({ enabled: z.boolean().optional(), }) .strict() .optional(), }) .strict() .optional(), agentToAgent: z .object({ enabled: z.boolean().optional(), allow: z.array(z.string()).optional(), }) .strict() .optional(), elevated: z .object({ enabled: z.boolean().optional(), allowFrom: ElevatedAllowFromSchema, }) .strict() .optional(), exec: z .object({ host: z.enum(["sandbox", "gateway", "node"]).optional(), security: z.enum(["deny", "allowlist", "full"]).optional(), ask: z.enum(["off", "on-miss", "always"]).optional(), node: z.string().optional(), pathPrepend: z.array(z.string()).optional(), safeBins: z.array(z.string()).optional(), backgroundMs: z.number().int().positive().optional(), timeoutSec: z.number().int().positive().optional(), cleanupMs: z.number().int().positive().optional(), notifyOnExit: z.boolean().optional(), applyPatch: z .object({ enabled: z.boolean().optional(), allowModels: z.array(z.string()).optional(), }) .strict() .optional(), }) .strict() .optional(), subagents: z .object({ tools: ToolPolicySchema, }) .strict() .optional(), sandbox: z .object({ tools: ToolPolicySchema, }) .strict() .optional(), }) .strict() .superRefine((value, ctx) => { if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "tools cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)", }); } }) .optional();