Merge branch 'main' into feat/mattermost-channel
This commit is contained in:
54
src/config/config.identity-avatar.test.ts
Normal file
54
src/config/config.identity-avatar.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { validateConfigObject } from "./config.js";
|
||||
import { withTempHome } from "./test-helpers.js";
|
||||
|
||||
describe("identity avatar validation", () => {
|
||||
it("accepts workspace-relative avatar paths", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const workspace = path.join(home, "clawd");
|
||||
const res = validateConfigObject({
|
||||
agents: {
|
||||
list: [{ id: "main", workspace, identity: { avatar: "avatars/clawd.png" } }],
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts http(s) and data avatars", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const workspace = path.join(home, "clawd");
|
||||
const httpRes = validateConfigObject({
|
||||
agents: {
|
||||
list: [{ id: "main", workspace, identity: { avatar: "https://example.com/avatar.png" } }],
|
||||
},
|
||||
});
|
||||
expect(httpRes.ok).toBe(true);
|
||||
|
||||
const dataRes = validateConfigObject({
|
||||
agents: {
|
||||
list: [{ id: "main", workspace, identity: { avatar: "data:image/png;base64,AAA" } }],
|
||||
},
|
||||
});
|
||||
expect(dataRes.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects avatar paths outside workspace", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const workspace = path.join(home, "clawd");
|
||||
const res = validateConfigObject({
|
||||
agents: {
|
||||
list: [{ id: "main", workspace, identity: { avatar: "../oops.png" } }],
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("agents.list.0.identity.avatar");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,21 +23,33 @@ describe("legacy config detection", () => {
|
||||
expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention");
|
||||
}
|
||||
});
|
||||
it("migrates routing.allowFrom to channels.whatsapp.allowFrom", async () => {
|
||||
it("migrates routing.allowFrom to channels.whatsapp.allowFrom when whatsapp configured", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
routing: { allowFrom: ["+15555550123"] },
|
||||
channels: { whatsapp: {} },
|
||||
});
|
||||
expect(res.changes).toContain("Moved routing.allowFrom → channels.whatsapp.allowFrom.");
|
||||
expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
|
||||
expect(res.config?.routing?.allowFrom).toBeUndefined();
|
||||
});
|
||||
it("migrates routing.groupChat.requireMention to channels whatsapp/telegram/imessage groups", async () => {
|
||||
it("drops routing.allowFrom when whatsapp missing", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
routing: { allowFrom: ["+15555550123"] },
|
||||
});
|
||||
expect(res.changes).toContain("Removed routing.allowFrom (channels.whatsapp not configured).");
|
||||
expect(res.config?.channels?.whatsapp).toBeUndefined();
|
||||
expect(res.config?.routing?.allowFrom).toBeUndefined();
|
||||
});
|
||||
it("migrates routing.groupChat.requireMention to channels whatsapp/telegram/imessage groups when whatsapp configured", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
routing: { groupChat: { requireMention: false } },
|
||||
channels: { whatsapp: {} },
|
||||
});
|
||||
expect(res.changes).toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
|
||||
@@ -53,6 +65,26 @@ describe("legacy config detection", () => {
|
||||
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
|
||||
});
|
||||
it("migrates routing.groupChat.requireMention to telegram/imessage when whatsapp missing", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
routing: { groupChat: { requireMention: false } },
|
||||
});
|
||||
expect(res.changes).toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.changes).not.toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.config?.channels?.whatsapp).toBeUndefined();
|
||||
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
|
||||
});
|
||||
it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
@@ -218,14 +250,20 @@ describe("legacy config detection", () => {
|
||||
expect(res.config?.gateway?.auth?.mode).toBe("token");
|
||||
expect((res.config?.gateway as { token?: string })?.token).toBeUndefined();
|
||||
});
|
||||
it("migrates gateway.bind from 'tailnet' to 'auto'", async () => {
|
||||
it("keeps gateway.bind tailnet", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const { migrateLegacyConfig, validateConfigObject } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
gateway: { bind: "tailnet" as const },
|
||||
});
|
||||
expect(res.changes).toContain("Migrated gateway.bind from 'tailnet' to 'auto'.");
|
||||
expect(res.config?.gateway?.bind).toBe("auto");
|
||||
expect(res.changes).not.toContain("Migrated gateway.bind from 'tailnet' to 'auto'.");
|
||||
expect(res.config).toBeNull();
|
||||
|
||||
const validated = validateConfigObject({ gateway: { bind: "tailnet" as const } });
|
||||
expect(validated.ok).toBe(true);
|
||||
if (validated.ok) {
|
||||
expect(validated.config.gateway?.bind).toBe("tailnet");
|
||||
}
|
||||
});
|
||||
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
@@ -4,7 +4,11 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "./test-helpers.js";
|
||||
|
||||
describe("config pruning defaults", () => {
|
||||
it("defaults contextPruning mode to adaptive", async () => {
|
||||
it("does not enable contextPruning by default", async () => {
|
||||
const prevApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
const prevOauthToken = process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
process.env.ANTHROPIC_API_KEY = "";
|
||||
process.env.ANTHROPIC_OAUTH_TOKEN = "";
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
@@ -18,7 +22,86 @@ describe("config pruning defaults", () => {
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("adaptive");
|
||||
expect(cfg.agents?.defaults?.contextPruning?.mode).toBeUndefined();
|
||||
});
|
||||
if (prevApiKey === undefined) {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
} else {
|
||||
process.env.ANTHROPIC_API_KEY = prevApiKey;
|
||||
}
|
||||
if (prevOauthToken === undefined) {
|
||||
delete process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
} else {
|
||||
process.env.ANTHROPIC_OAUTH_TOKEN = prevOauthToken;
|
||||
}
|
||||
});
|
||||
|
||||
it("enables cache-ttl pruning + 1h heartbeat for Anthropic OAuth", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:me": { provider: "anthropic", mode: "oauth", email: "me@example.com" },
|
||||
},
|
||||
},
|
||||
agents: { defaults: {} },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("cache-ttl");
|
||||
expect(cfg.agents?.defaults?.contextPruning?.ttl).toBe("1h");
|
||||
expect(cfg.agents?.defaults?.heartbeat?.every).toBe("1h");
|
||||
});
|
||||
});
|
||||
|
||||
it("enables cache-ttl pruning + 1h cache TTL for Anthropic API keys", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:api": { provider: "anthropic", mode: "api_key" },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("cache-ttl");
|
||||
expect(cfg.agents?.defaults?.contextPruning?.ttl).toBe("1h");
|
||||
expect(cfg.agents?.defaults?.heartbeat?.every).toBe("30m");
|
||||
expect(
|
||||
cfg.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]?.params?.cacheControlTtl,
|
||||
).toBe("1h");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseModelRef } from "../agents/model-selection.js";
|
||||
import { resolveTalkApiKey } from "./talk.js";
|
||||
import type { ClawdbotConfig } from "./types.js";
|
||||
import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js";
|
||||
@@ -6,6 +7,8 @@ type WarnState = { warned: boolean };
|
||||
|
||||
let defaultWarnState: WarnState = { warned: false };
|
||||
|
||||
type AnthropicAuthDefaultsMode = "api_key" | "oauth";
|
||||
|
||||
const DEFAULT_MODEL_ALIASES: Readonly<Record<string, string>> = {
|
||||
// Anthropic (pi-ai catalog uses "latest" ids without date suffix)
|
||||
opus: "anthropic/claude-opus-4-5",
|
||||
@@ -20,6 +23,40 @@ const DEFAULT_MODEL_ALIASES: Readonly<Record<string, string>> = {
|
||||
"gemini-flash": "google/gemini-3-flash-preview",
|
||||
};
|
||||
|
||||
function resolveAnthropicDefaultAuthMode(cfg: ClawdbotConfig): AnthropicAuthDefaultsMode | null {
|
||||
const profiles = cfg.auth?.profiles ?? {};
|
||||
const anthropicProfiles = Object.entries(profiles).filter(
|
||||
([, profile]) => profile?.provider === "anthropic",
|
||||
);
|
||||
|
||||
const order = cfg.auth?.order?.anthropic ?? [];
|
||||
for (const profileId of order) {
|
||||
const entry = profiles[profileId];
|
||||
if (!entry || entry.provider !== "anthropic") continue;
|
||||
if (entry.mode === "api_key") return "api_key";
|
||||
if (entry.mode === "oauth" || entry.mode === "token") return "oauth";
|
||||
}
|
||||
|
||||
const hasApiKey = anthropicProfiles.some(([, profile]) => profile?.mode === "api_key");
|
||||
const hasOauth = anthropicProfiles.some(
|
||||
([, profile]) => profile?.mode === "oauth" || profile?.mode === "token",
|
||||
);
|
||||
if (hasApiKey && !hasOauth) return "api_key";
|
||||
if (hasOauth && !hasApiKey) return "oauth";
|
||||
|
||||
if (process.env.ANTHROPIC_OAUTH_TOKEN?.trim()) return "oauth";
|
||||
if (process.env.ANTHROPIC_API_KEY?.trim()) return "api_key";
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePrimaryModelRef(raw?: string): string | null {
|
||||
if (!raw || typeof raw !== "string") return null;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
const aliasKey = trimmed.toLowerCase();
|
||||
return DEFAULT_MODEL_ALIASES[aliasKey] ?? trimmed;
|
||||
}
|
||||
|
||||
export type SessionDefaultsOptions = {
|
||||
warn?: (message: string) => void;
|
||||
warnState?: WarnState;
|
||||
@@ -159,20 +196,80 @@ export function applyLoggingDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const defaults = cfg.agents?.defaults;
|
||||
if (!defaults) return cfg;
|
||||
const contextPruning = defaults?.contextPruning;
|
||||
if (contextPruning?.mode) return cfg;
|
||||
|
||||
const authMode = resolveAnthropicDefaultAuthMode(cfg);
|
||||
if (!authMode) return cfg;
|
||||
|
||||
let mutated = false;
|
||||
const nextDefaults = { ...defaults };
|
||||
const contextPruning = defaults.contextPruning ?? {};
|
||||
const heartbeat = defaults.heartbeat ?? {};
|
||||
|
||||
if (defaults.contextPruning?.mode === undefined) {
|
||||
nextDefaults.contextPruning = {
|
||||
...contextPruning,
|
||||
mode: "cache-ttl",
|
||||
ttl: defaults.contextPruning?.ttl ?? "1h",
|
||||
};
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
if (defaults.heartbeat?.every === undefined) {
|
||||
nextDefaults.heartbeat = {
|
||||
...heartbeat,
|
||||
every: authMode === "oauth" ? "1h" : "30m",
|
||||
};
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
if (authMode === "api_key") {
|
||||
const nextModels = defaults.models ? { ...defaults.models } : {};
|
||||
let modelsMutated = false;
|
||||
|
||||
for (const [key, entry] of Object.entries(nextModels)) {
|
||||
const parsed = parseModelRef(key, "anthropic");
|
||||
if (!parsed || parsed.provider !== "anthropic") continue;
|
||||
const current = entry ?? {};
|
||||
const params = (current as { params?: Record<string, unknown> }).params ?? {};
|
||||
if (typeof params.cacheControlTtl === "string") continue;
|
||||
nextModels[key] = {
|
||||
...(current as Record<string, unknown>),
|
||||
params: { ...params, cacheControlTtl: "1h" },
|
||||
};
|
||||
modelsMutated = true;
|
||||
}
|
||||
|
||||
const primary = resolvePrimaryModelRef(defaults.model?.primary ?? undefined);
|
||||
if (primary) {
|
||||
const parsedPrimary = parseModelRef(primary, "anthropic");
|
||||
if (parsedPrimary?.provider === "anthropic") {
|
||||
const key = `${parsedPrimary.provider}/${parsedPrimary.model}`;
|
||||
const entry = nextModels[key];
|
||||
const current = entry ?? {};
|
||||
const params = (current as { params?: Record<string, unknown> }).params ?? {};
|
||||
if (typeof params.cacheControlTtl !== "string") {
|
||||
nextModels[key] = {
|
||||
...(current as Record<string, unknown>),
|
||||
params: { ...params, cacheControlTtl: "1h" },
|
||||
};
|
||||
modelsMutated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modelsMutated) {
|
||||
nextDefaults.models = nextModels;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mutated) return cfg;
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...defaults,
|
||||
contextPruning: {
|
||||
...contextPruning,
|
||||
mode: "adaptive",
|
||||
},
|
||||
},
|
||||
defaults: nextDefaults,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
.join("\n");
|
||||
if (!loggedInvalidConfigs.has(configPath)) {
|
||||
loggedInvalidConfigs.add(configPath);
|
||||
deps.logger.error(`Invalid config:\\n${details}`);
|
||||
deps.logger.error(`Invalid config at ${configPath}:\\n${details}`);
|
||||
}
|
||||
const error = new Error("Invalid config");
|
||||
(error as { code?: string; details?: string }).code = "INVALID_CONFIG";
|
||||
@@ -301,6 +301,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
deps.logger.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
const error = err as { code?: string };
|
||||
if (error?.code === "INVALID_CONFIG") {
|
||||
return {};
|
||||
}
|
||||
deps.logger.error(`Failed to read config at ${configPath}`, err);
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -157,11 +157,16 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
||||
const allowFrom = (routing as Record<string, unknown>).allowFrom;
|
||||
if (allowFrom === undefined) return;
|
||||
|
||||
const channels = ensureRecord(raw, "channels");
|
||||
const whatsapp =
|
||||
channels.whatsapp && typeof channels.whatsapp === "object"
|
||||
? (channels.whatsapp as Record<string, unknown>)
|
||||
: {};
|
||||
const channels = getRecord(raw.channels);
|
||||
const whatsapp = channels ? getRecord(channels.whatsapp) : null;
|
||||
if (!whatsapp) {
|
||||
delete (routing as Record<string, unknown>).allowFrom;
|
||||
if (Object.keys(routing as Record<string, unknown>).length === 0) {
|
||||
delete raw.routing;
|
||||
}
|
||||
changes.push("Removed routing.allowFrom (channels.whatsapp not configured).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (whatsapp.allowFrom === undefined) {
|
||||
whatsapp.allowFrom = allowFrom;
|
||||
@@ -174,8 +179,8 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
||||
if (Object.keys(routing as Record<string, unknown>).length === 0) {
|
||||
delete raw.routing;
|
||||
}
|
||||
channels.whatsapp = whatsapp;
|
||||
raw.channels = channels;
|
||||
channels!.whatsapp = whatsapp;
|
||||
raw.channels = channels!;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -194,7 +199,11 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
||||
if (requireMention === undefined) return;
|
||||
|
||||
const channels = ensureRecord(raw, "channels");
|
||||
const applyTo = (key: "whatsapp" | "telegram" | "imessage") => {
|
||||
const applyTo = (
|
||||
key: "whatsapp" | "telegram" | "imessage",
|
||||
options?: { requireExisting?: boolean },
|
||||
) => {
|
||||
if (options?.requireExisting && !isRecord(channels[key])) return;
|
||||
const section =
|
||||
channels[key] && typeof channels[key] === "object"
|
||||
? (channels[key] as Record<string, unknown>)
|
||||
@@ -223,7 +232,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
||||
}
|
||||
};
|
||||
|
||||
applyTo("whatsapp");
|
||||
applyTo("whatsapp", { requireExisting: true });
|
||||
applyTo("telegram");
|
||||
applyTo("imessage");
|
||||
|
||||
|
||||
@@ -143,21 +143,4 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
|
||||
delete raw.identity;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bind-tailnet->auto",
|
||||
describe: "Remap gateway bind 'tailnet' to 'auto'",
|
||||
apply: (raw, changes) => {
|
||||
const migrateBind = (obj: Record<string, unknown> | null | undefined, key: string) => {
|
||||
if (!obj) return;
|
||||
const bind = obj.bind;
|
||||
if (bind === "tailnet") {
|
||||
obj.bind = "auto";
|
||||
changes.push(`Migrated ${key}.bind from 'tailnet' to 'auto'.`);
|
||||
}
|
||||
};
|
||||
|
||||
const gateway = getRecord(raw.gateway);
|
||||
migrateBind(gateway, "gateway");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -119,6 +119,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages",
|
||||
"diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt",
|
||||
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
|
||||
"agents.list.*.identity.avatar": "Identity Avatar",
|
||||
"gateway.remote.url": "Remote Gateway URL",
|
||||
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
|
||||
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
|
||||
@@ -161,11 +162,13 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.exec.applyPatch.enabled": "Enable apply_patch",
|
||||
"tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",
|
||||
"tools.exec.notifyOnExit": "Exec Notify On Exit",
|
||||
"tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)",
|
||||
"tools.exec.host": "Exec Host",
|
||||
"tools.exec.security": "Exec Security",
|
||||
"tools.exec.ask": "Exec Ask",
|
||||
"tools.exec.node": "Exec Node Binding",
|
||||
"tools.exec.pathPrepend": "Exec PATH Prepend",
|
||||
"tools.exec.safeBins": "Exec Safe Bins",
|
||||
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
|
||||
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
|
||||
"tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)",
|
||||
@@ -186,6 +189,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
|
||||
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
||||
"gateway.controlUi.basePath": "Control UI Base Path",
|
||||
"gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth",
|
||||
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
||||
"gateway.reload.mode": "Config Reload Mode",
|
||||
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
|
||||
@@ -194,6 +198,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"skills.load.watch": "Watch Skills",
|
||||
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
|
||||
"agents.defaults.workspace": "Workspace",
|
||||
"agents.defaults.repoRoot": "Repo Root",
|
||||
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
|
||||
"agents.defaults.envelopeTimezone": "Envelope Timezone",
|
||||
"agents.defaults.envelopeTimestamp": "Envelope Timestamp",
|
||||
@@ -256,6 +261,8 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"commands.restart": "Allow Restart",
|
||||
"commands.useAccessGroups": "Use Access Groups",
|
||||
"ui.seamColor": "Accent Color",
|
||||
"ui.assistant.name": "Assistant Name",
|
||||
"ui.assistant.avatar": "Assistant Avatar",
|
||||
"browser.controlUrl": "Browser Control URL",
|
||||
"browser.snapshotDefaults": "Browser Snapshot Defaults",
|
||||
"browser.snapshotDefaults.mode": "Browser Snapshot Mode",
|
||||
@@ -317,6 +324,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.mattermost.requireMention": "Mattermost Require Mention",
|
||||
"channels.signal.account": "Signal Account",
|
||||
"channels.imessage.cliPath": "iMessage CLI Path",
|
||||
"agents.list[].identity.avatar": "Agent Avatar",
|
||||
"plugins.enabled": "Enable Plugins",
|
||||
"plugins.allow": "Plugin Allowlist",
|
||||
"plugins.deny": "Plugin Denylist",
|
||||
@@ -346,10 +354,14 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"gateway.remote.sshTarget":
|
||||
"Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.",
|
||||
"gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).",
|
||||
"agents.list[].identity.avatar":
|
||||
"Avatar image path (relative to the agent workspace only) or a remote URL/data URL.",
|
||||
"gateway.auth.token": "Recommended for all gateways; required for non-loopback binds.",
|
||||
"gateway.auth.password": "Required for Tailscale funnel.",
|
||||
"gateway.controlUi.basePath":
|
||||
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).",
|
||||
"gateway.controlUi.allowInsecureAuth":
|
||||
"Allow Control UI auth over insecure HTTP (token-only; not recommended).",
|
||||
"gateway.http.endpoints.chatCompletions.enabled":
|
||||
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
|
||||
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
|
||||
@@ -373,6 +385,8 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"tools.exec.notifyOnExit":
|
||||
"When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.",
|
||||
"tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).",
|
||||
"tools.exec.safeBins":
|
||||
"Allow stdin-only safe binaries to run without explicit allowlist entries.",
|
||||
"tools.message.allowCrossContextSend":
|
||||
"Legacy override: allow cross-context sends across all providers.",
|
||||
"tools.message.crossContext.allowWithinProvider":
|
||||
@@ -440,6 +454,8 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).",
|
||||
"agents.defaults.bootstrapMaxChars":
|
||||
"Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).",
|
||||
"agents.defaults.repoRoot":
|
||||
"Optional repository root shown in the system prompt runtime line (overrides auto-detect).",
|
||||
"agents.defaults.envelopeTimezone":
|
||||
'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).',
|
||||
"agents.defaults.envelopeTimestamp":
|
||||
@@ -516,6 +532,8 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"Resolved install directory (usually ~/.clawdbot/extensions/<id>).",
|
||||
"plugins.installs.*.version": "Version recorded at install time (if available).",
|
||||
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
|
||||
"agents.list.*.identity.avatar":
|
||||
"Agent avatar (workspace-relative path, http(s) URL, or data URI).",
|
||||
"agents.defaults.model.primary": "Primary model (provider/model).",
|
||||
"agents.defaults.model.fallbacks":
|
||||
"Ordered fallback models (provider/model). Used when the primary model fails.",
|
||||
@@ -624,6 +642,7 @@ const FIELD_PLACEHOLDERS: Record<string, string> = {
|
||||
"gateway.remote.sshTarget": "user@host",
|
||||
"gateway.controlUi.basePath": "/clawdbot",
|
||||
"channels.mattermost.baseUrl": "https://chat.example.com",
|
||||
"agents.list[].identity.avatar": "avatars/clawd.png",
|
||||
};
|
||||
|
||||
const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SessionConfig } from "../types.base.js";
|
||||
import type { SessionConfig, SessionResetConfig } from "../types.base.js";
|
||||
import { DEFAULT_IDLE_MINUTES } from "./types.js";
|
||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
|
||||
export type SessionResetMode = "daily" | "idle";
|
||||
export type SessionResetType = "dm" | "group" | "thread";
|
||||
@@ -67,13 +68,13 @@ export function resolveDailyResetAtMs(now: number, atHour: number): number {
|
||||
export function resolveSessionResetPolicy(params: {
|
||||
sessionCfg?: SessionConfig;
|
||||
resetType: SessionResetType;
|
||||
idleMinutesOverride?: number;
|
||||
resetOverride?: SessionResetConfig;
|
||||
}): SessionResetPolicy {
|
||||
const sessionCfg = params.sessionCfg;
|
||||
const baseReset = sessionCfg?.reset;
|
||||
const typeReset = sessionCfg?.resetByType?.[params.resetType];
|
||||
const baseReset = params.resetOverride ?? sessionCfg?.reset;
|
||||
const typeReset = params.resetOverride ? undefined : sessionCfg?.resetByType?.[params.resetType];
|
||||
const hasExplicitReset = Boolean(baseReset || sessionCfg?.resetByType);
|
||||
const legacyIdleMinutes = sessionCfg?.idleMinutes;
|
||||
const legacyIdleMinutes = params.resetOverride ? undefined : sessionCfg?.idleMinutes;
|
||||
const mode =
|
||||
typeReset?.mode ??
|
||||
baseReset?.mode ??
|
||||
@@ -81,11 +82,7 @@ export function resolveSessionResetPolicy(params: {
|
||||
const atHour = normalizeResetAtHour(
|
||||
typeReset?.atHour ?? baseReset?.atHour ?? DEFAULT_RESET_AT_HOUR,
|
||||
);
|
||||
const idleMinutesRaw =
|
||||
params.idleMinutesOverride ??
|
||||
typeReset?.idleMinutes ??
|
||||
baseReset?.idleMinutes ??
|
||||
legacyIdleMinutes;
|
||||
const idleMinutesRaw = typeReset?.idleMinutes ?? baseReset?.idleMinutes ?? legacyIdleMinutes;
|
||||
|
||||
let idleMinutes: number | undefined;
|
||||
if (idleMinutesRaw != null) {
|
||||
@@ -100,6 +97,19 @@ export function resolveSessionResetPolicy(params: {
|
||||
return { mode, atHour, idleMinutes };
|
||||
}
|
||||
|
||||
export function resolveChannelResetConfig(params: {
|
||||
sessionCfg?: SessionConfig;
|
||||
channel?: string | null;
|
||||
}): SessionResetConfig | undefined {
|
||||
const resetByChannel = params.sessionCfg?.resetByChannel;
|
||||
if (!resetByChannel) return undefined;
|
||||
const normalized = normalizeMessageChannel(params.channel);
|
||||
const fallback = params.channel?.trim().toLowerCase();
|
||||
const key = normalized ?? fallback;
|
||||
if (!key) return undefined;
|
||||
return resetByChannel[key] ?? resetByChannel[key.toLowerCase()];
|
||||
}
|
||||
|
||||
export function evaluateSessionFreshness(params: {
|
||||
updatedAt: number;
|
||||
now: number;
|
||||
|
||||
@@ -23,7 +23,9 @@ export type AgentModelListConfig = {
|
||||
};
|
||||
|
||||
export type AgentContextPruningConfig = {
|
||||
mode?: "off" | "adaptive" | "aggressive";
|
||||
mode?: "off" | "cache-ttl";
|
||||
/** TTL to consider cache expired (duration string, default unit: minutes). */
|
||||
ttl?: string;
|
||||
keepLastAssistants?: number;
|
||||
softTrimRatio?: number;
|
||||
hardClearRatio?: number;
|
||||
@@ -97,6 +99,8 @@ export type AgentDefaultsConfig = {
|
||||
models?: Record<string, AgentModelEntryConfig>;
|
||||
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
|
||||
workspace?: string;
|
||||
/** Optional repository root for system prompt runtime line (overrides auto-detect). */
|
||||
repoRoot?: string;
|
||||
/** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */
|
||||
skipBootstrap?: boolean;
|
||||
/** Max chars for injected bootstrap files before truncation (default: 20000). */
|
||||
@@ -132,7 +136,7 @@ export type AgentDefaultsConfig = {
|
||||
/** Default verbose level when no /verbose directive is present. */
|
||||
verboseDefault?: "off" | "on" | "full";
|
||||
/** Default elevated level when no /elevated directive is present. */
|
||||
elevatedDefault?: "off" | "on";
|
||||
elevatedDefault?: "off" | "on" | "ask" | "full";
|
||||
/** Default block streaming level when no override is present. */
|
||||
blockStreamingDefault?: "off" | "on";
|
||||
/**
|
||||
@@ -160,8 +164,19 @@ export type AgentDefaultsConfig = {
|
||||
heartbeat?: {
|
||||
/** Heartbeat interval (duration string, default unit: minutes; default: 30m). */
|
||||
every?: string;
|
||||
/** Optional active-hours window (local time); heartbeats run only inside this window. */
|
||||
activeHours?: {
|
||||
/** Start time (24h, HH:MM). Inclusive. */
|
||||
start?: string;
|
||||
/** End time (24h, HH:MM). Exclusive. Use "24:00" for end-of-day. */
|
||||
end?: string;
|
||||
/** Timezone for the window ("user", "local", or IANA TZ id). Default: "user". */
|
||||
timezone?: string;
|
||||
};
|
||||
/** Heartbeat model override (provider/model). */
|
||||
model?: string;
|
||||
/** Session key for heartbeat runs ("main" or explicit session key). */
|
||||
session?: string;
|
||||
/** Delivery target (last|whatsapp|telegram|discord|slack|mattermost|msteams|signal|imessage|none). */
|
||||
target?:
|
||||
| "last"
|
||||
|
||||
@@ -77,9 +77,10 @@ export type SessionConfig = {
|
||||
identityLinks?: Record<string, string[]>;
|
||||
resetTriggers?: string[];
|
||||
idleMinutes?: number;
|
||||
heartbeatIdleMinutes?: number;
|
||||
reset?: SessionResetConfig;
|
||||
resetByType?: SessionResetByTypeConfig;
|
||||
/** Channel-specific reset overrides (e.g. { discord: { mode: "idle", idleMinutes: 10080 } }). */
|
||||
resetByChannel?: Record<string, SessionResetConfig>;
|
||||
store?: string;
|
||||
typingIntervalSeconds?: number;
|
||||
typingMode?: TypingMode;
|
||||
@@ -153,4 +154,6 @@ export type IdentityConfig = {
|
||||
name?: string;
|
||||
theme?: string;
|
||||
emoji?: string;
|
||||
/** Avatar image: workspace-relative path, http(s) URL, or data URI. */
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
@@ -65,6 +65,12 @@ export type ClawdbotConfig = {
|
||||
ui?: {
|
||||
/** Accent color for Clawdbot UI chrome (hex). */
|
||||
seamColor?: string;
|
||||
assistant?: {
|
||||
/** Assistant display name for UI surfaces. */
|
||||
name?: string;
|
||||
/** Assistant avatar (emoji, short text, or image URL/data URI). */
|
||||
avatar?: string;
|
||||
};
|
||||
};
|
||||
skills?: SkillsConfig;
|
||||
plugins?: PluginsConfig;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom";
|
||||
export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom" | "tailnet";
|
||||
|
||||
export type GatewayTlsConfig = {
|
||||
/** Enable TLS for the gateway server. */
|
||||
@@ -51,6 +51,8 @@ export type GatewayControlUiConfig = {
|
||||
enabled?: boolean;
|
||||
/** Optional base path prefix for the Control UI (e.g. "/clawdbot"). */
|
||||
basePath?: string;
|
||||
/** Allow token-only auth over insecure HTTP (default: false). */
|
||||
allowInsecureAuth?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayAuthMode = "token" | "password";
|
||||
@@ -189,9 +191,10 @@ export type GatewayConfig = {
|
||||
mode?: "local" | "remote";
|
||||
/**
|
||||
* Bind address policy for the Gateway WebSocket + Control UI HTTP server.
|
||||
* - auto: Tailnet IPv4 if available, else 0.0.0.0 (fallback to all interfaces)
|
||||
* - auto: Loopback (127.0.0.1) if available, else 0.0.0.0 (fallback to all interfaces)
|
||||
* - lan: 0.0.0.0 (all interfaces, no fallback)
|
||||
* - loopback: 127.0.0.1 (local-only)
|
||||
* - tailnet: Tailnet IPv4 if available (100.64.0.0/10), else loopback
|
||||
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost)
|
||||
* Default: loopback (127.0.0.1).
|
||||
*/
|
||||
|
||||
@@ -13,21 +13,13 @@ export type QueueConfig = {
|
||||
mode?: QueueMode;
|
||||
byChannel?: QueueModeByProvider;
|
||||
debounceMs?: number;
|
||||
/** Per-channel debounce overrides (ms). */
|
||||
debounceMsByChannel?: InboundDebounceByProvider;
|
||||
cap?: number;
|
||||
drop?: QueueDropPolicy;
|
||||
};
|
||||
|
||||
export type InboundDebounceByProvider = {
|
||||
whatsapp?: number;
|
||||
telegram?: number;
|
||||
discord?: number;
|
||||
slack?: number;
|
||||
mattermost?: number;
|
||||
signal?: number;
|
||||
imessage?: number;
|
||||
msteams?: number;
|
||||
webchat?: number;
|
||||
};
|
||||
export type InboundDebounceByProvider = Record<string, number>;
|
||||
|
||||
export type InboundDebounceConfig = {
|
||||
debounceMs?: number;
|
||||
|
||||
@@ -78,4 +78,8 @@ export type MSTeamsConfig = {
|
||||
replyStyle?: MSTeamsReplyStyle;
|
||||
/** Per-team config. Key is team ID (from the /team/ URL path segment). */
|
||||
teams?: Record<string, MSTeamsTeamConfig>;
|
||||
/** Max media size in MB (default: 100MB for OneDrive upload support). */
|
||||
mediaMaxMb?: number;
|
||||
/** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2"). */
|
||||
sharePointSiteId?: string;
|
||||
};
|
||||
|
||||
@@ -131,10 +131,14 @@ export type ExecToolConfig = {
|
||||
node?: string;
|
||||
/** Directories to prepend to PATH when running exec (gateway/sandbox). */
|
||||
pathPrepend?: string[];
|
||||
/** Safe stdin-only binaries that can run without allowlist entries. */
|
||||
safeBins?: string[];
|
||||
/** Default time (ms) before an exec command auto-backgrounds. */
|
||||
backgroundMs?: number;
|
||||
/** Default timeout (seconds) before auto-killing exec commands. */
|
||||
timeoutSec?: number;
|
||||
/** Emit a running notice (ms) when approval-backed exec runs long (default: 10000, 0 = off). */
|
||||
approvalRunningNoticeMs?: number;
|
||||
/** How long to keep finished sessions in memory (ms). */
|
||||
cleanupMs?: number;
|
||||
/** Emit a system event and heartbeat when a backgrounded exec exits. */
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { CHANNEL_IDS } from "../channels/registry.js";
|
||||
import {
|
||||
@@ -13,6 +15,60 @@ import { findLegacyConfigIssues } from "./legacy.js";
|
||||
import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js";
|
||||
import { ClawdbotSchema } from "./zod-schema.js";
|
||||
|
||||
const AVATAR_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
||||
const AVATAR_DATA_RE = /^data:/i;
|
||||
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
||||
const WINDOWS_ABS_RE = /^[a-zA-Z]:[\\/]/;
|
||||
|
||||
function isWorkspaceAvatarPath(value: string, workspaceDir: string): boolean {
|
||||
const workspaceRoot = path.resolve(workspaceDir);
|
||||
const resolved = path.resolve(workspaceRoot, value);
|
||||
const relative = path.relative(workspaceRoot, resolved);
|
||||
if (relative === "") return true;
|
||||
if (relative.startsWith("..")) return false;
|
||||
return !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
function validateIdentityAvatar(config: ClawdbotConfig): ConfigValidationIssue[] {
|
||||
const agents = config.agents?.list;
|
||||
if (!Array.isArray(agents) || agents.length === 0) return [];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
for (const [index, entry] of agents.entries()) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const avatarRaw = entry.identity?.avatar;
|
||||
if (typeof avatarRaw !== "string") continue;
|
||||
const avatar = avatarRaw.trim();
|
||||
if (!avatar) continue;
|
||||
if (AVATAR_DATA_RE.test(avatar) || AVATAR_HTTP_RE.test(avatar)) continue;
|
||||
if (avatar.startsWith("~")) {
|
||||
issues.push({
|
||||
path: `agents.list.${index}.identity.avatar`,
|
||||
message: "identity.avatar must be a workspace-relative path, http(s) URL, or data URI.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const hasScheme = AVATAR_SCHEME_RE.test(avatar);
|
||||
if (hasScheme && !WINDOWS_ABS_RE.test(avatar)) {
|
||||
issues.push({
|
||||
path: `agents.list.${index}.identity.avatar`,
|
||||
message: "identity.avatar must be a workspace-relative path, http(s) URL, or data URI.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const workspaceDir = resolveAgentWorkspaceDir(
|
||||
config,
|
||||
entry.id ?? resolveDefaultAgentId(config),
|
||||
);
|
||||
if (!isWorkspaceAvatarPath(avatar, workspaceDir)) {
|
||||
issues.push({
|
||||
path: `agents.list.${index}.identity.avatar`,
|
||||
message: "identity.avatar must stay within the agent workspace.",
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateConfigObject(
|
||||
raw: unknown,
|
||||
): { ok: true; config: ClawdbotConfig } | { ok: false; issues: ConfigValidationIssue[] } {
|
||||
@@ -48,6 +104,10 @@ export function validateConfigObject(
|
||||
],
|
||||
};
|
||||
}
|
||||
const avatarIssues = validateIdentityAvatar(validated.data as ClawdbotConfig);
|
||||
if (avatarIssues.length > 0) {
|
||||
return { ok: false, issues: avatarIssues };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
config: applyModelDefaults(
|
||||
|
||||
@@ -42,6 +42,7 @@ export const AgentDefaultsSchema = z
|
||||
)
|
||||
.optional(),
|
||||
workspace: z.string().optional(),
|
||||
repoRoot: z.string().optional(),
|
||||
skipBootstrap: z.boolean().optional(),
|
||||
bootstrapMaxChars: z.number().int().positive().optional(),
|
||||
userTimezone: z.string().optional(),
|
||||
@@ -54,9 +55,8 @@ export const AgentDefaultsSchema = z
|
||||
memorySearch: MemorySearchSchema,
|
||||
contextPruning: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([z.literal("off"), z.literal("adaptive"), z.literal("aggressive")])
|
||||
.optional(),
|
||||
mode: z.union([z.literal("off"), z.literal("cache-ttl")]).optional(),
|
||||
ttl: z.string().optional(),
|
||||
keepLastAssistants: z.number().int().nonnegative().optional(),
|
||||
softTrimRatio: z.number().min(0).max(1).optional(),
|
||||
hardClearRatio: z.number().min(0).max(1).optional(),
|
||||
@@ -113,7 +113,9 @@ export const AgentDefaultsSchema = z
|
||||
])
|
||||
.optional(),
|
||||
verboseDefault: z.union([z.literal("off"), z.literal("on"), z.literal("full")]).optional(),
|
||||
elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
|
||||
elevatedDefault: z
|
||||
.union([z.literal("off"), z.literal("on"), z.literal("ask"), z.literal("full")])
|
||||
.optional(),
|
||||
blockStreamingDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
|
||||
blockStreamingBreak: z.union([z.literal("text_end"), z.literal("message_end")]).optional(),
|
||||
blockStreamingChunk: BlockStreamingChunkSchema.optional(),
|
||||
|
||||
@@ -11,7 +11,16 @@ import {
|
||||
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
|
||||
.union([
|
||||
@@ -43,6 +52,42 @@ export const HeartbeatSchema = z
|
||||
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();
|
||||
|
||||
@@ -214,8 +259,10 @@ export const AgentToolsSchema = z
|
||||
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
|
||||
@@ -442,6 +489,7 @@ export const ToolsSchema = z
|
||||
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(),
|
||||
|
||||
@@ -86,6 +86,7 @@ export const IdentitySchema = z
|
||||
name: z.string().optional(),
|
||||
theme: z.string().optional(),
|
||||
emoji: z.string().optional(),
|
||||
avatar: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
@@ -218,18 +219,7 @@ export const QueueModeBySurfaceSchema = z
|
||||
.optional();
|
||||
|
||||
export const DebounceMsBySurfaceSchema = z
|
||||
.object({
|
||||
whatsapp: z.number().int().nonnegative().optional(),
|
||||
telegram: z.number().int().nonnegative().optional(),
|
||||
discord: z.number().int().nonnegative().optional(),
|
||||
slack: z.number().int().nonnegative().optional(),
|
||||
mattermost: z.number().int().nonnegative().optional(),
|
||||
signal: z.number().int().nonnegative().optional(),
|
||||
imessage: z.number().int().nonnegative().optional(),
|
||||
msteams: z.number().int().nonnegative().optional(),
|
||||
webchat: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.strict()
|
||||
.record(z.string(), z.number().int().nonnegative())
|
||||
.optional();
|
||||
|
||||
export const QueueSchema = z
|
||||
@@ -237,6 +227,7 @@ export const QueueSchema = z
|
||||
mode: QueueModeSchema.optional(),
|
||||
byChannel: QueueModeBySurfaceSchema,
|
||||
debounceMs: z.number().int().nonnegative().optional(),
|
||||
debounceMsByChannel: DebounceMsBySurfaceSchema,
|
||||
cap: z.number().int().positive().optional(),
|
||||
drop: QueueDropSchema.optional(),
|
||||
})
|
||||
|
||||
@@ -620,6 +620,10 @@ export const MSTeamsConfigSchema = z
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
||||
teams: z.record(z.string(), MSTeamsTeamSchema.optional()).optional(),
|
||||
/** Max media size in MB (default: 100MB for OneDrive upload support). */
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
/** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2") */
|
||||
sharePointSiteId: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
|
||||
@@ -24,7 +24,6 @@ export const SessionSchema = z
|
||||
identityLinks: z.record(z.string(), z.array(z.string())).optional(),
|
||||
resetTriggers: z.array(z.string()).optional(),
|
||||
idleMinutes: z.number().int().positive().optional(),
|
||||
heartbeatIdleMinutes: z.number().int().positive().optional(),
|
||||
reset: SessionResetConfigSchema.optional(),
|
||||
resetByType: z
|
||||
.object({
|
||||
@@ -34,6 +33,7 @@ export const SessionSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
resetByChannel: z.record(z.string(), SessionResetConfigSchema).optional(),
|
||||
store: z.string().optional(),
|
||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||
typingMode: z
|
||||
|
||||
@@ -155,6 +155,13 @@ export const ClawdbotSchema = z
|
||||
ui: z
|
||||
.object({
|
||||
seamColor: HexColorSchema.optional(),
|
||||
assistant: z
|
||||
.object({
|
||||
name: z.string().max(50).optional(),
|
||||
avatar: z.string().max(200).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
@@ -191,6 +198,12 @@ export const ClawdbotSchema = z
|
||||
bindings: BindingsSchema,
|
||||
broadcast: BroadcastSchema,
|
||||
audio: AudioSchema,
|
||||
media: z
|
||||
.object({
|
||||
preserveFilenames: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
messages: MessagesSchema,
|
||||
commands: CommandsSchema,
|
||||
session: SessionSchema,
|
||||
@@ -270,12 +283,19 @@ export const ClawdbotSchema = z
|
||||
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("loopback"), z.literal("custom")])
|
||||
.union([
|
||||
z.literal("auto"),
|
||||
z.literal("lan"),
|
||||
z.literal("loopback"),
|
||||
z.literal("custom"),
|
||||
z.literal("tailnet"),
|
||||
])
|
||||
.optional(),
|
||||
controlUi: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
basePath: z.string().optional(),
|
||||
allowInsecureAuth: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
Reference in New Issue
Block a user