refactor: split config module
This commit is contained in:
@@ -107,7 +107,7 @@ function resolveElevatedAllowList(
|
||||
return allowFrom?.telegram;
|
||||
case "discord": {
|
||||
const hasExplicit = Boolean(
|
||||
allowFrom && Object.prototype.hasOwnProperty.call(allowFrom, "discord"),
|
||||
allowFrom && Object.hasOwn(allowFrom, "discord"),
|
||||
);
|
||||
if (hasExplicit) return allowFrom?.discord;
|
||||
return discordFallback;
|
||||
@@ -308,7 +308,7 @@ export async function getReplyFromConfig(
|
||||
: "on";
|
||||
const resolvedBlockStreaming =
|
||||
agentCfg?.blockStreamingDefault === "off" ? "off" : "on";
|
||||
const resolvedBlockStreamingBreak =
|
||||
const resolvedBlockStreamingBreak: "text_end" | "message_end" =
|
||||
agentCfg?.blockStreamingBreak === "message_end"
|
||||
? "message_end"
|
||||
: "text_end";
|
||||
@@ -468,9 +468,10 @@ export async function getReplyFromConfig(
|
||||
const isGroupChat = sessionCtx.ChatType === "group";
|
||||
const wasMentioned = ctx.WasMentioned === true;
|
||||
const shouldEagerType = !isGroupChat || wasMentioned;
|
||||
const shouldInjectGroupIntro =
|
||||
const shouldInjectGroupIntro = Boolean(
|
||||
isGroupChat &&
|
||||
(isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro);
|
||||
(isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro),
|
||||
);
|
||||
const groupIntro = shouldInjectGroupIntro
|
||||
? buildGroupIntro({
|
||||
sessionCtx,
|
||||
@@ -626,7 +627,7 @@ export async function getReplyFromConfig(
|
||||
ownerNumbers:
|
||||
command.ownerList.length > 0 ? command.ownerList : undefined,
|
||||
extraSystemPrompt: groupIntro || undefined,
|
||||
enforceFinalTag: provider === "ollama" ? true : undefined,
|
||||
...(provider === "ollama" ? { enforceFinalTag: true } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { ClawdisConfig } from "../../config/config.js";
|
||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||
import {
|
||||
type SessionEntry,
|
||||
type SessionScope,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { triggerClawdisRestart } from "../../infra/restart.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
@@ -101,7 +105,7 @@ export async function handleCommands(params: {
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
sessionScope: string;
|
||||
sessionScope?: SessionScope;
|
||||
workspaceDir: string;
|
||||
defaultGroupActivation: () => "always" | "mention";
|
||||
resolvedThinkLevel?: ThinkLevel;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveSessionKey,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
type SessionScope,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
@@ -26,7 +27,7 @@ export type SessionInitResult = {
|
||||
systemSent: boolean;
|
||||
abortedLastRun: boolean;
|
||||
storePath: string;
|
||||
sessionScope: string;
|
||||
sessionScope: SessionScope;
|
||||
groupResolution?: GroupKeyResolution;
|
||||
isGroup: boolean;
|
||||
bodyStripped?: string;
|
||||
@@ -66,7 +67,7 @@ export async function initSessionState(params: {
|
||||
let persistedModelOverride: string | undefined;
|
||||
let persistedProviderOverride: string | undefined;
|
||||
|
||||
const groupResolution = resolveGroupSessionKey(ctx);
|
||||
const groupResolution = resolveGroupSessionKey(ctx) ?? undefined;
|
||||
const isGroup =
|
||||
ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution);
|
||||
const triggerBodyNormalized = stripStructuralPrefixes(ctx.Body ?? "")
|
||||
|
||||
@@ -162,7 +162,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
const verboseLevel =
|
||||
args.resolvedVerbose ?? args.agent?.verboseDefault ?? "off";
|
||||
const elevatedLevel =
|
||||
args.entry?.elevatedLevel ?? args.agent?.elevatedDefault ?? "on";
|
||||
args.sessionEntry?.elevatedLevel ?? args.agent?.elevatedDefault ?? "on";
|
||||
|
||||
const webLine = (() => {
|
||||
if (args.webLinked === false) {
|
||||
|
||||
2073
src/config/config.ts
2073
src/config/config.ts
File diff suppressed because it is too large
Load Diff
83
src/config/defaults.ts
Normal file
83
src/config/defaults.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { resolveTalkApiKey } from "./talk.js";
|
||||
import type { ClawdisConfig } from "./types.js";
|
||||
|
||||
type WarnState = { warned: boolean };
|
||||
|
||||
let defaultWarnState: WarnState = { warned: false };
|
||||
|
||||
export type SessionDefaultsOptions = {
|
||||
warn?: (message: string) => void;
|
||||
warnState?: WarnState;
|
||||
};
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function applyIdentityDefaults(cfg: ClawdisConfig): ClawdisConfig {
|
||||
const identity = cfg.identity;
|
||||
if (!identity) return cfg;
|
||||
|
||||
const name = identity.name?.trim();
|
||||
|
||||
const routing = cfg.routing ?? {};
|
||||
const groupChat = routing.groupChat ?? {};
|
||||
|
||||
let mutated = false;
|
||||
const next: ClawdisConfig = { ...cfg };
|
||||
|
||||
if (name && !groupChat.mentionPatterns) {
|
||||
const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp);
|
||||
const re = parts.length ? parts.join("\\s+") : escapeRegExp(name);
|
||||
const pattern = `\\b@?${re}\\b`;
|
||||
next.routing = {
|
||||
...(next.routing ?? routing),
|
||||
groupChat: { ...groupChat, mentionPatterns: [pattern] },
|
||||
};
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
return mutated ? next : cfg;
|
||||
}
|
||||
|
||||
export function applySessionDefaults(
|
||||
cfg: ClawdisConfig,
|
||||
options: SessionDefaultsOptions = {},
|
||||
): ClawdisConfig {
|
||||
const session = cfg.session;
|
||||
if (!session || session.mainKey === undefined) return cfg;
|
||||
|
||||
const trimmed = session.mainKey.trim();
|
||||
const warn = options.warn ?? console.warn;
|
||||
const warnState = options.warnState ?? defaultWarnState;
|
||||
|
||||
const next: ClawdisConfig = {
|
||||
...cfg,
|
||||
session: { ...session, mainKey: "main" },
|
||||
};
|
||||
|
||||
if (trimmed && trimmed !== "main" && !warnState.warned) {
|
||||
warnState.warned = true;
|
||||
warn('session.mainKey is ignored; main session is always "main".');
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function applyTalkApiKey(config: ClawdisConfig): ClawdisConfig {
|
||||
const resolved = resolveTalkApiKey();
|
||||
if (!resolved) return config;
|
||||
const existing = config.talk?.apiKey?.trim();
|
||||
if (existing) return config;
|
||||
return {
|
||||
...config,
|
||||
talk: {
|
||||
...config.talk,
|
||||
apiKey: resolved,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resetSessionDefaultsWarningForTests() {
|
||||
defaultWarnState = { warned: false };
|
||||
}
|
||||
188
src/config/io.ts
Normal file
188
src/config/io.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import JSON5 from "json5";
|
||||
|
||||
import {
|
||||
applyIdentityDefaults,
|
||||
applySessionDefaults,
|
||||
applyTalkApiKey,
|
||||
} from "./defaults.js";
|
||||
import { findLegacyConfigIssues } from "./legacy.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDIS,
|
||||
resolveConfigPath,
|
||||
resolveStateDir,
|
||||
} from "./paths.js";
|
||||
import type {
|
||||
ClawdisConfig,
|
||||
ConfigFileSnapshot,
|
||||
LegacyConfigIssue,
|
||||
} from "./types.js";
|
||||
import { validateConfigObject } from "./validation.js";
|
||||
import { ClawdisSchema } from "./zod-schema.js";
|
||||
|
||||
export type ParseConfigJson5Result =
|
||||
| { ok: true; parsed: unknown }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type ConfigIoDeps = {
|
||||
fs?: typeof fs;
|
||||
json5?: typeof JSON5;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homedir?: () => string;
|
||||
configPath?: string;
|
||||
logger?: Pick<typeof console, "error" | "warn">;
|
||||
};
|
||||
|
||||
function resolveConfigPathForDeps(deps: Required<ConfigIoDeps>): string {
|
||||
if (deps.configPath) return deps.configPath;
|
||||
return resolveConfigPath(deps.env, resolveStateDir(deps.env, deps.homedir));
|
||||
}
|
||||
|
||||
function normalizeDeps(overrides: ConfigIoDeps = {}): Required<ConfigIoDeps> {
|
||||
return {
|
||||
fs: overrides.fs ?? fs,
|
||||
json5: overrides.json5 ?? JSON5,
|
||||
env: overrides.env ?? process.env,
|
||||
homedir: overrides.homedir ?? os.homedir,
|
||||
configPath: overrides.configPath ?? "",
|
||||
logger: overrides.logger ?? console,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseConfigJson5(
|
||||
raw: string,
|
||||
json5: { parse: (value: string) => unknown } = JSON5,
|
||||
): ParseConfigJson5Result {
|
||||
try {
|
||||
return { ok: true, parsed: json5.parse(raw) as unknown };
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
const deps = normalizeDeps(overrides);
|
||||
const configPath = resolveConfigPathForDeps(deps);
|
||||
|
||||
function loadConfig(): ClawdisConfig {
|
||||
try {
|
||||
if (!deps.fs.existsSync(configPath)) return {};
|
||||
const raw = deps.fs.readFileSync(configPath, "utf-8");
|
||||
const parsed = deps.json5.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null) return {};
|
||||
const validated = ClawdisSchema.safeParse(parsed);
|
||||
if (!validated.success) {
|
||||
deps.logger.error("Invalid config:");
|
||||
for (const iss of validated.error.issues) {
|
||||
deps.logger.error(`- ${iss.path.join(".")}: ${iss.message}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
return applySessionDefaults(
|
||||
applyIdentityDefaults(validated.data as ClawdisConfig),
|
||||
);
|
||||
} catch (err) {
|
||||
deps.logger.error(`Failed to read config at ${configPath}`, err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
const exists = deps.fs.existsSync(configPath);
|
||||
if (!exists) {
|
||||
const config = applyTalkApiKey(applySessionDefaults({}));
|
||||
const legacyIssues: LegacyConfigIssue[] = [];
|
||||
return {
|
||||
path: configPath,
|
||||
exists: false,
|
||||
raw: null,
|
||||
parsed: {},
|
||||
valid: true,
|
||||
config,
|
||||
issues: [],
|
||||
legacyIssues,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = deps.fs.readFileSync(configPath, "utf-8");
|
||||
const parsedRes = parseConfigJson5(raw, deps.json5);
|
||||
if (!parsedRes.ok) {
|
||||
return {
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw,
|
||||
parsed: {},
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: [
|
||||
{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` },
|
||||
],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
|
||||
const legacyIssues = findLegacyConfigIssues(parsedRes.parsed);
|
||||
|
||||
const validated = validateConfigObject(parsedRes.parsed);
|
||||
if (!validated.ok) {
|
||||
return {
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw,
|
||||
parsed: parsedRes.parsed,
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: validated.issues,
|
||||
legacyIssues,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw,
|
||||
parsed: parsedRes.parsed,
|
||||
valid: true,
|
||||
config: applyTalkApiKey(applySessionDefaults(validated.config)),
|
||||
issues: [],
|
||||
legacyIssues,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw: null,
|
||||
parsed: {},
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeConfigFile(cfg: ClawdisConfig) {
|
||||
await deps.fs.promises.mkdir(path.dirname(configPath), {
|
||||
recursive: true,
|
||||
});
|
||||
const json = JSON.stringify(cfg, null, 2).trimEnd().concat("\n");
|
||||
await deps.fs.promises.writeFile(configPath, json, "utf-8");
|
||||
}
|
||||
|
||||
return {
|
||||
configPath,
|
||||
loadConfig,
|
||||
readConfigFileSnapshot,
|
||||
writeConfigFile,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultIO = createConfigIO({ configPath: CONFIG_PATH_CLAWDIS });
|
||||
|
||||
export const loadConfig = defaultIO.loadConfig;
|
||||
export const readConfigFileSnapshot = defaultIO.readConfigFileSnapshot;
|
||||
export const writeConfigFile = defaultIO.writeConfigFile;
|
||||
19
src/config/legacy-migrate.ts
Normal file
19
src/config/legacy-migrate.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { applyLegacyMigrations } from "./legacy.js";
|
||||
import type { ClawdisConfig } from "./types.js";
|
||||
import { validateConfigObject } from "./validation.js";
|
||||
|
||||
export function migrateLegacyConfig(raw: unknown): {
|
||||
config: ClawdisConfig | null;
|
||||
changes: string[];
|
||||
} {
|
||||
const { next, changes } = applyLegacyMigrations(raw);
|
||||
if (!next) return { config: null, changes: [] };
|
||||
const validated = validateConfigObject(next);
|
||||
if (!validated.ok) {
|
||||
changes.push(
|
||||
"Migration applied, but config still invalid; fix remaining issues manually.",
|
||||
);
|
||||
return { config: null, changes };
|
||||
}
|
||||
return { config: validated.config, changes };
|
||||
}
|
||||
202
src/config/legacy.ts
Normal file
202
src/config/legacy.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import type { LegacyConfigIssue } from "./types.js";
|
||||
|
||||
type LegacyConfigRule = {
|
||||
path: string[];
|
||||
message: string;
|
||||
};
|
||||
|
||||
type LegacyConfigMigration = {
|
||||
id: string;
|
||||
describe: string;
|
||||
apply: (raw: Record<string, unknown>, changes: string[]) => void;
|
||||
};
|
||||
|
||||
const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
||||
{
|
||||
path: ["routing", "allowFrom"],
|
||||
message:
|
||||
"routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdis doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["routing", "groupChat", "requireMention"],
|
||||
message:
|
||||
'routing.groupChat.requireMention was removed; use whatsapp/telegram/imessage groups defaults (e.g. whatsapp.groups."*".requireMention) instead (run `clawdis doctor` to migrate).',
|
||||
},
|
||||
{
|
||||
path: ["telegram", "requireMention"],
|
||||
message:
|
||||
'telegram.requireMention was removed; use telegram.groups."*".requireMention instead (run `clawdis doctor` to migrate).',
|
||||
},
|
||||
];
|
||||
|
||||
const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
|
||||
{
|
||||
id: "routing.allowFrom->whatsapp.allowFrom",
|
||||
describe: "Move routing.allowFrom to whatsapp.allowFrom",
|
||||
apply: (raw, changes) => {
|
||||
const routing = raw.routing;
|
||||
if (!routing || typeof routing !== "object") return;
|
||||
const allowFrom = (routing as Record<string, unknown>).allowFrom;
|
||||
if (allowFrom === undefined) return;
|
||||
|
||||
const whatsapp =
|
||||
raw.whatsapp && typeof raw.whatsapp === "object"
|
||||
? (raw.whatsapp as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
if (whatsapp.allowFrom === undefined) {
|
||||
whatsapp.allowFrom = allowFrom;
|
||||
changes.push("Moved routing.allowFrom → whatsapp.allowFrom.");
|
||||
} else {
|
||||
changes.push(
|
||||
"Removed routing.allowFrom (whatsapp.allowFrom already set).",
|
||||
);
|
||||
}
|
||||
|
||||
delete (routing as Record<string, unknown>).allowFrom;
|
||||
if (Object.keys(routing as Record<string, unknown>).length === 0) {
|
||||
delete raw.routing;
|
||||
}
|
||||
raw.whatsapp = whatsapp;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "routing.groupChat.requireMention->groups.*.requireMention",
|
||||
describe:
|
||||
"Move routing.groupChat.requireMention to whatsapp/telegram/imessage groups",
|
||||
apply: (raw, changes) => {
|
||||
const routing = raw.routing;
|
||||
if (!routing || typeof routing !== "object") return;
|
||||
const groupChat =
|
||||
(routing as Record<string, unknown>).groupChat &&
|
||||
typeof (routing as Record<string, unknown>).groupChat === "object"
|
||||
? ((routing as Record<string, unknown>).groupChat as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: null;
|
||||
if (!groupChat) return;
|
||||
const requireMention = groupChat.requireMention;
|
||||
if (requireMention === undefined) return;
|
||||
|
||||
const applyTo = (key: "whatsapp" | "telegram" | "imessage") => {
|
||||
const section =
|
||||
raw[key] && typeof raw[key] === "object"
|
||||
? (raw[key] as Record<string, unknown>)
|
||||
: {};
|
||||
const groups =
|
||||
section.groups && typeof section.groups === "object"
|
||||
? (section.groups as Record<string, unknown>)
|
||||
: {};
|
||||
const defaultKey = "*";
|
||||
const entry =
|
||||
groups[defaultKey] && typeof groups[defaultKey] === "object"
|
||||
? (groups[defaultKey] as Record<string, unknown>)
|
||||
: {};
|
||||
if (entry.requireMention === undefined) {
|
||||
entry.requireMention = requireMention;
|
||||
groups[defaultKey] = entry;
|
||||
section.groups = groups;
|
||||
raw[key] = section;
|
||||
changes.push(
|
||||
`Moved routing.groupChat.requireMention → ${key}.groups."*".requireMention.`,
|
||||
);
|
||||
} else {
|
||||
changes.push(
|
||||
`Removed routing.groupChat.requireMention (${key}.groups."*" already set).`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
applyTo("whatsapp");
|
||||
applyTo("telegram");
|
||||
applyTo("imessage");
|
||||
|
||||
delete groupChat.requireMention;
|
||||
if (Object.keys(groupChat).length === 0) {
|
||||
delete (routing as Record<string, unknown>).groupChat;
|
||||
}
|
||||
if (Object.keys(routing as Record<string, unknown>).length === 0) {
|
||||
delete raw.routing;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "telegram.requireMention->telegram.groups.*.requireMention",
|
||||
describe:
|
||||
"Move telegram.requireMention to telegram.groups.*.requireMention",
|
||||
apply: (raw, changes) => {
|
||||
const telegram = raw.telegram;
|
||||
if (!telegram || typeof telegram !== "object") return;
|
||||
const requireMention = (telegram as Record<string, unknown>)
|
||||
.requireMention;
|
||||
if (requireMention === undefined) return;
|
||||
|
||||
const groups =
|
||||
(telegram as Record<string, unknown>).groups &&
|
||||
typeof (telegram as Record<string, unknown>).groups === "object"
|
||||
? ((telegram as Record<string, unknown>).groups as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: {};
|
||||
const defaultKey = "*";
|
||||
const entry =
|
||||
groups[defaultKey] && typeof groups[defaultKey] === "object"
|
||||
? (groups[defaultKey] as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
if (entry.requireMention === undefined) {
|
||||
entry.requireMention = requireMention;
|
||||
groups[defaultKey] = entry;
|
||||
(telegram as Record<string, unknown>).groups = groups;
|
||||
changes.push(
|
||||
'Moved telegram.requireMention → telegram.groups."*".requireMention.',
|
||||
);
|
||||
} else {
|
||||
changes.push(
|
||||
'Removed telegram.requireMention (telegram.groups."*" already set).',
|
||||
);
|
||||
}
|
||||
|
||||
delete (telegram as Record<string, unknown>).requireMention;
|
||||
if (Object.keys(telegram as Record<string, unknown>).length === 0) {
|
||||
delete raw.telegram;
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
|
||||
if (!raw || typeof raw !== "object") return [];
|
||||
const root = raw as Record<string, unknown>;
|
||||
const issues: LegacyConfigIssue[] = [];
|
||||
for (const rule of LEGACY_CONFIG_RULES) {
|
||||
let cursor: unknown = root;
|
||||
for (const key of rule.path) {
|
||||
if (!cursor || typeof cursor !== "object") {
|
||||
cursor = undefined;
|
||||
break;
|
||||
}
|
||||
cursor = (cursor as Record<string, unknown>)[key];
|
||||
}
|
||||
if (cursor !== undefined) {
|
||||
issues.push({ path: rule.path.join("."), message: rule.message });
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function applyLegacyMigrations(raw: unknown): {
|
||||
next: Record<string, unknown> | null;
|
||||
changes: string[];
|
||||
} {
|
||||
if (!raw || typeof raw !== "object") return { next: null, changes: [] };
|
||||
const next = structuredClone(raw) as Record<string, unknown>;
|
||||
const changes: string[] = [];
|
||||
for (const migration of LEGACY_CONFIG_MIGRATIONS) {
|
||||
migration.apply(next, changes);
|
||||
}
|
||||
if (changes.length === 0) return { next: null, changes: [] };
|
||||
return { next, changes };
|
||||
}
|
||||
69
src/config/paths.ts
Normal file
69
src/config/paths.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ClawdisConfig } from "./types.js";
|
||||
|
||||
/**
|
||||
* Nix mode detection: When CLAWDIS_NIX_MODE=1, the gateway is running under Nix.
|
||||
* In this mode:
|
||||
* - No auto-install flows should be attempted
|
||||
* - Missing dependencies should produce actionable Nix-specific error messages
|
||||
* - Config is managed externally (read-only from Nix perspective)
|
||||
*/
|
||||
export function resolveIsNixMode(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return env.CLAWDIS_NIX_MODE === "1";
|
||||
}
|
||||
|
||||
export const isNixMode = resolveIsNixMode();
|
||||
|
||||
/**
|
||||
* State directory for mutable data (sessions, logs, caches).
|
||||
* Can be overridden via CLAWDIS_STATE_DIR environment variable.
|
||||
* Default: ~/.clawdis
|
||||
*/
|
||||
export function resolveStateDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homedir: () => string = os.homedir,
|
||||
): string {
|
||||
const override = env.CLAWDIS_STATE_DIR?.trim();
|
||||
if (override) return override;
|
||||
return path.join(homedir(), ".clawdis");
|
||||
}
|
||||
|
||||
export const STATE_DIR_CLAWDIS = resolveStateDir();
|
||||
|
||||
/**
|
||||
* Config file path (JSON5).
|
||||
* Can be overridden via CLAWDIS_CONFIG_PATH environment variable.
|
||||
* Default: ~/.clawdis/clawdis.json (or $CLAWDIS_STATE_DIR/clawdis.json)
|
||||
*/
|
||||
export function resolveConfigPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
stateDir: string = resolveStateDir(env, os.homedir),
|
||||
): string {
|
||||
const override = env.CLAWDIS_CONFIG_PATH?.trim();
|
||||
if (override) return override;
|
||||
return path.join(stateDir, "clawdis.json");
|
||||
}
|
||||
|
||||
export const CONFIG_PATH_CLAWDIS = resolveConfigPath();
|
||||
|
||||
export const DEFAULT_GATEWAY_PORT = 18789;
|
||||
|
||||
export function resolveGatewayPort(
|
||||
cfg?: ClawdisConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): number {
|
||||
const envRaw = env.CLAWDIS_GATEWAY_PORT?.trim();
|
||||
if (envRaw) {
|
||||
const parsed = Number.parseInt(envRaw, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
const configPort = cfg?.gateway?.port;
|
||||
if (typeof configPort === "number" && Number.isFinite(configPort)) {
|
||||
if (configPort > 0) return configPort;
|
||||
}
|
||||
return DEFAULT_GATEWAY_PORT;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { VERSION } from "../version.js";
|
||||
import { ClawdisSchema } from "./config.js";
|
||||
import { ClawdisSchema } from "./zod-schema.js";
|
||||
|
||||
export type ConfigUiHint = {
|
||||
label?: string;
|
||||
|
||||
45
src/config/talk.ts
Normal file
45
src/config/talk.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
type TalkApiKeyDeps = {
|
||||
fs?: typeof fs;
|
||||
os?: typeof os;
|
||||
path?: typeof path;
|
||||
};
|
||||
|
||||
export function readTalkApiKeyFromProfile(
|
||||
deps: TalkApiKeyDeps = {},
|
||||
): string | null {
|
||||
const fsImpl = deps.fs ?? fs;
|
||||
const osImpl = deps.os ?? os;
|
||||
const pathImpl = deps.path ?? path;
|
||||
|
||||
const home = osImpl.homedir();
|
||||
const candidates = [".profile", ".zprofile", ".zshrc", ".bashrc"].map(
|
||||
(name) => pathImpl.join(home, name),
|
||||
);
|
||||
for (const candidate of candidates) {
|
||||
if (!fsImpl.existsSync(candidate)) continue;
|
||||
try {
|
||||
const text = fsImpl.readFileSync(candidate, "utf-8");
|
||||
const match = text.match(
|
||||
/(?:^|\n)\s*(?:export\s+)?ELEVENLABS_API_KEY\s*=\s*["']?([^\n"']+)["']?/,
|
||||
);
|
||||
const value = match?.[1]?.trim();
|
||||
if (value) return value;
|
||||
} catch {
|
||||
// Ignore profile read errors.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveTalkApiKey(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
deps: TalkApiKeyDeps = {},
|
||||
): string | null {
|
||||
const envValue = (env.ELEVENLABS_API_KEY ?? "").trim();
|
||||
if (envValue) return envValue;
|
||||
return readTalkApiKeyFromProfile(deps);
|
||||
}
|
||||
764
src/config/types.ts
Normal file
764
src/config/types.ts
Normal file
@@ -0,0 +1,764 @@
|
||||
export type ReplyMode = "text" | "command";
|
||||
export type SessionScope = "per-sender" | "global";
|
||||
export type ReplyToMode = "off" | "first" | "all";
|
||||
|
||||
export type SessionSendPolicyAction = "allow" | "deny";
|
||||
export type SessionSendPolicyMatch = {
|
||||
surface?: string;
|
||||
chatType?: "direct" | "group" | "room";
|
||||
keyPrefix?: string;
|
||||
};
|
||||
export type SessionSendPolicyRule = {
|
||||
action: SessionSendPolicyAction;
|
||||
match?: SessionSendPolicyMatch;
|
||||
};
|
||||
export type SessionSendPolicyConfig = {
|
||||
default?: SessionSendPolicyAction;
|
||||
rules?: SessionSendPolicyRule[];
|
||||
};
|
||||
|
||||
export type SessionConfig = {
|
||||
scope?: SessionScope;
|
||||
resetTriggers?: string[];
|
||||
idleMinutes?: number;
|
||||
heartbeatIdleMinutes?: number;
|
||||
store?: string;
|
||||
typingIntervalSeconds?: number;
|
||||
mainKey?: string;
|
||||
sendPolicy?: SessionSendPolicyConfig;
|
||||
agentToAgent?: {
|
||||
/** Max ping-pong turns between requester/target (0–5). Default: 5. */
|
||||
maxPingPongTurns?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type LoggingConfig = {
|
||||
level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
|
||||
file?: string;
|
||||
consoleLevel?:
|
||||
| "silent"
|
||||
| "fatal"
|
||||
| "error"
|
||||
| "warn"
|
||||
| "info"
|
||||
| "debug"
|
||||
| "trace";
|
||||
consoleStyle?: "pretty" | "compact" | "json";
|
||||
};
|
||||
|
||||
export type WebReconnectConfig = {
|
||||
initialMs?: number;
|
||||
maxMs?: number;
|
||||
factor?: number;
|
||||
jitter?: number;
|
||||
maxAttempts?: number; // 0 = unlimited
|
||||
};
|
||||
|
||||
export type WebConfig = {
|
||||
/** If false, do not start the WhatsApp web provider. Default: true. */
|
||||
enabled?: boolean;
|
||||
heartbeatSeconds?: number;
|
||||
reconnect?: WebReconnectConfig;
|
||||
};
|
||||
|
||||
export type AgentElevatedAllowFromConfig = {
|
||||
whatsapp?: string[];
|
||||
telegram?: Array<string | number>;
|
||||
discord?: Array<string | number>;
|
||||
signal?: Array<string | number>;
|
||||
imessage?: Array<string | number>;
|
||||
webchat?: Array<string | number>;
|
||||
};
|
||||
|
||||
export type WhatsAppConfig = {
|
||||
/** Optional allowlist for WhatsApp direct chats (E.164). */
|
||||
allowFrom?: string[];
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
groups?: Record<
|
||||
string,
|
||||
{
|
||||
requireMention?: boolean;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export type BrowserProfileConfig = {
|
||||
/** CDP port for this profile. Allocated once at creation, persisted permanently. */
|
||||
cdpPort?: number;
|
||||
/** CDP URL for this profile (use for remote Chrome). */
|
||||
cdpUrl?: string;
|
||||
/** Profile color (hex). Auto-assigned at creation. */
|
||||
color: string;
|
||||
};
|
||||
export type BrowserConfig = {
|
||||
enabled?: boolean;
|
||||
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
||||
controlUrl?: string;
|
||||
/** Base URL of the CDP endpoint. Default: controlUrl with port + 1. */
|
||||
cdpUrl?: string;
|
||||
/** Accent color for the clawd browser profile (hex). Default: #FF4500 */
|
||||
color?: string;
|
||||
/** Override the browser executable path (macOS/Linux). */
|
||||
executablePath?: string;
|
||||
/** Start Chrome headless (best-effort). Default: false */
|
||||
headless?: boolean;
|
||||
/** Pass --no-sandbox to Chrome (Linux containers). Default: false */
|
||||
noSandbox?: boolean;
|
||||
/** If true: never launch; only attach to an existing browser. Default: false */
|
||||
attachOnly?: boolean;
|
||||
/** Default profile to use when profile param is omitted. Default: "clawd" */
|
||||
defaultProfile?: string;
|
||||
/** Named browser profiles with explicit CDP ports or URLs. */
|
||||
profiles?: Record<string, BrowserProfileConfig>;
|
||||
};
|
||||
|
||||
export type CronConfig = {
|
||||
enabled?: boolean;
|
||||
store?: string;
|
||||
maxConcurrentRuns?: number;
|
||||
};
|
||||
|
||||
export type HookMappingMatch = {
|
||||
path?: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type HookMappingTransform = {
|
||||
module: string;
|
||||
export?: string;
|
||||
};
|
||||
|
||||
export type HookMappingConfig = {
|
||||
id?: string;
|
||||
match?: HookMappingMatch;
|
||||
action?: "wake" | "agent";
|
||||
wakeMode?: "now" | "next-heartbeat";
|
||||
name?: string;
|
||||
sessionKey?: string;
|
||||
messageTemplate?: string;
|
||||
textTemplate?: string;
|
||||
deliver?: boolean;
|
||||
channel?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
to?: string;
|
||||
thinking?: string;
|
||||
timeoutSeconds?: number;
|
||||
transform?: HookMappingTransform;
|
||||
};
|
||||
|
||||
export type HooksGmailTailscaleMode = "off" | "serve" | "funnel";
|
||||
|
||||
export type HooksGmailConfig = {
|
||||
account?: string;
|
||||
label?: string;
|
||||
topic?: string;
|
||||
subscription?: string;
|
||||
pushToken?: string;
|
||||
hookUrl?: string;
|
||||
includeBody?: boolean;
|
||||
maxBytes?: number;
|
||||
renewEveryMinutes?: number;
|
||||
serve?: {
|
||||
bind?: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
};
|
||||
tailscale?: {
|
||||
mode?: HooksGmailTailscaleMode;
|
||||
path?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type HooksConfig = {
|
||||
enabled?: boolean;
|
||||
path?: string;
|
||||
token?: string;
|
||||
maxBodyBytes?: number;
|
||||
presets?: string[];
|
||||
transformsDir?: string;
|
||||
mappings?: HookMappingConfig[];
|
||||
gmail?: HooksGmailConfig;
|
||||
};
|
||||
|
||||
export type TelegramConfig = {
|
||||
/** If false, do not start the Telegram provider. Default: true. */
|
||||
enabled?: boolean;
|
||||
botToken?: string;
|
||||
/** Path to file containing bot token (for secret managers like agenix) */
|
||||
tokenFile?: string;
|
||||
/** Control reply threading when reply tags are present (off|first|all). */
|
||||
replyToMode?: ReplyToMode;
|
||||
groups?: Record<
|
||||
string,
|
||||
{
|
||||
requireMention?: boolean;
|
||||
}
|
||||
>;
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
mediaMaxMb?: number;
|
||||
proxy?: string;
|
||||
webhookUrl?: string;
|
||||
webhookSecret?: string;
|
||||
webhookPath?: string;
|
||||
};
|
||||
|
||||
export type DiscordDmConfig = {
|
||||
/** If false, ignore all incoming Discord DMs. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Allowlist for DM senders (ids or names). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** If true, allow group DMs (default: false). */
|
||||
groupEnabled?: boolean;
|
||||
/** Optional allowlist for group DM channels (ids or slugs). */
|
||||
groupChannels?: Array<string | number>;
|
||||
};
|
||||
|
||||
export type DiscordGuildChannelConfig = {
|
||||
allow?: boolean;
|
||||
requireMention?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordReactionNotificationMode =
|
||||
| "off"
|
||||
| "own"
|
||||
| "all"
|
||||
| "allowlist";
|
||||
|
||||
export type DiscordGuildEntry = {
|
||||
slug?: string;
|
||||
requireMention?: boolean;
|
||||
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
||||
reactionNotifications?: DiscordReactionNotificationMode;
|
||||
users?: Array<string | number>;
|
||||
channels?: Record<string, DiscordGuildChannelConfig>;
|
||||
};
|
||||
|
||||
export type DiscordSlashCommandConfig = {
|
||||
/** Enable handling for the configured slash command (default: false). */
|
||||
enabled?: boolean;
|
||||
/** Slash command name (default: "clawd"). */
|
||||
name?: string;
|
||||
/** Session key prefix for slash commands (default: "discord:slash"). */
|
||||
sessionPrefix?: string;
|
||||
/** Reply ephemerally (default: true). */
|
||||
ephemeral?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordActionConfig = {
|
||||
reactions?: boolean;
|
||||
stickers?: boolean;
|
||||
polls?: boolean;
|
||||
permissions?: boolean;
|
||||
messages?: boolean;
|
||||
threads?: boolean;
|
||||
pins?: boolean;
|
||||
search?: boolean;
|
||||
memberInfo?: boolean;
|
||||
roleInfo?: boolean;
|
||||
roles?: boolean;
|
||||
channelInfo?: boolean;
|
||||
voiceStatus?: boolean;
|
||||
events?: boolean;
|
||||
moderation?: boolean;
|
||||
emojiUploads?: boolean;
|
||||
stickerUploads?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordConfig = {
|
||||
/** If false, do not start the Discord provider. Default: true. */
|
||||
enabled?: boolean;
|
||||
token?: string;
|
||||
/** Outbound text chunk size (chars). Default: 2000. */
|
||||
textChunkLimit?: number;
|
||||
mediaMaxMb?: number;
|
||||
historyLimit?: number;
|
||||
/** Per-action tool gating (default: true for all). */
|
||||
actions?: DiscordActionConfig;
|
||||
/** Control reply threading when reply tags are present (off|first|all). */
|
||||
replyToMode?: ReplyToMode;
|
||||
slashCommand?: DiscordSlashCommandConfig;
|
||||
dm?: DiscordDmConfig;
|
||||
/** New per-guild config keyed by guild id or slug. */
|
||||
guilds?: Record<string, DiscordGuildEntry>;
|
||||
};
|
||||
|
||||
export type SignalConfig = {
|
||||
/** If false, do not start the Signal provider. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Optional explicit E.164 account for signal-cli. */
|
||||
account?: string;
|
||||
/** Optional full base URL for signal-cli HTTP daemon. */
|
||||
httpUrl?: string;
|
||||
/** HTTP host for signal-cli daemon (default 127.0.0.1). */
|
||||
httpHost?: string;
|
||||
/** HTTP port for signal-cli daemon (default 8080). */
|
||||
httpPort?: number;
|
||||
/** signal-cli binary path (default: signal-cli). */
|
||||
cliPath?: string;
|
||||
/** Auto-start signal-cli daemon (default: true if httpUrl not set). */
|
||||
autoStart?: boolean;
|
||||
receiveMode?: "on-start" | "manual";
|
||||
ignoreAttachments?: boolean;
|
||||
ignoreStories?: boolean;
|
||||
sendReadReceipts?: boolean;
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
mediaMaxMb?: number;
|
||||
};
|
||||
|
||||
export type IMessageConfig = {
|
||||
/** If false, do not start the iMessage provider. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** imsg CLI binary path (default: imsg). */
|
||||
cliPath?: string;
|
||||
/** Optional Messages db path override. */
|
||||
dbPath?: string;
|
||||
/** Optional default send service (imessage|sms|auto). */
|
||||
service?: "imessage" | "sms" | "auto";
|
||||
/** Optional default region (used when sending SMS). */
|
||||
region?: string;
|
||||
/** Optional allowlist for inbound handles or chat_id targets. */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Include attachments + reactions in watch payloads. */
|
||||
includeAttachments?: boolean;
|
||||
/** Max outbound media size in MB. */
|
||||
mediaMaxMb?: number;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
groups?: Record<
|
||||
string,
|
||||
{
|
||||
requireMention?: boolean;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export type QueueMode =
|
||||
| "steer"
|
||||
| "followup"
|
||||
| "collect"
|
||||
| "steer-backlog"
|
||||
| "steer+backlog"
|
||||
| "queue"
|
||||
| "interrupt";
|
||||
export type QueueDropPolicy = "old" | "new" | "summarize";
|
||||
|
||||
export type QueueModeBySurface = {
|
||||
whatsapp?: QueueMode;
|
||||
telegram?: QueueMode;
|
||||
discord?: QueueMode;
|
||||
signal?: QueueMode;
|
||||
imessage?: QueueMode;
|
||||
webchat?: QueueMode;
|
||||
};
|
||||
|
||||
export type GroupChatConfig = {
|
||||
mentionPatterns?: string[];
|
||||
historyLimit?: number;
|
||||
};
|
||||
|
||||
export type RoutingConfig = {
|
||||
transcribeAudio?: {
|
||||
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
||||
command: string[];
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
groupChat?: GroupChatConfig;
|
||||
queue?: {
|
||||
mode?: QueueMode;
|
||||
bySurface?: QueueModeBySurface;
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
drop?: QueueDropPolicy;
|
||||
};
|
||||
};
|
||||
|
||||
export type MessagesConfig = {
|
||||
messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdis]" if no allowFrom, else "")
|
||||
responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞")
|
||||
timestampPrefix?: boolean | string; // true/false or IANA timezone string (default: true with UTC)
|
||||
};
|
||||
|
||||
export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback";
|
||||
|
||||
export type BridgeConfig = {
|
||||
enabled?: boolean;
|
||||
port?: number;
|
||||
/**
|
||||
* Bind address policy for the node bridge server.
|
||||
* - auto: prefer tailnet IP when present, else LAN (0.0.0.0)
|
||||
* - lan: 0.0.0.0 (reachable on local network + any forwarded interfaces)
|
||||
* - tailnet: bind to the Tailscale interface IP (100.64.0.0/10) plus loopback
|
||||
* - loopback: 127.0.0.1
|
||||
*/
|
||||
bind?: BridgeBindMode;
|
||||
};
|
||||
|
||||
export type WideAreaDiscoveryConfig = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type DiscoveryConfig = {
|
||||
wideArea?: WideAreaDiscoveryConfig;
|
||||
};
|
||||
|
||||
export type CanvasHostConfig = {
|
||||
enabled?: boolean;
|
||||
/** Directory to serve (default: ~/clawd/canvas). */
|
||||
root?: string;
|
||||
/** HTTP port to listen on (default: 18793). */
|
||||
port?: number;
|
||||
};
|
||||
|
||||
export type TalkConfig = {
|
||||
/** Default ElevenLabs voice ID for Talk mode. */
|
||||
voiceId?: string;
|
||||
/** Optional voice name -> ElevenLabs voice ID map. */
|
||||
voiceAliases?: Record<string, string>;
|
||||
/** Default ElevenLabs model ID for Talk mode. */
|
||||
modelId?: string;
|
||||
/** Default ElevenLabs output format (e.g. mp3_44100_128). */
|
||||
outputFormat?: string;
|
||||
/** ElevenLabs API key (optional; falls back to ELEVENLABS_API_KEY). */
|
||||
apiKey?: string;
|
||||
/** Stop speaking when user starts talking (default: true). */
|
||||
interruptOnSpeech?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayControlUiConfig = {
|
||||
/** If false, the Gateway will not serve the Control UI (default /). */
|
||||
enabled?: boolean;
|
||||
/** Optional base path prefix for the Control UI (e.g. "/clawdis"). */
|
||||
basePath?: string;
|
||||
};
|
||||
|
||||
export type GatewayAuthMode = "token" | "password";
|
||||
|
||||
export type GatewayAuthConfig = {
|
||||
/** Authentication mode for Gateway connections. Defaults to token when set. */
|
||||
mode?: GatewayAuthMode;
|
||||
/** Shared token for token mode (stored locally for CLI auth). */
|
||||
token?: string;
|
||||
/** Shared password for password mode (consider env instead). */
|
||||
password?: string;
|
||||
/** Allow Tailscale identity headers when serve mode is enabled. */
|
||||
allowTailscale?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayTailscaleMode = "off" | "serve" | "funnel";
|
||||
|
||||
export type GatewayTailscaleConfig = {
|
||||
/** Tailscale exposure mode for the Gateway control UI. */
|
||||
mode?: GatewayTailscaleMode;
|
||||
/** Reset serve/funnel configuration on shutdown. */
|
||||
resetOnExit?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayRemoteConfig = {
|
||||
/** Remote Gateway WebSocket URL (ws:// or wss://). */
|
||||
url?: string;
|
||||
/** Token for remote auth (when the gateway requires token auth). */
|
||||
token?: string;
|
||||
/** Password for remote auth (when the gateway requires password auth). */
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type GatewayReloadMode = "off" | "restart" | "hot" | "hybrid";
|
||||
|
||||
export type GatewayReloadConfig = {
|
||||
/** Reload strategy for config changes (default: hybrid). */
|
||||
mode?: GatewayReloadMode;
|
||||
/** Debounce window for config reloads (ms). Default: 300. */
|
||||
debounceMs?: number;
|
||||
};
|
||||
|
||||
export type GatewayConfig = {
|
||||
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
|
||||
port?: number;
|
||||
/**
|
||||
* Explicit gateway mode. When set to "remote", local gateway start is disabled.
|
||||
* When set to "local", the CLI may start the gateway locally.
|
||||
*/
|
||||
mode?: "local" | "remote";
|
||||
/**
|
||||
* Bind address policy for the Gateway WebSocket + Control UI HTTP server.
|
||||
* Default: loopback (127.0.0.1).
|
||||
*/
|
||||
bind?: BridgeBindMode;
|
||||
controlUi?: GatewayControlUiConfig;
|
||||
auth?: GatewayAuthConfig;
|
||||
tailscale?: GatewayTailscaleConfig;
|
||||
remote?: GatewayRemoteConfig;
|
||||
reload?: GatewayReloadConfig;
|
||||
};
|
||||
|
||||
export type SkillConfig = {
|
||||
enabled?: boolean;
|
||||
apiKey?: string;
|
||||
env?: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type SkillsLoadConfig = {
|
||||
/**
|
||||
* Additional skill folders to scan (lowest precedence).
|
||||
* Each directory should contain skill subfolders with `SKILL.md`.
|
||||
*/
|
||||
extraDirs?: string[];
|
||||
};
|
||||
|
||||
export type SkillsInstallConfig = {
|
||||
preferBrew?: boolean;
|
||||
nodeManager?: "npm" | "pnpm" | "yarn" | "bun";
|
||||
};
|
||||
|
||||
export type SkillsConfig = {
|
||||
/** Optional bundled-skill allowlist (only affects bundled skills). */
|
||||
allowBundled?: string[];
|
||||
load?: SkillsLoadConfig;
|
||||
install?: SkillsInstallConfig;
|
||||
entries?: Record<string, SkillConfig>;
|
||||
};
|
||||
|
||||
export type ModelApi =
|
||||
| "openai-completions"
|
||||
| "openai-responses"
|
||||
| "anthropic-messages"
|
||||
| "google-generative-ai";
|
||||
|
||||
export type ModelCompatConfig = {
|
||||
supportsStore?: boolean;
|
||||
supportsDeveloperRole?: boolean;
|
||||
supportsReasoningEffort?: boolean;
|
||||
maxTokensField?: "max_completion_tokens" | "max_tokens";
|
||||
};
|
||||
|
||||
export type ModelDefinitionConfig = {
|
||||
id: string;
|
||||
name: string;
|
||||
api?: ModelApi;
|
||||
reasoning: boolean;
|
||||
input: Array<"text" | "image">;
|
||||
cost: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
};
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
headers?: Record<string, string>;
|
||||
compat?: ModelCompatConfig;
|
||||
};
|
||||
|
||||
export type ModelProviderConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
api?: ModelApi;
|
||||
headers?: Record<string, string>;
|
||||
authHeader?: boolean;
|
||||
models: ModelDefinitionConfig[];
|
||||
};
|
||||
|
||||
export type ModelsConfig = {
|
||||
mode?: "merge" | "replace";
|
||||
providers?: Record<string, ModelProviderConfig>;
|
||||
};
|
||||
|
||||
export type ClawdisConfig = {
|
||||
identity?: {
|
||||
name?: string;
|
||||
theme?: string;
|
||||
emoji?: string;
|
||||
};
|
||||
wizard?: {
|
||||
lastRunAt?: string;
|
||||
lastRunVersion?: string;
|
||||
lastRunCommit?: string;
|
||||
lastRunCommand?: string;
|
||||
lastRunMode?: "local" | "remote";
|
||||
};
|
||||
logging?: LoggingConfig;
|
||||
browser?: BrowserConfig;
|
||||
ui?: {
|
||||
/** Accent color for Clawdis UI chrome (hex). */
|
||||
seamColor?: string;
|
||||
};
|
||||
skills?: SkillsConfig;
|
||||
models?: ModelsConfig;
|
||||
agent?: {
|
||||
/** Model id (provider/model), e.g. "anthropic/claude-opus-4-5". */
|
||||
model?: string;
|
||||
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
|
||||
workspace?: string;
|
||||
/** Optional allowlist for /model (provider/model or model-only). */
|
||||
allowedModels?: string[];
|
||||
/** Optional model aliases for /model (alias -> provider/model). */
|
||||
modelAliases?: Record<string, string>;
|
||||
/** Optional display-only context window override (used for % in status UIs). */
|
||||
contextTokens?: number;
|
||||
/** Default thinking level when no /think directive is present. */
|
||||
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high";
|
||||
/** Default verbose level when no /verbose directive is present. */
|
||||
verboseDefault?: "off" | "on";
|
||||
/** Default elevated level when no /elevated directive is present. */
|
||||
elevatedDefault?: "off" | "on";
|
||||
/** Default block streaming level when no override is present. */
|
||||
blockStreamingDefault?: "off" | "on";
|
||||
/**
|
||||
* Block streaming boundary:
|
||||
* - "text_end": end of each assistant text content block (before tool calls)
|
||||
* - "message_end": end of the whole assistant message (may include tool blocks)
|
||||
*/
|
||||
blockStreamingBreak?: "text_end" | "message_end";
|
||||
/** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */
|
||||
blockStreamingChunk?: {
|
||||
minChars?: number;
|
||||
maxChars?: number;
|
||||
breakPreference?: "paragraph" | "newline" | "sentence";
|
||||
};
|
||||
timeoutSeconds?: number;
|
||||
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
|
||||
mediaMaxMb?: number;
|
||||
typingIntervalSeconds?: number;
|
||||
/** Periodic background heartbeat runs. */
|
||||
heartbeat?: {
|
||||
/** Heartbeat interval (duration string, default unit: minutes). */
|
||||
every?: string;
|
||||
/** Heartbeat model override (provider/model). */
|
||||
model?: string;
|
||||
/** Delivery target (last|whatsapp|telegram|discord|signal|imessage|none). */
|
||||
target?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "none";
|
||||
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
|
||||
to?: string;
|
||||
/** Override the heartbeat prompt body (default: "HEARTBEAT"). */
|
||||
prompt?: string;
|
||||
};
|
||||
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
|
||||
maxConcurrent?: number;
|
||||
/** Bash tool defaults. */
|
||||
bash?: {
|
||||
/** Default time (ms) before a bash command auto-backgrounds. */
|
||||
backgroundMs?: number;
|
||||
/** Default timeout (seconds) before auto-killing bash commands. */
|
||||
timeoutSec?: number;
|
||||
/** How long to keep finished sessions in memory (ms). */
|
||||
cleanupMs?: number;
|
||||
};
|
||||
/** Elevated bash permissions for the host machine. */
|
||||
elevated?: {
|
||||
/** Enable or disable elevated mode (default: true). */
|
||||
enabled?: boolean;
|
||||
/** Approved senders for /elevated (per-surface allowlists). */
|
||||
allowFrom?: AgentElevatedAllowFromConfig;
|
||||
};
|
||||
/** Optional sandbox settings for non-main sessions. */
|
||||
sandbox?: {
|
||||
/** Enable sandboxing for sessions. */
|
||||
mode?: "off" | "non-main" | "all";
|
||||
/** Use one container per session (recommended for hard isolation). */
|
||||
perSession?: boolean;
|
||||
/** Root directory for sandbox workspaces. */
|
||||
workspaceRoot?: string;
|
||||
/** Docker-specific sandbox settings. */
|
||||
docker?: {
|
||||
/** Docker image to use for sandbox containers. */
|
||||
image?: string;
|
||||
/** Prefix for sandbox container names. */
|
||||
containerPrefix?: string;
|
||||
/** Container workdir mount path (default: /workspace). */
|
||||
workdir?: string;
|
||||
/** Run container rootfs read-only. */
|
||||
readOnlyRoot?: boolean;
|
||||
/** Extra tmpfs mounts for read-only containers. */
|
||||
tmpfs?: string[];
|
||||
/** Container network mode (bridge|none|custom). */
|
||||
network?: string;
|
||||
/** Container user (uid:gid). */
|
||||
user?: string;
|
||||
/** Drop Linux capabilities. */
|
||||
capDrop?: string[];
|
||||
/** Extra environment variables for sandbox exec. */
|
||||
env?: Record<string, string>;
|
||||
/** Optional setup command run once after container creation. */
|
||||
setupCommand?: string;
|
||||
};
|
||||
/** Optional sandboxed browser settings. */
|
||||
browser?: {
|
||||
enabled?: boolean;
|
||||
image?: string;
|
||||
containerPrefix?: string;
|
||||
cdpPort?: number;
|
||||
vncPort?: number;
|
||||
noVncPort?: number;
|
||||
headless?: boolean;
|
||||
enableNoVnc?: boolean;
|
||||
};
|
||||
/** Tool allow/deny policy (deny wins). */
|
||||
tools?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
/** Auto-prune sandbox containers. */
|
||||
prune?: {
|
||||
/** Prune if idle for more than N hours (0 disables). */
|
||||
idleHours?: number;
|
||||
/** Prune if older than N days (0 disables). */
|
||||
maxAgeDays?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
routing?: RoutingConfig;
|
||||
messages?: MessagesConfig;
|
||||
session?: SessionConfig;
|
||||
web?: WebConfig;
|
||||
whatsapp?: WhatsAppConfig;
|
||||
telegram?: TelegramConfig;
|
||||
discord?: DiscordConfig;
|
||||
signal?: SignalConfig;
|
||||
imessage?: IMessageConfig;
|
||||
cron?: CronConfig;
|
||||
hooks?: HooksConfig;
|
||||
bridge?: BridgeConfig;
|
||||
discovery?: DiscoveryConfig;
|
||||
canvasHost?: CanvasHostConfig;
|
||||
talk?: TalkConfig;
|
||||
gateway?: GatewayConfig;
|
||||
};
|
||||
|
||||
export type ConfigValidationIssue = {
|
||||
path: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type LegacyConfigIssue = {
|
||||
path: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ConfigFileSnapshot = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
raw: string | null;
|
||||
parsed: unknown;
|
||||
valid: boolean;
|
||||
config: ClawdisConfig;
|
||||
issues: ConfigValidationIssue[];
|
||||
legacyIssues: LegacyConfigIssue[];
|
||||
};
|
||||
37
src/config/validation.ts
Normal file
37
src/config/validation.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { applyIdentityDefaults, applySessionDefaults } from "./defaults.js";
|
||||
import { findLegacyConfigIssues } from "./legacy.js";
|
||||
import type { ClawdisConfig, ConfigValidationIssue } from "./types.js";
|
||||
import { ClawdisSchema } from "./zod-schema.js";
|
||||
|
||||
export function validateConfigObject(
|
||||
raw: unknown,
|
||||
):
|
||||
| { ok: true; config: ClawdisConfig }
|
||||
| { ok: false; issues: ConfigValidationIssue[] } {
|
||||
const legacyIssues = findLegacyConfigIssues(raw);
|
||||
if (legacyIssues.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
issues: legacyIssues.map((iss) => ({
|
||||
path: iss.path,
|
||||
message: iss.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const validated = ClawdisSchema.safeParse(raw);
|
||||
if (!validated.success) {
|
||||
return {
|
||||
ok: false,
|
||||
issues: validated.error.issues.map((iss) => ({
|
||||
path: iss.path.join("."),
|
||||
message: iss.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
config: applySessionDefaults(
|
||||
applyIdentityDefaults(validated.data as ClawdisConfig),
|
||||
),
|
||||
};
|
||||
}
|
||||
798
src/config/zod-schema.ts
Normal file
798
src/config/zod-schema.ts
Normal file
@@ -0,0 +1,798 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
|
||||
const ModelApiSchema = z.union([
|
||||
z.literal("openai-completions"),
|
||||
z.literal("openai-responses"),
|
||||
z.literal("anthropic-messages"),
|
||||
z.literal("google-generative-ai"),
|
||||
]);
|
||||
|
||||
const ModelCompatSchema = z
|
||||
.object({
|
||||
supportsStore: z.boolean().optional(),
|
||||
supportsDeveloperRole: z.boolean().optional(),
|
||||
supportsReasoningEffort: z.boolean().optional(),
|
||||
maxTokensField: z
|
||||
.union([z.literal("max_completion_tokens"), z.literal("max_tokens")])
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const ModelDefinitionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
api: ModelApiSchema.optional(),
|
||||
reasoning: z.boolean(),
|
||||
input: z.array(z.union([z.literal("text"), z.literal("image")])),
|
||||
cost: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cacheRead: z.number(),
|
||||
cacheWrite: z.number(),
|
||||
}),
|
||||
contextWindow: z.number().positive(),
|
||||
maxTokens: z.number().positive(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
compat: ModelCompatSchema,
|
||||
});
|
||||
|
||||
const ModelProviderSchema = z.object({
|
||||
baseUrl: z.string().min(1),
|
||||
apiKey: z.string().min(1),
|
||||
api: ModelApiSchema.optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
authHeader: z.boolean().optional(),
|
||||
models: z.array(ModelDefinitionSchema),
|
||||
});
|
||||
|
||||
const ModelsConfigSchema = z
|
||||
.object({
|
||||
mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
|
||||
providers: z.record(z.string(), ModelProviderSchema).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const GroupChatSchema = z
|
||||
.object({
|
||||
mentionPatterns: z.array(z.string()).optional(),
|
||||
historyLimit: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const QueueModeSchema = z.union([
|
||||
z.literal("steer"),
|
||||
z.literal("followup"),
|
||||
z.literal("collect"),
|
||||
z.literal("steer-backlog"),
|
||||
z.literal("steer+backlog"),
|
||||
z.literal("queue"),
|
||||
z.literal("interrupt"),
|
||||
]);
|
||||
const QueueDropSchema = z.union([
|
||||
z.literal("old"),
|
||||
z.literal("new"),
|
||||
z.literal("summarize"),
|
||||
]);
|
||||
const ReplyToModeSchema = z.union([
|
||||
z.literal("off"),
|
||||
z.literal("first"),
|
||||
z.literal("all"),
|
||||
]);
|
||||
|
||||
const QueueModeBySurfaceSchema = z
|
||||
.object({
|
||||
whatsapp: QueueModeSchema.optional(),
|
||||
telegram: QueueModeSchema.optional(),
|
||||
discord: QueueModeSchema.optional(),
|
||||
signal: QueueModeSchema.optional(),
|
||||
imessage: QueueModeSchema.optional(),
|
||||
webchat: QueueModeSchema.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const TranscribeAudioSchema = z
|
||||
.object({
|
||||
command: z.array(z.string()),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const HexColorSchema = z
|
||||
.string()
|
||||
.regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)");
|
||||
|
||||
const SessionSchema = z
|
||||
.object({
|
||||
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
|
||||
resetTriggers: z.array(z.string()).optional(),
|
||||
idleMinutes: z.number().int().positive().optional(),
|
||||
heartbeatIdleMinutes: z.number().int().positive().optional(),
|
||||
store: z.string().optional(),
|
||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||
mainKey: z.string().optional(),
|
||||
sendPolicy: z
|
||||
.object({
|
||||
default: z.union([z.literal("allow"), z.literal("deny")]).optional(),
|
||||
rules: z
|
||||
.array(
|
||||
z.object({
|
||||
action: z.union([z.literal("allow"), z.literal("deny")]),
|
||||
match: z
|
||||
.object({
|
||||
surface: z.string().optional(),
|
||||
chatType: z
|
||||
.union([
|
||||
z.literal("direct"),
|
||||
z.literal("group"),
|
||||
z.literal("room"),
|
||||
])
|
||||
.optional(),
|
||||
keyPrefix: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
agentToAgent: z
|
||||
.object({
|
||||
maxPingPongTurns: z.number().int().min(0).max(5).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const MessagesSchema = z
|
||||
.object({
|
||||
messagePrefix: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
timestampPrefix: z.union([z.boolean(), z.string()]).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const HeartbeatSchema = z
|
||||
.object({
|
||||
every: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
target: z
|
||||
.union([
|
||||
z.literal("last"),
|
||||
z.literal("whatsapp"),
|
||||
z.literal("telegram"),
|
||||
z.literal("discord"),
|
||||
z.literal("signal"),
|
||||
z.literal("imessage"),
|
||||
z.literal("none"),
|
||||
])
|
||||
.optional(),
|
||||
to: z.string().optional(),
|
||||
prompt: z.string().optional(),
|
||||
})
|
||||
.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)",
|
||||
});
|
||||
}
|
||||
})
|
||||
.optional();
|
||||
|
||||
const RoutingSchema = z
|
||||
.object({
|
||||
groupChat: GroupChatSchema,
|
||||
transcribeAudio: TranscribeAudioSchema,
|
||||
queue: z
|
||||
.object({
|
||||
mode: QueueModeSchema.optional(),
|
||||
bySurface: QueueModeBySurfaceSchema,
|
||||
debounceMs: z.number().int().nonnegative().optional(),
|
||||
cap: z.number().int().positive().optional(),
|
||||
drop: QueueDropSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const HookMappingSchema = z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
match: z
|
||||
.object({
|
||||
path: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
action: z.union([z.literal("wake"), z.literal("agent")]).optional(),
|
||||
wakeMode: z
|
||||
.union([z.literal("now"), z.literal("next-heartbeat")])
|
||||
.optional(),
|
||||
name: z.string().optional(),
|
||||
sessionKey: z.string().optional(),
|
||||
messageTemplate: z.string().optional(),
|
||||
textTemplate: z.string().optional(),
|
||||
deliver: z.boolean().optional(),
|
||||
channel: z
|
||||
.union([
|
||||
z.literal("last"),
|
||||
z.literal("whatsapp"),
|
||||
z.literal("telegram"),
|
||||
z.literal("discord"),
|
||||
z.literal("signal"),
|
||||
z.literal("imessage"),
|
||||
])
|
||||
.optional(),
|
||||
to: z.string().optional(),
|
||||
thinking: z.string().optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
transform: z
|
||||
.object({
|
||||
module: z.string(),
|
||||
export: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const HooksGmailSchema = z
|
||||
.object({
|
||||
account: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
topic: z.string().optional(),
|
||||
subscription: z.string().optional(),
|
||||
pushToken: z.string().optional(),
|
||||
hookUrl: z.string().optional(),
|
||||
includeBody: z.boolean().optional(),
|
||||
maxBytes: z.number().int().positive().optional(),
|
||||
renewEveryMinutes: z.number().int().positive().optional(),
|
||||
serve: z
|
||||
.object({
|
||||
bind: z.string().optional(),
|
||||
port: z.number().int().positive().optional(),
|
||||
path: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
tailscale: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([z.literal("off"), z.literal("serve"), z.literal("funnel")])
|
||||
.optional(),
|
||||
path: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const ClawdisSchema = z.object({
|
||||
identity: z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
theme: z.string().optional(),
|
||||
emoji: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
wizard: z
|
||||
.object({
|
||||
lastRunAt: z.string().optional(),
|
||||
lastRunVersion: z.string().optional(),
|
||||
lastRunCommit: z.string().optional(),
|
||||
lastRunCommand: z.string().optional(),
|
||||
lastRunMode: z
|
||||
.union([z.literal("local"), z.literal("remote")])
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
logging: z
|
||||
.object({
|
||||
level: z
|
||||
.union([
|
||||
z.literal("silent"),
|
||||
z.literal("fatal"),
|
||||
z.literal("error"),
|
||||
z.literal("warn"),
|
||||
z.literal("info"),
|
||||
z.literal("debug"),
|
||||
z.literal("trace"),
|
||||
])
|
||||
.optional(),
|
||||
file: z.string().optional(),
|
||||
consoleLevel: z
|
||||
.union([
|
||||
z.literal("silent"),
|
||||
z.literal("fatal"),
|
||||
z.literal("error"),
|
||||
z.literal("warn"),
|
||||
z.literal("info"),
|
||||
z.literal("debug"),
|
||||
z.literal("trace"),
|
||||
])
|
||||
.optional(),
|
||||
consoleStyle: z
|
||||
.union([z.literal("pretty"), z.literal("compact"), z.literal("json")])
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
browser: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
controlUrl: z.string().optional(),
|
||||
cdpUrl: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
executablePath: z.string().optional(),
|
||||
headless: z.boolean().optional(),
|
||||
noSandbox: z.boolean().optional(),
|
||||
attachOnly: z.boolean().optional(),
|
||||
defaultProfile: z.string().optional(),
|
||||
profiles: z
|
||||
.record(
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^[a-z0-9-]+$/,
|
||||
"Profile names must be alphanumeric with hyphens only",
|
||||
),
|
||||
z
|
||||
.object({
|
||||
cdpPort: z.number().int().min(1).max(65535).optional(),
|
||||
cdpUrl: z.string().optional(),
|
||||
color: HexColorSchema,
|
||||
})
|
||||
.refine((value) => value.cdpPort || value.cdpUrl, {
|
||||
message: "Profile must set cdpPort or cdpUrl",
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
ui: z
|
||||
.object({
|
||||
seamColor: HexColorSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
models: ModelsConfigSchema,
|
||||
agent: z
|
||||
.object({
|
||||
model: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
allowedModels: z.array(z.string()).optional(),
|
||||
modelAliases: z.record(z.string(), z.string()).optional(),
|
||||
contextTokens: z.number().int().positive().optional(),
|
||||
thinkingDefault: z
|
||||
.union([
|
||||
z.literal("off"),
|
||||
z.literal("minimal"),
|
||||
z.literal("low"),
|
||||
z.literal("medium"),
|
||||
z.literal("high"),
|
||||
])
|
||||
.optional(),
|
||||
verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
|
||||
elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
|
||||
blockStreamingDefault: z
|
||||
.union([z.literal("off"), z.literal("on")])
|
||||
.optional(),
|
||||
blockStreamingBreak: z
|
||||
.union([z.literal("text_end"), z.literal("message_end")])
|
||||
.optional(),
|
||||
blockStreamingChunk: z
|
||||
.object({
|
||||
minChars: z.number().int().positive().optional(),
|
||||
maxChars: z.number().int().positive().optional(),
|
||||
breakPreference: z
|
||||
.union([
|
||||
z.literal("paragraph"),
|
||||
z.literal("newline"),
|
||||
z.literal("sentence"),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||
heartbeat: HeartbeatSchema,
|
||||
maxConcurrent: z.number().int().positive().optional(),
|
||||
bash: z
|
||||
.object({
|
||||
backgroundMs: z.number().int().positive().optional(),
|
||||
timeoutSec: z.number().int().positive().optional(),
|
||||
cleanupMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
elevated: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z
|
||||
.object({
|
||||
whatsapp: z.array(z.string()).optional(),
|
||||
telegram: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
discord: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
signal: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
imessage: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
webchat: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
sandbox: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
|
||||
.optional(),
|
||||
perSession: z.boolean().optional(),
|
||||
workspaceRoot: z.string().optional(),
|
||||
docker: 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(),
|
||||
})
|
||||
.optional(),
|
||||
browser: 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(),
|
||||
})
|
||||
.optional(),
|
||||
tools: z
|
||||
.object({
|
||||
allow: z.array(z.string()).optional(),
|
||||
deny: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
prune: z
|
||||
.object({
|
||||
idleHours: z.number().int().nonnegative().optional(),
|
||||
maxAgeDays: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
routing: RoutingSchema,
|
||||
messages: MessagesSchema,
|
||||
session: SessionSchema,
|
||||
cron: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
store: z.string().optional(),
|
||||
maxConcurrentRuns: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
hooks: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
path: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
maxBodyBytes: z.number().int().positive().optional(),
|
||||
presets: z.array(z.string()).optional(),
|
||||
transformsDir: z.string().optional(),
|
||||
mappings: z.array(HookMappingSchema).optional(),
|
||||
gmail: HooksGmailSchema,
|
||||
})
|
||||
.optional(),
|
||||
web: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
heartbeatSeconds: z.number().int().positive().optional(),
|
||||
reconnect: z
|
||||
.object({
|
||||
initialMs: z.number().positive().optional(),
|
||||
maxMs: z.number().positive().optional(),
|
||||
factor: z.number().positive().optional(),
|
||||
jitter: z.number().min(0).max(1).optional(),
|
||||
maxAttempts: z.number().int().min(0).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
whatsapp: z
|
||||
.object({
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
groups: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
telegram: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
botToken: z.string().optional(),
|
||||
tokenFile: z.string().optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
groups: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
proxy: z.string().optional(),
|
||||
webhookUrl: z.string().optional(),
|
||||
webhookSecret: z.string().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
discord: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
token: z.string().optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
slashCommand: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
sessionPrefix: z.string().optional(),
|
||||
ephemeral: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
actions: z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
stickers: z.boolean().optional(),
|
||||
polls: z.boolean().optional(),
|
||||
permissions: z.boolean().optional(),
|
||||
messages: z.boolean().optional(),
|
||||
threads: z.boolean().optional(),
|
||||
pins: z.boolean().optional(),
|
||||
search: z.boolean().optional(),
|
||||
memberInfo: z.boolean().optional(),
|
||||
roleInfo: z.boolean().optional(),
|
||||
roles: z.boolean().optional(),
|
||||
channelInfo: z.boolean().optional(),
|
||||
voiceStatus: z.boolean().optional(),
|
||||
events: z.boolean().optional(),
|
||||
moderation: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
dm: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupEnabled: z.boolean().optional(),
|
||||
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
})
|
||||
.optional(),
|
||||
guilds: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
slug: z.string().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
reactionNotifications: z
|
||||
.enum(["off", "own", "all", "allowlist"])
|
||||
.optional(),
|
||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
channels: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
allow: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
signal: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
account: z.string().optional(),
|
||||
httpUrl: z.string().optional(),
|
||||
httpHost: z.string().optional(),
|
||||
httpPort: z.number().int().positive().optional(),
|
||||
cliPath: z.string().optional(),
|
||||
autoStart: z.boolean().optional(),
|
||||
receiveMode: z
|
||||
.union([z.literal("on-start"), z.literal("manual")])
|
||||
.optional(),
|
||||
ignoreAttachments: z.boolean().optional(),
|
||||
ignoreStories: z.boolean().optional(),
|
||||
sendReadReceipts: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
imessage: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
cliPath: z.string().optional(),
|
||||
dbPath: z.string().optional(),
|
||||
service: z
|
||||
.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")])
|
||||
.optional(),
|
||||
region: z.string().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
includeAttachments: z.boolean().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
groups: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
bridge: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
port: z.number().int().positive().optional(),
|
||||
bind: z
|
||||
.union([
|
||||
z.literal("auto"),
|
||||
z.literal("lan"),
|
||||
z.literal("tailnet"),
|
||||
z.literal("loopback"),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
discovery: z
|
||||
.object({
|
||||
wideArea: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
canvasHost: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
root: z.string().optional(),
|
||||
port: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
talk: z
|
||||
.object({
|
||||
voiceId: z.string().optional(),
|
||||
voiceAliases: z.record(z.string(), z.string()).optional(),
|
||||
modelId: z.string().optional(),
|
||||
outputFormat: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
interruptOnSpeech: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
gateway: z
|
||||
.object({
|
||||
port: z.number().int().positive().optional(),
|
||||
mode: z.union([z.literal("local"), z.literal("remote")]).optional(),
|
||||
bind: z
|
||||
.union([
|
||||
z.literal("auto"),
|
||||
z.literal("lan"),
|
||||
z.literal("tailnet"),
|
||||
z.literal("loopback"),
|
||||
])
|
||||
.optional(),
|
||||
controlUi: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
basePath: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
auth: z
|
||||
.object({
|
||||
mode: z.union([z.literal("token"), z.literal("password")]).optional(),
|
||||
token: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
allowTailscale: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
tailscale: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([z.literal("off"), z.literal("serve"), z.literal("funnel")])
|
||||
.optional(),
|
||||
resetOnExit: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
remote: z
|
||||
.object({
|
||||
url: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
reload: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([
|
||||
z.literal("off"),
|
||||
z.literal("restart"),
|
||||
z.literal("hot"),
|
||||
z.literal("hybrid"),
|
||||
])
|
||||
.optional(),
|
||||
debounceMs: z.number().int().min(0).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
skills: z
|
||||
.object({
|
||||
allowBundled: z.array(z.string()).optional(),
|
||||
load: z
|
||||
.object({
|
||||
extraDirs: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
install: z
|
||||
.object({
|
||||
preferBrew: z.boolean().optional(),
|
||||
nodeManager: z
|
||||
.union([
|
||||
z.literal("npm"),
|
||||
z.literal("pnpm"),
|
||||
z.literal("yarn"),
|
||||
z.literal("bun"),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
entries: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
@@ -378,16 +378,8 @@ describe("web auto-reply", () => {
|
||||
await vi.advanceTimersByTimeAsync(31 * 60 * 1000);
|
||||
await Promise.resolve();
|
||||
|
||||
const waitForSecondCall = async () => {
|
||||
const started = Date.now();
|
||||
while (
|
||||
listenerFactory.mock.calls.length < 2 &&
|
||||
Date.now() - started < 200
|
||||
) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
};
|
||||
await waitForSecondCall();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await Promise.resolve();
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(2);
|
||||
|
||||
controller.abort();
|
||||
|
||||
Reference in New Issue
Block a user