fix(validation): preserve wave 2 boundary compatibility (#125031)

This commit is contained in:
Peter Steinberger
2026-08-16 22:36:06 -07:00
committed by GitHub
parent bb5c27bf76
commit 49ff3e5b69
11 changed files with 126 additions and 39 deletions
@@ -47,6 +47,39 @@ describe("session manager codec compatibility", () => {
expect(isIndexedSessionEntry(entry)).toBe(false);
});
it.each([
{ name: "singleton", reason: ["reset"] },
{ name: "nested singleton", reason: [["reset"]] },
])("preserves a $name legacy reset reason", ({ reason }) => {
const manager = SessionManager.fromEntries([
{
type: "session",
version: CURRENT_SESSION_VERSION,
id: "legacy-reset-session",
timestamp: "2026-01-01T00:00:00.000Z",
cwd: "/tmp",
},
{
type: "message",
id: "before-reset",
parentId: null,
message: { role: "user", content: "before" },
},
{ type: "reset", id: "legacy-reset", parentId: "before-reset", reason },
{
type: "message",
id: "after-reset",
parentId: "legacy-reset",
message: { role: "user", content: "after" },
},
]);
expect(manager.getEntry("legacy-reset")).toBeDefined();
const context = JSON.stringify(manager.buildSessionContext());
expect(context).not.toContain("before");
expect(context).toContain("after");
});
it("parses opaque tree links without widening their variants", () => {
expect(parseParentLinkedOpaqueEntry({ type: "future", id: "f1", parentId: null })).toEqual({
id: "f1",
+1 -1
View File
@@ -83,7 +83,7 @@ const indexedSessionEntrySchema = z.discriminatedUnion("type", [
z.looseObject({
...indexedSessionEntryBaseShape,
type: z.literal("reset"),
reason: z.enum(["new", "reset", "idle", "daily", "cron-stale"]),
reason: z.coerce.string().pipe(z.enum(["new", "reset", "idle", "daily", "cron-stale"])),
firstKeptEntryId: z.string().optional(),
}),
z.looseObject({
+1 -1
View File
@@ -45,7 +45,7 @@ const optionalCronTokenCountSchema = z
.optional()
.transform((value) => asSafeIntegerInRange(value, { min: 0 }));
const cronUsageSchema = z
.looseObject({
.object({
input_tokens: optionalCronTokenCountSchema,
output_tokens: optionalCronTokenCountSchema,
total_tokens: optionalCronTokenCountSchema,
+6 -1
View File
@@ -558,13 +558,18 @@ describe("cron task run history", () => {
usage: undefined,
});
expect(parseCronRunLogEntryObject({ ...base, usage: [] })?.usage).toBeUndefined();
expect(parseCronRunLogEntryObject({ ...base, usage: { input_tokens: 0 } })?.usage).toEqual({
expect(
parseCronRunLogEntryObject({ ...base, usage: { input_tokens: 0, future_tokens: 1 } })?.usage,
).toEqual({
input_tokens: 0,
output_tokens: undefined,
total_tokens: undefined,
cache_read_tokens: undefined,
cache_write_tokens: undefined,
});
expect(
parseCronRunLogEntryObject({ ...base, usage: { future_tokens: 1 } })?.usage,
).toBeUndefined();
expect(parseCronRunLogEntryObject({ ...base, ts: MAX_DATE_TIMESTAMP_MS })).not.toBeNull();
expect(parseCronRunLogEntryObject({ ...base, ts: MAX_DATE_TIMESTAMP_MS + 1 })).toBeNull();
});
+1 -1
View File
@@ -179,7 +179,7 @@ describe("persisted exec approvals schema", () => {
const parsed = tryParsePersistedExecApprovals(
JSON.stringify({
version: 1,
agents: { main: { allowlist: ["ls", { pattern: "cat", source: "legacy" }] } },
agents: { main: { allowlist: [" ls ", { pattern: "cat", source: "legacy" }] } },
}),
);
expect(parsed?.agents?.main?.allowlist?.[0]).toMatchObject({ pattern: "ls" });
+7 -2
View File
@@ -1,6 +1,7 @@
// Parses and normalizes the persisted exec approval policy.
import { randomBytes } from "node:crypto";
import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
@@ -33,7 +34,7 @@ function normalizePersistedAllowlistSource(value: string): "allow-always" | unde
}
const persistedExecAllowlistEntrySchema = z
.union([
z.string().refine((value) => value.trim().length > 0),
z.string().trim().min(1),
z.looseObject({
pattern: z.string().refine((value) => value.trim().length > 0),
id: z.string().optional(),
@@ -51,6 +52,10 @@ const persistedExecAllowlistEntrySchema = z
const persistedExecApprovalsAgentSchema = persistedExecApprovalPolicySchema.extend({
allowlist: z.array(persistedExecAllowlistEntrySchema).optional(),
});
const persistedExecApprovalsAgentsSchema = z
.unknown()
.refine((value) => !isRecord(value) || !Object.hasOwn(value, "__proto__"))
.pipe(z.record(z.string(), persistedExecApprovalsAgentSchema));
const persistedExecApprovalsSchema = z.looseObject({
version: z.literal(1),
socket: z
@@ -60,7 +65,7 @@ const persistedExecApprovalsSchema = z.looseObject({
})
.optional(),
defaults: persistedExecApprovalPolicySchema.optional(),
agents: z.record(z.string(), persistedExecApprovalsAgentSchema).optional(),
agents: persistedExecApprovalsAgentsSchema.optional(),
});
export const DEFAULT_SECURITY: ExecSecurity = "full";
+8 -2
View File
@@ -223,11 +223,17 @@ describe("exec approvals SQLite store", () => {
expect(row()).toMatchObject({ has_socket_token: 1, socket_path: first.socket?.path });
});
it("fails closed and warns once for malformed raw_json", () => {
it.each([
{ name: "invalid JSON", raw: "{not-json" },
{
name: "an invalid own prototype-key policy",
raw: '{"version":1,"agents":{"__proto__":{"security":42}}}',
},
])("fails closed and warns once for $name", ({ raw }) => {
const { db } = openOpenClawStateDatabase();
db.prepare(
"INSERT INTO exec_approvals_config (config_key, raw_json, socket_path, has_socket_token, default_security, default_ask, default_ask_fallback, auto_allow_skills, agent_count, allowlist_count, updated_at_ms) VALUES (?, ?, NULL, 0, NULL, NULL, NULL, NULL, 0, 0, 1)",
).run("current", "{not-json");
).run("current", raw);
expect(loadExecApprovals().defaults).toMatchObject({ security: "deny", ask: "off" });
expect(loadExecApprovals().defaults?.security).toBe("deny");
+36 -22
View File
@@ -168,30 +168,44 @@ describe("legacy ACP replay doctor migration", () => {
});
});
it("retains malformed state without partially importing it", async () => {
await withTestDir({ prefix: "openclaw-acp-replay-migration-" }, async (stateDir) => {
const sourcePath = await writeLegacyStore(stateDir, {
...legacyStore(),
sessions: { broken: { sessionId: "broken" } },
});
const result = await migrateLegacyAcpReplayLedger({
detected: detectLegacyAcpReplayLedger({
it.each([
{
name: "an ordinary session",
sessionId: "broken",
sessions: { broken: { sessionId: "broken" } },
},
{
name: "an own prototype-key session",
sessionId: "__proto__",
sessions: JSON.parse('{"__proto__":{"sessionId":"broken"}}') as Record<string, unknown>,
},
])(
"retains malformed state from $name without partially importing it",
async ({ sessionId, sessions }) => {
await withTestDir({ prefix: "openclaw-acp-replay-migration-" }, async (stateDir) => {
const sourcePath = await writeLegacyStore(stateDir, {
...legacyStore(),
sessions,
});
const result = await migrateLegacyAcpReplayLedger({
detected: detectLegacyAcpReplayLedger({
stateDir,
doctorOnlyStateMigrations: true,
}),
stateDir,
doctorOnlyStateMigrations: true,
}),
stateDir,
});
});
expect(result.changes).toEqual([]);
expect(result.warnings[0]).toContain("legacy ACP replay session broken is invalid");
await expect(fs.stat(sourcePath)).resolves.toBeDefined();
await expect(
createSqliteAcpEventLedger({
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
}).readReplayBySessionId({ sessionId: "broken" }),
).resolves.toEqual({ complete: false, events: [] });
});
});
expect(result.changes).toEqual([]);
expect(result.warnings[0]).toContain(`legacy ACP replay session ${sessionId} is invalid`);
await expect(fs.stat(sourcePath)).resolves.toBeDefined();
await expect(
createSqliteAcpEventLedger({
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
}).readReplayBySessionId({ sessionId }),
).resolves.toEqual({ complete: false, events: [] });
});
},
);
it("removes a retry source when its prior import already exists", async () => {
await withTestDir({ prefix: "openclaw-acp-replay-migration-" }, async (stateDir) => {
+6 -2
View File
@@ -6,6 +6,7 @@ import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { isDeepStrictEqual } from "node:util";
import type { SessionUpdate } from "@agentclientprotocol/sdk";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { z } from "zod";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
@@ -63,7 +64,10 @@ type AcpReplayMigrationDatabase = Pick<
"acp_replay_events" | "acp_replay_sessions"
>;
const legacyAcpReplayUpdateSchema = z.looseObject({ sessionUpdate: z.string() });
const legacyAcpReplayRecordSchema = z.custom<Record<string, unknown>>(isRecord);
const legacyAcpReplayUpdateSchema = legacyAcpReplayRecordSchema.refine(
(update) => typeof update.sessionUpdate === "string",
);
const legacyAcpReplayEventSchema = z.looseObject({
seq: z
.number()
@@ -90,7 +94,7 @@ const legacyAcpReplaySessionSchema = z.looseObject({
});
const legacyAcpReplayLedgerSchema = z.looseObject({
version: z.literal(LEGACY_LEDGER_VERSION),
sessions: z.record(z.string(), z.unknown()),
sessions: legacyAcpReplayRecordSchema,
});
function resolveLegacyAcpReplayLedgerPath(stateDir: string): string {
+17
View File
@@ -146,6 +146,23 @@ describe("Skill Workshop persisted record validation", () => {
},
},
},
{
name: "invalid own prototype-key metric",
value: {
...shippedProposal,
evaluation: {
...validEvaluation,
outcomes: [
{
evaluatorId: "reviewer",
pluginId: "review-plugin",
status: "completed",
result: { metrics: JSON.parse('{"__proto__":null}') as unknown },
},
],
},
},
},
])("rejects $name", ({ value }) => {
expect(parseSkillProposalRecord(value)).toBeNull();
});
+10 -7
View File
@@ -1,3 +1,4 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { err, ok, type Result } from "@openclaw/normalization-core/result";
import { z } from "zod";
import {
@@ -35,14 +36,16 @@ const skillProposalFindingSchema = z.looseObject({
.refine((value) => value >= 1)
.optional(),
});
const skillProposalMetricValueSchema = z.union([
z.string().max(4_000),
z.number().finite(),
z.boolean(),
]);
const skillProposalMetricsSchema = z
.record(z.string(), z.union([z.string().max(4_000), z.number().finite(), z.boolean()]))
.superRefine((metrics, context) => {
const keys = Object.keys(metrics);
if (keys.length > 64 || keys.some((key) => key.length === 0 || key.length > 128)) {
context.addIssue({ code: "custom", message: "invalid evaluation metric keys" });
}
});
.custom<Record<string, unknown>>(isRecord)
.transform((metrics) => new Map(Object.entries(metrics)))
.pipe(z.map(z.string().min(1).max(128), skillProposalMetricValueSchema))
.refine((metrics) => metrics.size <= 64);
const skillProposalEvaluationResultSchema = z.looseObject({
summary: z.string().max(8_000).optional(),
evaluatorVersion: z.string().max(128).optional(),