diff --git a/CHANGELOG.md b/CHANGELOG.md index 02ac9a9b272..78da9fdeedc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Agents/OpenAI Responses: clamp `input_tokens - cached_tokens` at zero and reconstruct `totalTokens` from input + output + cached components so Responses-API streams report consistent usage when providers under-report `input_tokens` relative to `cached_tokens`. - Plugins: reject malformed `package.json` `openclaw.extensions` metadata during install, discovery, and post-update payload smoke instead of silently dropping invalid entries. - Media/files: sniff `input_file` bytes before trusting declared MIME headers, rejecting spoofed image or zip payloads before they become agent-visible text. +- Config persistence: ignore malformed array/scalar auth profile, cron job state, and session store entries instead of hydrating them into numeric profile ids, crashed cron rows, or invalid session records. - Hooks: raise bounded gateway lifecycle hook wait budgets to 5 seconds for shutdown and 10 seconds for pre-restart, giving short restart notification handlers time to finish before shutdown continues. (#82273) Thanks @bryanbaer. - Plugin releases: require external package compatibility metadata in the npm plugin publish plan, matching the ClawHub package contract before packages ship. - Agents/OpenAI-compatible: honor per-model `max_completion_tokens`/`max_tokens` params in embedded OpenAI-completions runs so high-token Kimi-style routes keep their configured completion cap. Fixes #82230. Thanks @albert-zen. diff --git a/src/agents/auth-profiles.ensureauthprofilestore.test.ts b/src/agents/auth-profiles.ensureauthprofilestore.test.ts index 98a449f9edb..ca61a42ac9d 100644 --- a/src/agents/auth-profiles.ensureauthprofilestore.test.ts +++ b/src/agents/auth-profiles.ensureauthprofilestore.test.ts @@ -80,6 +80,14 @@ describe("ensureAuthProfileStore", () => { ); } + function writeRawAuthProfileStore(agentDir: string, raw: unknown): void { + fs.writeFileSync( + path.join(agentDir, "auth-profiles.json"), + `${JSON.stringify(raw, null, 2)}\n`, + "utf8", + ); + } + function loadAuthProfile(agentDir: string, profileId: string): AuthProfileCredential { clearRuntimeAuthProfileStoreSnapshots(); const store = ensureAuthProfileStore(agentDir); @@ -204,6 +212,43 @@ describe("ensureAuthProfileStore", () => { } }); + it("ignores array-shaped auth profile stores instead of loading numeric profile ids", () => { + withTempAgentDir("openclaw-auth-profiles-array-", (agentDir) => { + writeRawAuthProfileStore(agentDir, { + version: AUTH_STORE_VERSION, + profiles: [ + { + type: "api_key", + provider: "openai", + key: "test-array-shaped-profile", + }, + ], + }); + + const store = ensureAuthProfileStore(agentDir); + + expect(store.profiles["0"]).toBeUndefined(); + expect(Object.keys(store.profiles)).toEqual([]); + }); + }); + + it("ignores top-level array auth stores instead of treating entries as profiles", () => { + withTempAgentDir("openclaw-auth-top-array-", (agentDir) => { + writeRawAuthProfileStore(agentDir, [ + { + type: "api_key", + provider: "openai", + key: "test-array-shaped-store", + }, + ]); + + const store = ensureAuthProfileStore(agentDir); + + expect(store.profiles["0"]).toBeUndefined(); + expect(Object.keys(store.profiles)).toEqual([]); + }); + }); + it("merges main auth profiles into agent store and keeps agent overrides", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-merge-")); const { mainDir, agentDir, previousStateDir, previousAgentDir, previousPiAgentDir } = diff --git a/src/agents/auth-profiles/persisted.ts b/src/agents/auth-profiles/persisted.ts index 260c04ce04e..f573ed8115b 100644 --- a/src/agents/auth-profiles/persisted.ts +++ b/src/agents/auth-profiles/persisted.ts @@ -73,6 +73,10 @@ type LoadPersistedAuthProfileStoreOptions = { repairOAuthSecretPayloads?: boolean; }; +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + function normalizeSecretBackedField(params: { entry: Record; valueField: "key" | "token"; @@ -564,10 +568,10 @@ function warnRejectedCredentialEntries(source: string, rejected: RejectedCredent } function coerceLegacyAuthStore(raw: unknown): LegacyAuthStore | null { - if (!raw || typeof raw !== "object") { + if (!isRecord(raw)) { return null; } - const record = raw as Record; + const record = raw; if ("profiles" in record) { return null; } @@ -586,14 +590,14 @@ function coerceLegacyAuthStore(raw: unknown): LegacyAuthStore | null { } export function coercePersistedAuthProfileStore(raw: unknown): AuthProfileStore | null { - if (!raw || typeof raw !== "object") { + if (!isRecord(raw)) { return null; } - const record = raw as Record; - if (!record.profiles || typeof record.profiles !== "object") { + const record = raw; + if (!isRecord(record.profiles)) { return null; } - const profiles = record.profiles as Record; + const profiles = record.profiles; const normalized: Record = {}; const rejected: RejectedCredentialEntry[] = []; for (const [key, value] of Object.entries(profiles)) { diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 473212f171e..574a81c1bba 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -324,6 +324,20 @@ describe("session store writer queue", () => { expect((store[key] as Record).counter).toBe(N); }); + it("drops non-object persisted session entries on load", async () => { + const { storePath } = await makeTmpStore({ + "agent:main:good": { sessionId: "s-good", updatedAt: Date.now() }, + "agent:main:string": "not-a-session-entry", + "agent:main:array": [{ sessionId: "s-array", updatedAt: Date.now() }], + } as unknown as Record); + + const store = loadSessionStore(storePath, { skipCache: true }); + + expect(store["agent:main:good"]?.sessionId).toBe("s-good"); + expect(store["agent:main:string"]).toBeUndefined(); + expect(store["agent:main:array"]).toBeUndefined(); + }); + it("skips session store disk writes when payload is unchanged", async () => { const key = "agent:main:no-op-save"; const { storePath } = await makeTmpStore({ diff --git a/src/config/sessions/store-load.ts b/src/config/sessions/store-load.ts index 16a64fcf076..278f22a17ea 100644 --- a/src/config/sessions/store-load.ts +++ b/src/config/sessions/store-load.ts @@ -33,6 +33,10 @@ function isSessionStoreRecord(value: unknown): value is Record): boolean { let changed = false; for (const [key, entry] of Object.entries(store)) { - if (!entry) { + if (!isSessionEntryRecord(entry)) { + delete store[key]; + changed = true; continue; } const normalized = stripPersistedSkillsCache( diff --git a/src/config/sessions/store-read.test.ts b/src/config/sessions/store-read.test.ts index bdeaba49138..0261ed743ae 100644 --- a/src/config/sessions/store-read.test.ts +++ b/src/config/sessions/store-read.test.ts @@ -18,4 +18,26 @@ describe("readSessionStoreReadOnly", () => { expect(store["session-1"]?.updatedAt).toBe(1); }); }); + + it("filters non-object entries from read-only session store snapshots", async () => { + await withTempDir({ prefix: "openclaw-session-store-readonly-" }, async (dir) => { + const storePath = path.join(dir, "sessions.json"); + + await fs.writeFile( + storePath, + JSON.stringify({ + good: { sessionId: "s-good", updatedAt: 1 }, + scalar: "bad", + array: [{ sessionId: "s-array", updatedAt: 1 }], + }), + "utf8", + ); + + const store = readSessionStoreReadOnly(storePath); + + expect(store.good?.sessionId).toBe("s-good"); + expect(store.scalar).toBeUndefined(); + expect(store.array).toBeUndefined(); + }); + }); }); diff --git a/src/config/sessions/store-read.ts b/src/config/sessions/store-read.ts index 7444b3b6b2f..e627f24c014 100644 --- a/src/config/sessions/store-read.ts +++ b/src/config/sessions/store-read.ts @@ -7,6 +7,10 @@ const SessionStoreSchema = z.record(z.string(), z.unknown()) as z.ZodType< Record >; +function isSessionEntryRecord(value: unknown): value is SessionEntry { + return !!value && typeof value === "object" && !Array.isArray(value); +} + export function readSessionStoreReadOnly( storePath: string, ): Record { @@ -15,7 +19,10 @@ export function readSessionStoreReadOnly( if (!raw.trim()) { return {}; } - return safeParseJsonWithSchema(SessionStoreSchema, raw) ?? {}; + const parsed = safeParseJsonWithSchema(SessionStoreSchema, raw) ?? {}; + return Object.fromEntries( + Object.entries(parsed).filter(([, entry]) => isSessionEntryRecord(entry)), + ); } catch { return {}; } diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index 26d90f67934..3781da32f44 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -136,6 +136,30 @@ describe("cron store", () => { expect(loaded.jobs[0]?.enabled).toBe(true); }); + it("skips non-object persisted jobs instead of hydrating scalar rows", async () => { + const store = await makeStorePath(); + const valid = makeStore("job-valid", true).jobs[0]; + await fs.mkdir(path.dirname(store.storePath), { recursive: true }); + await fs.writeFile( + store.storePath, + JSON.stringify( + { + version: 1, + jobs: ["bad-row", 7, null, false, valid], + }, + null, + 2, + ), + "utf-8", + ); + + const loaded = await loadCronStore(store.storePath); + + expect(loaded.jobs).toHaveLength(1); + expect(loaded.jobs[0]?.id).toBe("job-valid"); + expect(loaded.jobs[0]?.state).toStrictEqual({}); + }); + it("loads split cron state synchronously for task reconciliation", async () => { const { storePath } = await makeStorePath(); await saveCronStore(storePath, makeStore("job-sync", true)); @@ -526,6 +550,48 @@ describe("cron store", () => { expect(loaded.jobs[0]?.state.nextRunAtMs).toBe(job.createdAtMs + 60_000); }); + it("drops non-object runtime state from split cron sidecars", async () => { + const store = await makeStorePath(); + const first = makeStore("job-array-state", true).jobs[0]; + const second = makeStore("job-scalar-entry", true).jobs[0]; + const config = { + version: 1, + jobs: [ + { ...first, state: {}, updatedAtMs: undefined }, + { ...second, state: {}, updatedAtMs: undefined }, + ], + }; + const statePath = store.storePath.replace(/\.json$/, "-state.json"); + + await fs.mkdir(path.dirname(store.storePath), { recursive: true }); + await fs.writeFile(store.storePath, JSON.stringify(config, null, 2), "utf-8"); + await fs.writeFile( + statePath, + JSON.stringify( + { + version: 1, + jobs: { + [first.id]: { + updatedAtMs: first.createdAtMs + 60_000, + state: ["not", "state"], + }, + [second.id]: "not-an-entry", + }, + }, + null, + 2, + ), + "utf-8", + ); + + const loaded = await loadCronStore(store.storePath); + + expect(loaded.jobs[0]?.updatedAtMs).toBe(first.createdAtMs + 60_000); + expect(loaded.jobs[0]?.state).toStrictEqual({}); + expect(loaded.jobs[1]?.updatedAtMs).toBe(second.createdAtMs); + expect(loaded.jobs[1]?.state).toStrictEqual({}); + }); + it.skipIf(process.platform === "win32")( "writes store and backup files with secure permissions", async () => { diff --git a/src/cron/store.ts b/src/cron/store.ts index 8fbd1c6b1af..3d8aed208d6 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -50,6 +50,10 @@ type CronStateFile = { jobs: Record; }; +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + function stripRuntimeOnlyCronFields(store: CronStoreFile): unknown { return { version: store.version, @@ -154,15 +158,13 @@ function hasInlineState(jobs: Array | null | undefined>) return jobs.some( (job) => job != null && - job.state !== undefined && - typeof job.state === "object" && - job.state !== null && - Object.keys(job.state as Record).length > 0, + isRecord(job.state) && + Object.keys(job.state).length > 0, ); } function ensureJobStateObject(job: CronStoreFile["jobs"][number]): void { - if (!job.state || typeof job.state !== "object") { + if (!isRecord(job.state)) { job.state = {} as never; } } @@ -186,9 +188,13 @@ function resolveUpdatedAtMs(job: CronStoreFile["jobs"][number], updatedAtMs: unk : Date.now(); } -function mergeStateFileEntry(job: CronStoreFile["jobs"][number], entry: CronStateFileEntry): void { +function mergeStateFileEntry(job: CronStoreFile["jobs"][number], entry: unknown): void { + if (!isRecord(entry)) { + backfillMissingRuntimeFields(job); + return; + } job.updatedAtMs = resolveUpdatedAtMs(job, entry.updatedAtMs); - job.state = (entry.state ?? {}) as never; + job.state = isRecord(entry.state) ? (entry.state as never) : ({} as never); if ( typeof entry.scheduleIdentity === "string" && entry.scheduleIdentity !== tryCronScheduleIdentity(job as unknown as Record) @@ -213,7 +219,9 @@ export async function loadCronStore(storePath: string): Promise { parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; - const jobs = Array.isArray(parsedRecord.jobs) ? (parsedRecord.jobs as never[]) : []; + const jobs = Array.isArray(parsedRecord.jobs) + ? (parsedRecord.jobs.filter(isRecord) as never[]) + : []; const store = { version: 1 as const, jobs: jobs.filter(Boolean) as never as CronStoreFile["jobs"], @@ -281,7 +289,9 @@ export function loadCronStoreSync(storePath: string): CronStoreFile { parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; - const jobs = Array.isArray(parsedRecord.jobs) ? (parsedRecord.jobs as never[]) : []; + const jobs = Array.isArray(parsedRecord.jobs) + ? (parsedRecord.jobs.filter(isRecord) as never[]) + : []; const store = { version: 1 as const, jobs: jobs.filter(Boolean) as never as CronStoreFile["jobs"],