chore: merge origin/main into main

This commit is contained in:
Peter Steinberger
2026-02-22 13:42:52 +00:00
304 changed files with 17041 additions and 5502 deletions

View File

@@ -363,6 +363,16 @@ describe("legacy config detection", () => {
expectedValue: "work",
});
});
it("accepts bindings[].comment on load", () => {
expectValidConfigValue({
config: {
bindings: [{ agentId: "main", comment: "primary route", match: { channel: "telegram" } }],
},
readValue: (config) =>
(config as { bindings?: Array<{ comment?: string }> }).bindings?.[0]?.comment,
expectedValue: "primary route",
});
});
it("rejects session.sendPolicy.rules[].match.provider on load", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");

View File

@@ -77,4 +77,60 @@ describe("config io paths", () => {
expect(io.loadConfig().gateway?.port).toBe(20003);
});
});
it("normalizes safeBinProfiles at config load time", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, "openclaw.json");
await fs.writeFile(
configPath,
JSON.stringify(
{
tools: {
exec: {
safeBinProfiles: {
" MyFilter ": {
allowedValueFlags: ["--limit", " --limit ", ""],
},
},
},
},
agents: {
list: [
{
id: "ops",
tools: {
exec: {
safeBinProfiles: {
" Custom ": {
deniedFlags: ["-f", " -f ", ""],
},
},
},
},
},
],
},
},
null,
2,
),
"utf-8",
);
const io = createIoForHome(home);
expect(io.configPath).toBe(configPath);
const cfg = io.loadConfig();
expect(cfg.tools?.exec?.safeBinProfiles).toEqual({
myfilter: {
allowedValueFlags: ["--limit"],
},
});
expect(cfg.agents?.list?.[0]?.tools?.exec?.safeBinProfiles).toEqual({
custom: {
deniedFlags: ["-f"],
},
});
});
});
});

View File

@@ -6,6 +6,7 @@ import { isDeepStrictEqual } from "node:util";
import JSON5 from "json5";
import { ensureOwnerDisplaySecret } from "../agents/owner-display.js";
import { loadDotEnv } from "../infra/dotenv.js";
import { normalizeSafeBinProfileFixtures } from "../infra/exec-safe-bin-policy.js";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
import {
loadShellEnvFallback,
@@ -555,6 +556,33 @@ function maybeLoadDotEnvForConfig(env: NodeJS.ProcessEnv): void {
loadDotEnv({ quiet: true });
}
function normalizeExecSafeBinProfilesInConfig(cfg: OpenClawConfig): void {
const normalizeExec = (exec: unknown) => {
if (!exec || typeof exec !== "object" || Array.isArray(exec)) {
return;
}
const typedExec = exec as { safeBinProfiles?: Record<string, unknown> };
const normalized = normalizeSafeBinProfileFixtures(
typedExec.safeBinProfiles as Record<
string,
{
minPositional?: number;
maxPositional?: number;
allowedValueFlags?: readonly string[];
deniedFlags?: readonly string[];
}
>,
);
typedExec.safeBinProfiles = Object.keys(normalized).length > 0 ? normalized : undefined;
};
normalizeExec(cfg.tools?.exec);
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
for (const agent of agents) {
normalizeExec(agent?.tools?.exec);
}
}
export function parseConfigJson5(
raw: string,
json5: { parse: (value: string) => unknown } = JSON5,
@@ -675,6 +703,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
),
);
normalizeConfigPaths(cfg);
normalizeExecSafeBinProfilesInConfig(cfg);
const duplicates = findDuplicateAgentDirs(cfg, {
env: deps.env,
@@ -875,6 +904,16 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}
warnIfConfigFromFuture(validated.config, deps.logger);
const snapshotConfig = normalizeConfigPaths(
applyTalkApiKey(
applyModelDefaults(
applyAgentDefaults(
applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))),
),
),
),
);
normalizeExecSafeBinProfilesInConfig(snapshotConfig);
return {
snapshot: {
path: configPath,
@@ -885,17 +924,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
// for config set/unset operations (issue #6070)
resolved: coerceConfig(resolvedConfigRaw),
valid: true,
config: normalizeConfigPaths(
applyTalkApiKey(
applyModelDefaults(
applyAgentDefaults(
applySessionDefaults(
applyLoggingDefaults(applyMessageDefaults(validated.config)),
),
),
),
),
),
config: snapshotConfig,
hash,
issues: [],
warnings: validated.warnings,

View File

@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
GROUP_POLICY_BLOCKED_LABEL,
resetMissingProviderGroupPolicyFallbackWarningsForTesting,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveOpenProviderRuntimeGroupPolicy,
resolveRuntimeGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "./runtime-group-policy.js";
beforeEach(() => {
resetMissingProviderGroupPolicyFallbackWarningsForTesting();
});
describe("resolveRuntimeGroupPolicy", () => {
it.each([
{
title: "fails closed when provider config is missing and no defaults are set",
params: { providerConfigPresent: false },
expectedPolicy: "allowlist",
expectedFallbackApplied: true,
},
{
title: "keeps configured fallback when provider config is present",
params: { providerConfigPresent: true, configuredFallbackPolicy: "open" as const },
expectedPolicy: "open",
expectedFallbackApplied: false,
},
{
title: "ignores global defaults when provider config is missing",
params: {
providerConfigPresent: false,
defaultGroupPolicy: "disabled" as const,
configuredFallbackPolicy: "open" as const,
missingProviderFallbackPolicy: "allowlist" as const,
},
expectedPolicy: "allowlist",
expectedFallbackApplied: true,
},
])("$title", ({ params, expectedPolicy, expectedFallbackApplied }) => {
const resolved = resolveRuntimeGroupPolicy(params);
expect(resolved.groupPolicy).toBe(expectedPolicy);
expect(resolved.providerMissingFallbackApplied).toBe(expectedFallbackApplied);
});
});
describe("resolveOpenProviderRuntimeGroupPolicy", () => {
it("uses open fallback when provider config exists", () => {
const resolved = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: true,
});
expect(resolved.groupPolicy).toBe("open");
expect(resolved.providerMissingFallbackApplied).toBe(false);
});
});
describe("resolveAllowlistProviderRuntimeGroupPolicy", () => {
it("uses allowlist fallback when provider config exists", () => {
const resolved = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: true,
});
expect(resolved.groupPolicy).toBe("allowlist");
expect(resolved.providerMissingFallbackApplied).toBe(false);
});
});
describe("resolveDefaultGroupPolicy", () => {
it("returns channels.defaults.groupPolicy when present", () => {
const resolved = resolveDefaultGroupPolicy({
channels: { defaults: { groupPolicy: "disabled" } },
});
expect(resolved).toBe("disabled");
});
});
describe("warnMissingProviderGroupPolicyFallbackOnce", () => {
it("logs only once per provider/account key", () => {
const lines: string[] = [];
const first = warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied: true,
providerKey: "runtime-policy-test",
accountId: "account-a",
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
log: (message) => lines.push(message),
});
const second = warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied: true,
providerKey: "runtime-policy-test",
accountId: "account-a",
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
log: (message) => lines.push(message),
});
expect(first).toBe(true);
expect(second).toBe(false);
expect(lines).toHaveLength(1);
expect(lines[0]).toContain("channels.runtime-policy-test is missing");
expect(lines[0]).toContain("room messages blocked");
});
});

View File

@@ -0,0 +1,118 @@
import type { GroupPolicy } from "./types.base.js";
export type RuntimeGroupPolicyResolution = {
groupPolicy: GroupPolicy;
providerMissingFallbackApplied: boolean;
};
export type RuntimeGroupPolicyParams = {
providerConfigPresent: boolean;
groupPolicy?: GroupPolicy;
defaultGroupPolicy?: GroupPolicy;
configuredFallbackPolicy?: GroupPolicy;
missingProviderFallbackPolicy?: GroupPolicy;
};
export function resolveRuntimeGroupPolicy(
params: RuntimeGroupPolicyParams,
): RuntimeGroupPolicyResolution {
const configuredFallbackPolicy = params.configuredFallbackPolicy ?? "open";
const missingProviderFallbackPolicy = params.missingProviderFallbackPolicy ?? "allowlist";
const groupPolicy = params.providerConfigPresent
? (params.groupPolicy ?? params.defaultGroupPolicy ?? configuredFallbackPolicy)
: (params.groupPolicy ?? missingProviderFallbackPolicy);
const providerMissingFallbackApplied =
!params.providerConfigPresent && params.groupPolicy === undefined;
return { groupPolicy, providerMissingFallbackApplied };
}
export type ResolveProviderRuntimeGroupPolicyParams = {
providerConfigPresent: boolean;
groupPolicy?: GroupPolicy;
defaultGroupPolicy?: GroupPolicy;
};
export type GroupPolicyDefaultsConfig = {
channels?: {
defaults?: {
groupPolicy?: GroupPolicy;
};
};
};
export function resolveDefaultGroupPolicy(cfg: GroupPolicyDefaultsConfig): GroupPolicy | undefined {
return cfg.channels?.defaults?.groupPolicy;
}
export const GROUP_POLICY_BLOCKED_LABEL = {
group: "group messages",
guild: "guild messages",
room: "room messages",
channel: "channel messages",
space: "space messages",
} as const;
/**
* Standard provider runtime policy:
* - configured provider fallback: open
* - missing provider fallback: allowlist (fail-closed)
*/
export function resolveOpenProviderRuntimeGroupPolicy(
params: ResolveProviderRuntimeGroupPolicyParams,
): RuntimeGroupPolicyResolution {
return resolveRuntimeGroupPolicy({
providerConfigPresent: params.providerConfigPresent,
groupPolicy: params.groupPolicy,
defaultGroupPolicy: params.defaultGroupPolicy,
configuredFallbackPolicy: "open",
missingProviderFallbackPolicy: "allowlist",
});
}
/**
* Strict provider runtime policy:
* - configured provider fallback: allowlist
* - missing provider fallback: allowlist (fail-closed)
*/
export function resolveAllowlistProviderRuntimeGroupPolicy(
params: ResolveProviderRuntimeGroupPolicyParams,
): RuntimeGroupPolicyResolution {
return resolveRuntimeGroupPolicy({
providerConfigPresent: params.providerConfigPresent,
groupPolicy: params.groupPolicy,
defaultGroupPolicy: params.defaultGroupPolicy,
configuredFallbackPolicy: "allowlist",
missingProviderFallbackPolicy: "allowlist",
});
}
const warnedMissingProviderGroupPolicy = new Set<string>();
export function warnMissingProviderGroupPolicyFallbackOnce(params: {
providerMissingFallbackApplied: boolean;
providerKey: string;
accountId?: string;
blockedLabel?: string;
log: (message: string) => void;
}): boolean {
if (!params.providerMissingFallbackApplied) {
return false;
}
const key = `${params.providerKey}:${params.accountId ?? "*"}`;
if (warnedMissingProviderGroupPolicy.has(key)) {
return false;
}
warnedMissingProviderGroupPolicy.add(key);
const blockedLabel = params.blockedLabel?.trim() || "group messages";
params.log(
`${params.providerKey}: channels.${params.providerKey} is missing; defaulting groupPolicy to "allowlist" (${blockedLabel} blocked until explicitly configured).`,
);
return true;
}
/**
* Test helper. Keeps warning-cache state deterministic across test files.
*/
export function resetMissingProviderGroupPolicyFallbackWarningsForTesting(): void {
warnedMissingProviderGroupPolicy.clear();
}

View File

@@ -88,12 +88,14 @@ export const FIELD_HELP: Record<string, string> = {
"Enable known poll tool no-progress loop detection (default: true).",
"tools.loopDetection.detectors.pingPong": "Enable ping-pong loop detection (default: true).",
"tools.exec.notifyOnExit":
"When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.",
"When true (default), backgrounded exec sessions on exit and node exec lifecycle events enqueue a system event and request a heartbeat.",
"tools.exec.notifyOnExitEmptySuccess":
"When true, successful backgrounded exec exits with empty output still enqueue a completion system event (default: false).",
"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.exec.safeBinProfiles":
"Optional per-binary safe-bin profiles (positional limits + allowed/denied flags).",
"tools.fs.workspaceOnly":
"Restrict filesystem tools (read/write/edit/apply_patch) to the workspace directory (default: false).",
"tools.sessions.visibility":

View File

@@ -92,6 +92,7 @@ export const FIELD_LABELS: Record<string, string> = {
"tools.exec.node": "Exec Node Binding",
"tools.exec.pathPrepend": "Exec PATH Prepend",
"tools.exec.safeBins": "Exec Safe Bins",
"tools.exec.safeBinProfiles": "Exec Safe Bin Profiles",
"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)",

View File

@@ -72,6 +72,7 @@ export type AgentsConfig = {
export type AgentBinding = {
agentId: string;
comment?: string;
match: {
channel: string;
accountId?: string;

View File

@@ -1,4 +1,5 @@
import type { ChatType } from "../channels/chat-type.js";
import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js";
import type { AgentElevatedAllowFromConfig, SessionSendPolicyAction } from "./types.base.js";
export type MediaUnderstandingScopeMatch = {
@@ -190,6 +191,8 @@ export type ExecToolConfig = {
pathPrepend?: string[];
/** Safe stdin-only binaries that can run without allowlist entries. */
safeBins?: string[];
/** Optional custom safe-bin profiles for entries in tools.exec.safeBins. */
safeBinProfiles?: Record<string, SafeBinProfileFixture>;
/** Default time (ms) before an exec command auto-backgrounds. */
backgroundMs?: number;
/** Default timeout (seconds) before auto-killing exec commands. */

View File

@@ -337,6 +337,15 @@ const ToolExecApplyPatchSchema = z
.strict()
.optional();
const ToolExecSafeBinProfileSchema = z
.object({
minPositional: z.number().int().nonnegative().optional(),
maxPositional: z.number().int().nonnegative().optional(),
allowedValueFlags: z.array(z.string()).optional(),
deniedFlags: z.array(z.string()).optional(),
})
.strict();
const ToolExecBaseShape = {
host: z.enum(["sandbox", "gateway", "node"]).optional(),
security: z.enum(["deny", "allowlist", "full"]).optional(),
@@ -344,6 +353,7 @@ const ToolExecBaseShape = {
node: z.string().optional(),
pathPrepend: z.array(z.string()).optional(),
safeBins: z.array(z.string()).optional(),
safeBinProfiles: z.record(z.string(), ToolExecSafeBinProfileSchema).optional(),
backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(),

View File

@@ -16,6 +16,7 @@ export const BindingsSchema = z
z
.object({
agentId: z.string(),
comment: z.string().optional(),
match: z
.object({
channel: z.string(),