diff --git a/src/agents/sessions/session-manager-codec.test.ts b/src/agents/sessions/session-manager-codec.test.ts index 6cc0f0da89f4..9581ab206ba8 100644 --- a/src/agents/sessions/session-manager-codec.test.ts +++ b/src/agents/sessions/session-manager-codec.test.ts @@ -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", diff --git a/src/agents/sessions/session-manager-codec.ts b/src/agents/sessions/session-manager-codec.ts index 0a45e2794293..4e243ab58124 100644 --- a/src/agents/sessions/session-manager-codec.ts +++ b/src/agents/sessions/session-manager-codec.ts @@ -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({ diff --git a/src/cron/task-run-detail.ts b/src/cron/task-run-detail.ts index 6ada3b634fb7..743f802c4cea 100644 --- a/src/cron/task-run-detail.ts +++ b/src/cron/task-run-detail.ts @@ -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, diff --git a/src/cron/task-run-history.test.ts b/src/cron/task-run-history.test.ts index 89aec9620972..15bf8fce91fb 100644 --- a/src/cron/task-run-history.test.ts +++ b/src/cron/task-run-history.test.ts @@ -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(); }); diff --git a/src/infra/exec-approvals-config.test.ts b/src/infra/exec-approvals-config.test.ts index bea31a8bac9a..d3fa5e045a0c 100644 --- a/src/infra/exec-approvals-config.test.ts +++ b/src/infra/exec-approvals-config.test.ts @@ -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" }); diff --git a/src/infra/exec-approvals-config.ts b/src/infra/exec-approvals-config.ts index 51e32d1892cc..648342d261ad 100644 --- a/src/infra/exec-approvals-config.ts +++ b/src/infra/exec-approvals-config.ts @@ -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"; diff --git a/src/infra/exec-approvals-store.test.ts b/src/infra/exec-approvals-store.test.ts index 6c0802f13cee..3f10601567dd 100644 --- a/src/infra/exec-approvals-store.test.ts +++ b/src/infra/exec-approvals-store.test.ts @@ -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"); diff --git a/src/infra/state-migrations.acp-replay.test.ts b/src/infra/state-migrations.acp-replay.test.ts index 8c7ffd13d13b..27c009e01d11 100644 --- a/src/infra/state-migrations.acp-replay.test.ts +++ b/src/infra/state-migrations.acp-replay.test.ts @@ -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, + }, + ])( + "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) => { diff --git a/src/infra/state-migrations.acp-replay.ts b/src/infra/state-migrations.acp-replay.ts index 9a32ec0c38d4..206c958cdfda 100644 --- a/src/infra/state-migrations.acp-replay.ts +++ b/src/infra/state-migrations.acp-replay.ts @@ -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>(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 { diff --git a/src/skills/workshop/store-record.test.ts b/src/skills/workshop/store-record.test.ts index 5bc4cbd33c21..807bdf112900 100644 --- a/src/skills/workshop/store-record.test.ts +++ b/src/skills/workshop/store-record.test.ts @@ -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(); }); diff --git a/src/skills/workshop/store-record.ts b/src/skills/workshop/store-record.ts index 029c86c77f08..054bb7617e2a 100644 --- a/src/skills/workshop/store-record.ts +++ b/src/skills/workshop/store-record.ts @@ -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>(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(),