Compare commits
6 Commits
feat/routi
...
pr15280-me
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5def704e2 | ||
|
|
55e4a5c227 | ||
|
|
32e93a63d4 | ||
|
|
58456bc10d | ||
|
|
87ed2632d3 | ||
|
|
dba1d4c705 |
@@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293.
|
||||
- Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic.
|
||||
- Providers/MiniMax: switch implicit MiniMax API-key provider from `openai-completions` to `anthropic-messages` with the correct Anthropic-compatible base URL, fixing `invalid role: developer (2013)` errors on MiniMax M2.5. (#15275) Thanks @lailoo.
|
||||
- Config: accept `$schema` key in config file so JSON Schema editor tooling works without validation errors. (#14998)
|
||||
- Routing: enforce strict binding-scope matching across peer/guild/team/roles so peer-scoped Discord/Slack bindings no longer match unrelated guild/team contexts or fallback tiers. (#15274) Thanks @lailoo.
|
||||
- Web UI: add `img` to DOMPurify allowed tags and `src`/`alt` to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo.
|
||||
- Ollama/Agents: use resolved model/provider base URLs for native `/api/chat` streaming (including aliased providers), normalize `/v1` endpoints, and forward abort + `maxTokens` stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98.
|
||||
|
||||
@@ -61,7 +61,7 @@ See the [full reference](/gateway/configuration-reference) for every available f
|
||||
## Strict validation
|
||||
|
||||
<Warning>
|
||||
OpenClaw only accepts configurations that fully match the schema. Unknown keys, malformed types, or invalid values cause the Gateway to **refuse to start**.
|
||||
OpenClaw only accepts configurations that fully match the schema. Unknown keys, malformed types, or invalid values cause the Gateway to **refuse to start**. The only root-level exception is `$schema` (string), so editors can attach JSON Schema metadata.
|
||||
</Warning>
|
||||
|
||||
When validation fails:
|
||||
|
||||
@@ -11,7 +11,7 @@ title: "Strict Config Validation"
|
||||
|
||||
## Goals
|
||||
|
||||
- **Reject unknown config keys everywhere** (root + nested).
|
||||
- **Reject unknown config keys everywhere** (root + nested), except root `$schema` metadata.
|
||||
- **Reject plugin config without a schema**; don’t load that plugin.
|
||||
- **Remove legacy auto-migration on load**; migrations run via doctor only.
|
||||
- **Auto-run doctor (dry-run) on startup**; if invalid, block non-diagnostic commands.
|
||||
@@ -24,7 +24,7 @@ title: "Strict Config Validation"
|
||||
## Strict validation rules
|
||||
|
||||
- Config must match the schema exactly at every level.
|
||||
- Unknown keys are validation errors (no passthrough at root or nested).
|
||||
- Unknown keys are validation errors (no passthrough at root or nested), except root `$schema` when it is a string.
|
||||
- `plugins.entries.<id>.config` must be validated by the plugin’s schema.
|
||||
- If a plugin lacks a schema, **reject plugin load** and surface a clear error.
|
||||
- Unknown `channels.<id>` keys are errors unless a plugin manifest declares the channel id.
|
||||
|
||||
24
src/config/config.schema-key.test.ts
Normal file
24
src/config/config.schema-key.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OpenClawSchema } from "./zod-schema.js";
|
||||
|
||||
describe("$schema key in config (#14998)", () => {
|
||||
it("accepts config with $schema string", () => {
|
||||
const result = OpenClawSchema.safeParse({
|
||||
$schema: "https://openclaw.ai/config.json",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.$schema).toBe("https://openclaw.ai/config.json");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts config without $schema", () => {
|
||||
const result = OpenClawSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-string $schema", () => {
|
||||
const result = OpenClawSchema.safeParse({ $schema: 123 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
5
src/config/schema-root-metadata.ts
Normal file
5
src/config/schema-root-metadata.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const ROOT_CONFIG_SCHEMA_KEY = "$schema";
|
||||
|
||||
export const ROOT_CONFIG_METADATA_KEYS = [ROOT_CONFIG_SCHEMA_KEY] as const;
|
||||
|
||||
export const ROOT_CONFIG_METADATA_KEY_SET = new Set<string>(ROOT_CONFIG_METADATA_KEYS);
|
||||
@@ -85,4 +85,13 @@ describe("mapSensitivePaths", () => {
|
||||
expect(hints["gateway.auth.token"]?.sensitive).toBe(true);
|
||||
expect(hints["skills.entries.*.apiKey"]?.sensitive).toBe(true);
|
||||
});
|
||||
|
||||
it("wrapped main schema still yields sensitive hints", () => {
|
||||
const wrapped = z.preprocess((value) => value, OpenClawSchema);
|
||||
const hints = mapSensitivePaths(wrapped, "", {});
|
||||
|
||||
expect(hints["agents.defaults.memorySearch.remote.apiKey"]?.sensitive).toBe(true);
|
||||
expect(hints["channels.discord.accounts.*.token"]?.sensitive).toBe(true);
|
||||
expect(hints["gateway.auth.token"]?.sensitive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,9 +156,8 @@ export function applySensitiveHints(
|
||||
return next;
|
||||
}
|
||||
|
||||
// Seems to be the only way tsgo accepts us to check if we have a ZodClass
|
||||
// with an unwrap() method. And it's overly complex because oxlint and
|
||||
// tsgo are each forbidding what the other allows.
|
||||
// Tsgo and oxlint disagree on some Zod internals, so keep wrapper checks
|
||||
// explicit and narrow.
|
||||
interface ZodDummy {
|
||||
unwrap: () => z.ZodType;
|
||||
}
|
||||
@@ -172,19 +171,67 @@ function isUnwrappable(object: unknown): object is ZodDummy {
|
||||
);
|
||||
}
|
||||
|
||||
interface ZodPipeDummy {
|
||||
_def: {
|
||||
in?: z.ZodType;
|
||||
out?: z.ZodType;
|
||||
};
|
||||
}
|
||||
|
||||
function getPipeTraversalSchema(schema: z.ZodType): z.ZodType | null {
|
||||
if (!(schema instanceof z.ZodPipe)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pipeSchema = schema as unknown as ZodPipeDummy;
|
||||
const input = pipeSchema._def.in;
|
||||
const output = pipeSchema._def.out;
|
||||
|
||||
if (output && !(output instanceof z.ZodTransform)) {
|
||||
return output;
|
||||
}
|
||||
if (input && !(input instanceof z.ZodTransform)) {
|
||||
return input;
|
||||
}
|
||||
return output ?? input ?? null;
|
||||
}
|
||||
|
||||
function unwrapSchemaForTraversal(schema: z.ZodType): {
|
||||
schema: z.ZodType;
|
||||
isSensitive: boolean;
|
||||
} {
|
||||
let currentSchema = schema;
|
||||
let isSensitive = sensitive.has(currentSchema);
|
||||
|
||||
while (true) {
|
||||
if (isUnwrappable(currentSchema)) {
|
||||
currentSchema = currentSchema.unwrap();
|
||||
isSensitive ||= sensitive.has(currentSchema);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pipeTraversalSchema = getPipeTraversalSchema(currentSchema);
|
||||
if (pipeTraversalSchema) {
|
||||
currentSchema = pipeTraversalSchema;
|
||||
isSensitive ||= sensitive.has(currentSchema);
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return { schema: currentSchema, isSensitive };
|
||||
}
|
||||
|
||||
export function mapSensitivePaths(
|
||||
schema: z.ZodType,
|
||||
path: string,
|
||||
hints: ConfigUiHints,
|
||||
): ConfigUiHints {
|
||||
let next = { ...hints };
|
||||
let currentSchema = schema;
|
||||
let isSensitive = sensitive.has(currentSchema);
|
||||
|
||||
while (isUnwrappable(currentSchema)) {
|
||||
currentSchema = currentSchema.unwrap();
|
||||
isSensitive ||= sensitive.has(currentSchema);
|
||||
}
|
||||
const unwrapped = unwrapSchemaForTraversal(schema);
|
||||
let currentSchema = unwrapped.schema;
|
||||
const isSensitive = unwrapped.isSensitive;
|
||||
|
||||
if (isSensitive) {
|
||||
next[path] = { ...next[path], sensitive: true };
|
||||
|
||||
@@ -7,6 +7,7 @@ describe("config schema", () => {
|
||||
const schema = res.schema as { properties?: Record<string, unknown> };
|
||||
expect(schema.properties?.gateway).toBeTruthy();
|
||||
expect(schema.properties?.agents).toBeTruthy();
|
||||
expect(schema.properties?.$schema).toBeUndefined();
|
||||
expect(res.uiHints.gateway?.label).toBe("Gateway");
|
||||
expect(res.uiHints["gateway.auth.token"]?.sensitive).toBe(true);
|
||||
expect(res.version).toBeTruthy();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
|
||||
import { CHANNEL_IDS } from "../channels/registry.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { ROOT_CONFIG_METADATA_KEYS, ROOT_CONFIG_METADATA_KEY_SET } from "./schema-root-metadata.js";
|
||||
import { applySensitiveHints, buildBaseHints, mapSensitivePaths } from "./schema.hints.js";
|
||||
import { OpenClawSchema } from "./zod-schema.js";
|
||||
|
||||
@@ -297,7 +298,25 @@ function applyChannelSchemas(schema: ConfigSchema, channels: ChannelUiMetadata[]
|
||||
|
||||
let cachedBase: ConfigSchemaResponse | null = null;
|
||||
|
||||
function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
|
||||
function stripRootMetadataForUiSchema(schema: ConfigSchema): ConfigSchema {
|
||||
const next = cloneSchema(schema);
|
||||
const root = asSchemaObject(next);
|
||||
if (!root || !root.properties) {
|
||||
return next;
|
||||
}
|
||||
|
||||
// Allow root metadata keys in config files, but keep the Control UI focused
|
||||
// on user-editable config sections.
|
||||
for (const key of ROOT_CONFIG_METADATA_KEYS) {
|
||||
delete root.properties[key];
|
||||
}
|
||||
if (Array.isArray(root.required)) {
|
||||
root.required = root.required.filter((key) => !ROOT_CONFIG_METADATA_KEY_SET.has(key));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function stripChannelsForUiSchema(schema: ConfigSchema): ConfigSchema {
|
||||
const next = cloneSchema(schema);
|
||||
const root = asSchemaObject(next);
|
||||
if (!root || !root.properties) {
|
||||
@@ -323,7 +342,7 @@ function buildBaseConfigSchema(): ConfigSchemaResponse {
|
||||
schema.title = "OpenClawConfig";
|
||||
const hints = mapSensitivePaths(OpenClawSchema, "", buildBaseHints());
|
||||
const next = {
|
||||
schema: stripChannelSchema(schema),
|
||||
schema: stripChannelsForUiSchema(stripRootMetadataForUiSchema(schema)),
|
||||
uiHints: hints,
|
||||
version: VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { ROOT_CONFIG_SCHEMA_KEY } from "./schema-root-metadata.js";
|
||||
import { ToolsSchema } from "./zod-schema.agent-runtime.js";
|
||||
import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js";
|
||||
import { ApprovalsSchema } from "./zod-schema.approvals.js";
|
||||
@@ -95,6 +96,7 @@ const MemorySchema = z
|
||||
|
||||
export const OpenClawSchema = z
|
||||
.object({
|
||||
[ROOT_CONFIG_SCHEMA_KEY]: z.string().optional(),
|
||||
meta: z
|
||||
.object({
|
||||
lastTouchedVersion: z.string().optional(),
|
||||
|
||||
Reference in New Issue
Block a user