mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-30 03:35:04 +00:00
Merge branch 'main' into vincentkoc-code/slack-block-kit-interactions
This commit is contained in:
@@ -315,6 +315,7 @@ describe("model compat config schema", () => {
|
||||
requiresAssistantAfterToolResult: false,
|
||||
requiresThinkingAsText: false,
|
||||
requiresMistralToolIds: false,
|
||||
requiresOpenAiAnthropicToolPayload: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -360,6 +361,33 @@ describe("config strict validation", () => {
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts documented agents.list[].params overrides", () => {
|
||||
const res = validateConfigObject({
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
params: {
|
||||
cacheRetention: "none",
|
||||
temperature: 0.4,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.agents?.list?.[0]?.params).toEqual({
|
||||
cacheRetention: "none",
|
||||
temperature: 0.4,
|
||||
maxTokens: 8192,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("flags legacy config entries without auto-migrating", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
|
||||
@@ -211,4 +211,17 @@ describe("config schema regressions", () => {
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts discovery.wideArea.domain for unicast DNS-SD", () => {
|
||||
const res = validateConfigObject({
|
||||
discovery: {
|
||||
wideArea: {
|
||||
enabled: true,
|
||||
domain: "openclaw.internal",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -296,6 +296,7 @@ const TARGET_KEYS = [
|
||||
"web.reconnect.jitter",
|
||||
"web.reconnect.maxAttempts",
|
||||
"discovery",
|
||||
"discovery.wideArea.domain",
|
||||
"discovery.wideArea.enabled",
|
||||
"discovery.mdns",
|
||||
"discovery.mdns.mode",
|
||||
|
||||
@@ -292,6 +292,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Wide-area discovery configuration group for exposing discovery signals beyond local-link scopes. Enable only in deployments that intentionally aggregate gateway presence across sites.",
|
||||
"discovery.wideArea.enabled":
|
||||
"Enables wide-area discovery signaling when your environment needs non-local gateway discovery. Keep disabled unless cross-network discovery is operationally required.",
|
||||
"discovery.wideArea.domain":
|
||||
"Optional unicast DNS-SD domain for wide-area discovery, such as openclaw.internal. Use this when you intentionally publish gateway discovery beyond local mDNS scopes.",
|
||||
"discovery.mdns":
|
||||
"mDNS discovery configuration group for local network advertisement and discovery behavior tuning. Keep minimal mode for routine LAN discovery unless extra metadata is required.",
|
||||
tools:
|
||||
@@ -1483,7 +1485,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"messages.statusReactions.enabled":
|
||||
"Enable lifecycle status reactions for Telegram. When enabled, the ack reaction becomes the initial 'queued' state and progresses through thinking, tool, done/error automatically. Default: false.",
|
||||
"messages.statusReactions.emojis":
|
||||
"Override default status reaction emojis. Keys: thinking, tool, coding, web, done, error, stallSoft, stallHard. Must be valid Telegram reaction emojis.",
|
||||
"Override default status reaction emojis. Keys: thinking, compacting, tool, coding, web, done, error, stallSoft, stallHard. Must be valid Telegram reaction emojis.",
|
||||
"messages.statusReactions.timing":
|
||||
"Override default timing. Keys: debounceMs (700), stallSoftMs (25000), stallHardMs (60000), doneHoldMs (1500), errorHoldMs (2500).",
|
||||
"messages.inbound.debounceMs":
|
||||
|
||||
@@ -654,6 +654,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
discovery: "Discovery",
|
||||
"discovery.wideArea": "Wide-area Discovery",
|
||||
"discovery.wideArea.enabled": "Wide-area Discovery Enabled",
|
||||
"discovery.wideArea.domain": "Wide-area Discovery Domain",
|
||||
"discovery.mdns": "mDNS Discovery",
|
||||
canvasHost: "Canvas Host",
|
||||
"canvasHost.enabled": "Canvas Host Enabled",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildConfigSchema, lookupConfigSchema } from "./schema.js";
|
||||
import { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js";
|
||||
import { ToolsSchema } from "./zod-schema.agent-runtime.js";
|
||||
|
||||
describe("config schema", () => {
|
||||
type SchemaInput = NonNullable<Parameters<typeof buildConfigSchema>[0]>;
|
||||
@@ -200,6 +201,51 @@ describe("config schema", () => {
|
||||
expect(tags).toContain("performance");
|
||||
});
|
||||
|
||||
it("accepts web fetch readability and firecrawl config in the runtime zod schema", () => {
|
||||
const parsed = ToolsSchema.parse({
|
||||
web: {
|
||||
fetch: {
|
||||
readability: true,
|
||||
firecrawl: {
|
||||
enabled: true,
|
||||
apiKey: "firecrawl-test-key",
|
||||
baseUrl: "https://api.firecrawl.dev",
|
||||
onlyMainContent: true,
|
||||
maxAgeMs: 60_000,
|
||||
timeoutSeconds: 15,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed?.web?.fetch?.readability).toBe(true);
|
||||
expect(parsed?.web?.fetch).toMatchObject({
|
||||
firecrawl: {
|
||||
enabled: true,
|
||||
apiKey: "firecrawl-test-key",
|
||||
baseUrl: "https://api.firecrawl.dev",
|
||||
onlyMainContent: true,
|
||||
maxAgeMs: 60_000,
|
||||
timeoutSeconds: 15,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown keys inside web fetch firecrawl config", () => {
|
||||
expect(() =>
|
||||
ToolsSchema.parse({
|
||||
web: {
|
||||
fetch: {
|
||||
firecrawl: {
|
||||
enabled: true,
|
||||
nope: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("keeps tags in the allowed taxonomy", () => {
|
||||
const withTags = applyDerivedTags({
|
||||
"gateway.auth.token": {},
|
||||
|
||||
@@ -324,6 +324,88 @@ describe("appendAssistantMessageToSessionTranscript", () => {
|
||||
expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not append a duplicate delivery mirror for the same idempotency key", async () => {
|
||||
const sessionId = "test-session-id";
|
||||
const sessionKey = "test-session";
|
||||
const store = {
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
chatType: "direct",
|
||||
channel: "discord",
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8");
|
||||
|
||||
await appendAssistantMessageToSessionTranscript({
|
||||
sessionKey,
|
||||
text: "Hello from delivery mirror!",
|
||||
idempotencyKey: "mirror:test-source-message",
|
||||
storePath: fixture.storePath(),
|
||||
});
|
||||
await appendAssistantMessageToSessionTranscript({
|
||||
sessionKey,
|
||||
text: "Hello from delivery mirror!",
|
||||
idempotencyKey: "mirror:test-source-message",
|
||||
storePath: fixture.storePath(),
|
||||
});
|
||||
|
||||
const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir());
|
||||
const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n");
|
||||
expect(lines.length).toBe(2);
|
||||
|
||||
const messageLine = JSON.parse(lines[1]);
|
||||
expect(messageLine.message.idempotencyKey).toBe("mirror:test-source-message");
|
||||
expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!");
|
||||
});
|
||||
|
||||
it("ignores malformed transcript lines when checking mirror idempotency", async () => {
|
||||
const sessionId = "test-session-id";
|
||||
const sessionKey = "test-session";
|
||||
const store = {
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
chatType: "direct",
|
||||
channel: "discord",
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8");
|
||||
|
||||
const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir());
|
||||
fs.writeFileSync(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
version: 1,
|
||||
id: sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
"{not-json",
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
idempotencyKey: "mirror:test-source-message",
|
||||
content: [{ type: "text", text: "Hello from delivery mirror!" }],
|
||||
},
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await appendAssistantMessageToSessionTranscript({
|
||||
sessionKey,
|
||||
text: "Hello from delivery mirror!",
|
||||
idempotencyKey: "mirror:test-source-message",
|
||||
storePath: fixture.storePath(),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n");
|
||||
expect(lines.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAndPersistSessionFile", () => {
|
||||
|
||||
@@ -15,6 +15,58 @@ async function resolveRealStorePath(sessionsDir: string): Promise<string> {
|
||||
return fsSync.realpathSync.native(path.join(sessionsDir, "sessions.json"));
|
||||
}
|
||||
|
||||
async function createAgentSessionStores(
|
||||
root: string,
|
||||
agentIds: string[],
|
||||
): Promise<Record<string, string>> {
|
||||
const storePaths: Record<string, string> = {};
|
||||
for (const agentId of agentIds) {
|
||||
const sessionsDir = path.join(root, "agents", agentId, "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(sessionsDir, "sessions.json"), "{}", "utf8");
|
||||
storePaths[agentId] = await resolveRealStorePath(sessionsDir);
|
||||
}
|
||||
return storePaths;
|
||||
}
|
||||
|
||||
function createCustomRootCfg(customRoot: string, defaultAgentId = "ops"): OpenClawConfig {
|
||||
return {
|
||||
session: {
|
||||
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: defaultAgentId, default: true }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function expectTargetsToContainStores(
|
||||
targets: Array<{ agentId: string; storePath: string }>,
|
||||
stores: Record<string, string>,
|
||||
): void {
|
||||
expect(targets).toEqual(
|
||||
expect.arrayContaining(
|
||||
Object.entries(stores).map(([agentId, storePath]) => ({
|
||||
agentId,
|
||||
storePath,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const discoveryResolvers = [
|
||||
{
|
||||
label: "async",
|
||||
resolve: async (cfg: OpenClawConfig, env: NodeJS.ProcessEnv) =>
|
||||
await resolveAllAgentSessionStoreTargets(cfg, { env }),
|
||||
},
|
||||
{
|
||||
label: "sync",
|
||||
resolve: async (cfg: OpenClawConfig, env: NodeJS.ProcessEnv) =>
|
||||
resolveAllAgentSessionStoreTargetsSync(cfg, { env }),
|
||||
},
|
||||
] as const;
|
||||
|
||||
describe("resolveSessionStoreTargets", () => {
|
||||
it("resolves all configured agent stores", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
@@ -83,97 +135,39 @@ describe("resolveAllAgentSessionStoreTargets", () => {
|
||||
it("includes discovered on-disk agent stores alongside configured targets", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const opsSessionsDir = path.join(stateDir, "agents", "ops", "sessions");
|
||||
const retiredSessionsDir = path.join(stateDir, "agents", "retired", "sessions");
|
||||
await fs.mkdir(opsSessionsDir, { recursive: true });
|
||||
await fs.mkdir(retiredSessionsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
const storePaths = await createAgentSessionStores(stateDir, ["ops", "retired"]);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [{ id: "ops", default: true }],
|
||||
},
|
||||
};
|
||||
const opsStorePath = await resolveRealStorePath(opsSessionsDir);
|
||||
const retiredStorePath = await resolveRealStorePath(retiredSessionsDir);
|
||||
|
||||
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
||||
|
||||
expect(targets).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
agentId: "ops",
|
||||
storePath: opsStorePath,
|
||||
},
|
||||
{
|
||||
agentId: "retired",
|
||||
storePath: retiredStorePath,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(targets.filter((target) => target.storePath === opsStorePath)).toHaveLength(1);
|
||||
expectTargetsToContainStores(targets, storePaths);
|
||||
expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("discovers retired agent stores under a configured custom session root", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions");
|
||||
const retiredSessionsDir = path.join(customRoot, "agents", "retired", "sessions");
|
||||
await fs.mkdir(opsSessionsDir, { recursive: true });
|
||||
await fs.mkdir(retiredSessionsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
session: {
|
||||
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "ops", default: true }],
|
||||
},
|
||||
};
|
||||
const opsStorePath = await resolveRealStorePath(opsSessionsDir);
|
||||
const retiredStorePath = await resolveRealStorePath(retiredSessionsDir);
|
||||
const storePaths = await createAgentSessionStores(customRoot, ["ops", "retired"]);
|
||||
const cfg = createCustomRootCfg(customRoot);
|
||||
|
||||
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
||||
|
||||
expect(targets).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
agentId: "ops",
|
||||
storePath: opsStorePath,
|
||||
},
|
||||
{
|
||||
agentId: "retired",
|
||||
storePath: retiredStorePath,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(targets.filter((target) => target.storePath === opsStorePath)).toHaveLength(1);
|
||||
expectTargetsToContainStores(targets, storePaths);
|
||||
expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the actual on-disk store path for discovered retired agents", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions");
|
||||
const retiredSessionsDir = path.join(customRoot, "agents", "Retired Agent", "sessions");
|
||||
await fs.mkdir(opsSessionsDir, { recursive: true });
|
||||
await fs.mkdir(retiredSessionsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
session: {
|
||||
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "ops", default: true }],
|
||||
},
|
||||
};
|
||||
const retiredStorePath = await resolveRealStorePath(retiredSessionsDir);
|
||||
const storePaths = await createAgentSessionStores(customRoot, ["ops", "Retired Agent"]);
|
||||
const cfg = createCustomRootCfg(customRoot);
|
||||
|
||||
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
||||
|
||||
@@ -181,7 +175,7 @@ describe("resolveAllAgentSessionStoreTargets", () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
agentId: "retired-agent",
|
||||
storePath: retiredStorePath,
|
||||
storePath: storePaths["Retired Agent"],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
@@ -223,73 +217,52 @@ describe("resolveAllAgentSessionStoreTargets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("skips unreadable or invalid discovery roots when other roots are still readable", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
await fs.mkdir(customRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8");
|
||||
for (const resolver of discoveryResolvers) {
|
||||
it(`skips unreadable or invalid discovery roots when other roots are still readable (${resolver.label})`, async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
await fs.mkdir(customRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8");
|
||||
|
||||
const envStateDir = path.join(home, "env-state");
|
||||
const mainSessionsDir = path.join(envStateDir, "agents", "main", "sessions");
|
||||
const retiredSessionsDir = path.join(envStateDir, "agents", "retired", "sessions");
|
||||
await fs.mkdir(mainSessionsDir, { recursive: true });
|
||||
await fs.mkdir(retiredSessionsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
const envStateDir = path.join(home, "env-state");
|
||||
const storePaths = await createAgentSessionStores(envStateDir, ["main", "retired"]);
|
||||
const cfg = createCustomRootCfg(customRoot, "main");
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: envStateDir,
|
||||
};
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
session: {
|
||||
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: envStateDir,
|
||||
};
|
||||
const retiredStorePath = await resolveRealStorePath(retiredSessionsDir);
|
||||
|
||||
await expect(resolveAllAgentSessionStoreTargets(cfg, { env })).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
agentId: "retired",
|
||||
storePath: retiredStorePath,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips symlinked discovered stores under templated agents roots", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions");
|
||||
const leakedFile = path.join(home, "outside.json");
|
||||
await fs.mkdir(opsSessionsDir, { recursive: true });
|
||||
await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8");
|
||||
await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json"));
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
session: {
|
||||
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "ops", default: true }],
|
||||
},
|
||||
};
|
||||
|
||||
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
||||
expect(targets).not.toContainEqual({
|
||||
agentId: "ops",
|
||||
storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")),
|
||||
await expect(resolver.resolve(cfg, env)).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
agentId: "retired",
|
||||
storePath: storePaths.retired,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it(`skips symlinked discovered stores under templated agents roots (${resolver.label})`, async () => {
|
||||
await withTempHome(async (home) => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions");
|
||||
const leakedFile = path.join(home, "outside.json");
|
||||
await fs.mkdir(opsSessionsDir, { recursive: true });
|
||||
await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8");
|
||||
await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json"));
|
||||
|
||||
const targets = await resolver.resolve(createCustomRootCfg(customRoot), process.env);
|
||||
expect(targets).not.toContainEqual({
|
||||
agentId: "ops",
|
||||
storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("skips discovered directories that only normalize into the default main agent", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
@@ -315,73 +288,3 @@ describe("resolveAllAgentSessionStoreTargets", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAllAgentSessionStoreTargetsSync", () => {
|
||||
it("skips unreadable or invalid discovery roots when other roots are still readable", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
await fs.mkdir(customRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8");
|
||||
|
||||
const envStateDir = path.join(home, "env-state");
|
||||
const mainSessionsDir = path.join(envStateDir, "agents", "main", "sessions");
|
||||
const retiredSessionsDir = path.join(envStateDir, "agents", "retired", "sessions");
|
||||
await fs.mkdir(mainSessionsDir, { recursive: true });
|
||||
await fs.mkdir(retiredSessionsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
session: {
|
||||
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: envStateDir,
|
||||
};
|
||||
const retiredStorePath = await resolveRealStorePath(retiredSessionsDir);
|
||||
|
||||
expect(resolveAllAgentSessionStoreTargetsSync(cfg, { env })).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
agentId: "retired",
|
||||
storePath: retiredStorePath,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips symlinked discovered stores under templated agents roots", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions");
|
||||
const leakedFile = path.join(home, "outside.json");
|
||||
await fs.mkdir(opsSessionsDir, { recursive: true });
|
||||
await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8");
|
||||
await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json"));
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
session: {
|
||||
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "ops", default: true }],
|
||||
},
|
||||
};
|
||||
|
||||
const targets = resolveAllAgentSessionStoreTargetsSync(cfg, { env: process.env });
|
||||
expect(targets).not.toContainEqual({
|
||||
agentId: "ops",
|
||||
storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,6 +135,7 @@ export async function appendAssistantMessageToSessionTranscript(params: {
|
||||
sessionKey: string;
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
idempotencyKey?: string;
|
||||
/** Optional override for store path (mostly for tests). */
|
||||
storePath?: string;
|
||||
}): Promise<{ ok: true; sessionFile: string } | { ok: false; reason: string }> {
|
||||
@@ -179,6 +180,13 @@ export async function appendAssistantMessageToSessionTranscript(params: {
|
||||
|
||||
await ensureSessionHeader({ sessionFile, sessionId: entry.sessionId });
|
||||
|
||||
if (
|
||||
params.idempotencyKey &&
|
||||
(await transcriptHasIdempotencyKey(sessionFile, params.idempotencyKey))
|
||||
) {
|
||||
return { ok: true, sessionFile };
|
||||
}
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
sessionManager.appendMessage({
|
||||
role: "assistant",
|
||||
@@ -202,8 +210,34 @@ export async function appendAssistantMessageToSessionTranscript(params: {
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
...(params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}),
|
||||
});
|
||||
|
||||
emitSessionTranscriptUpdate(sessionFile);
|
||||
return { ok: true, sessionFile };
|
||||
}
|
||||
|
||||
async function transcriptHasIdempotencyKey(
|
||||
transcriptPath: string,
|
||||
idempotencyKey: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(transcriptPath, "utf-8");
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line) as { message?: { idempotencyKey?: unknown } };
|
||||
if (parsed.message?.idempotencyKey === idempotencyKey) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -307,6 +307,8 @@ export type AgentCompactionConfig = {
|
||||
reserveTokensFloor?: number;
|
||||
/** Max share of context window for history during safeguard pruning (0.1–0.9, default 0.5). */
|
||||
maxHistoryShare?: number;
|
||||
/** Additional compaction-summary instructions that can preserve language or persona continuity. */
|
||||
customInstructions?: string;
|
||||
/** Preserve this many most-recent user/assistant turns verbatim in compaction summary context. */
|
||||
recentTurnsPreserve?: number;
|
||||
/** Identifier-preservation instruction policy for compaction summaries. */
|
||||
|
||||
@@ -58,6 +58,7 @@ export type StatusReactionsEmojiConfig = {
|
||||
error?: string;
|
||||
stallSoft?: string;
|
||||
stallHard?: string;
|
||||
compacting?: string;
|
||||
};
|
||||
|
||||
export type StatusReactionsTimingConfig = {
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { CommonChannelMessagingConfig } from "./types.channel-messaging-common.js";
|
||||
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||
|
||||
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
||||
export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive";
|
||||
|
||||
export type SignalGroupConfig = {
|
||||
requireMention?: boolean;
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
};
|
||||
|
||||
export type SignalAccountConfig = CommonChannelMessagingConfig & {
|
||||
/** Optional explicit E.164 account for signal-cli. */
|
||||
account?: string;
|
||||
@@ -24,6 +31,8 @@ export type SignalAccountConfig = CommonChannelMessagingConfig & {
|
||||
ignoreAttachments?: boolean;
|
||||
ignoreStories?: boolean;
|
||||
sendReadReceipts?: boolean;
|
||||
/** Per-group overrides keyed by Signal group id (or "*"). */
|
||||
groups?: Record<string, SignalGroupConfig>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
||||
|
||||
@@ -91,6 +91,7 @@ export const AgentDefaultsSchema = z
|
||||
keepRecentTokens: z.number().int().positive().optional(),
|
||||
reserveTokensFloor: z.number().int().nonnegative().optional(),
|
||||
maxHistoryShare: z.number().min(0.1).max(0.9).optional(),
|
||||
customInstructions: z.string().optional(),
|
||||
identifierPolicy: z
|
||||
.union([z.literal("strict"), z.literal("off"), z.literal("custom")])
|
||||
.optional(),
|
||||
|
||||
@@ -327,6 +327,18 @@ export const ToolsWebFetchSchema = z
|
||||
cacheTtlMinutes: z.number().nonnegative().optional(),
|
||||
maxRedirects: z.number().int().nonnegative().optional(),
|
||||
userAgent: z.string().optional(),
|
||||
readability: z.boolean().optional(),
|
||||
firecrawl: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
apiKey: SecretInputSchema.optional().register(sensitive),
|
||||
baseUrl: z.string().optional(),
|
||||
onlyMainContent: z.boolean().optional(),
|
||||
maxAgeMs: z.number().int().nonnegative().optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
@@ -757,6 +769,7 @@ export const AgentEntrySchema = z
|
||||
.strict()
|
||||
.optional(),
|
||||
sandbox: AgentSandboxSchema,
|
||||
params: z.record(z.string(), z.unknown()).optional(),
|
||||
tools: AgentToolsSchema,
|
||||
runtime: AgentRuntimeSchema,
|
||||
})
|
||||
|
||||
@@ -979,6 +979,16 @@ export const SlackConfigSchema = SlackAccountSchema.safeExtend({
|
||||
validateSlackSigningSecretRequirements(value, ctx);
|
||||
});
|
||||
|
||||
const SignalGroupEntrySchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
toolsBySender: ToolPolicyBySenderSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const SignalGroupsSchema = z.record(z.string(), SignalGroupEntrySchema.optional()).optional();
|
||||
|
||||
export const SignalAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
@@ -1003,6 +1013,7 @@ export const SignalAccountSchemaBase = z
|
||||
defaultTo: z.string().optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
groups: SignalGroupsSchema,
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
|
||||
@@ -169,6 +169,7 @@ export const MessagesSchema = z
|
||||
error: z.string().optional(),
|
||||
stallSoft: z.string().optional(),
|
||||
stallHard: z.string().optional(),
|
||||
compacting: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
65
src/config/zod-schema.signal-groups.test.ts
Normal file
65
src/config/zod-schema.signal-groups.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateConfigObject } from "./config.js";
|
||||
|
||||
describe("signal groups schema", () => {
|
||||
it("accepts top-level Signal groups overrides", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
signal: {
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: false,
|
||||
},
|
||||
"+1234567890": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts per-account Signal groups overrides", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
signal: {
|
||||
accounts: {
|
||||
primary: {
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown keys in Signal groups entries", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
signal: {
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: false,
|
||||
nope: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues.some((issue) => issue.path.startsWith("channels.signal.groups"))).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -596,6 +596,7 @@ export const OpenClawSchema = z
|
||||
wideArea: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
domain: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
Reference in New Issue
Block a user