mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 17:11:42 -06:00
fix(validation): preserve wave 2 boundary compatibility (#125031)
This commit is contained in:
committed by
GitHub
parent
bb5c27bf76
commit
49ff3e5b69
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user