fix(config): harden persisted store shapes

This commit is contained in:
Vincent Koc
2026-05-16 03:45:37 +08:00
parent 628c753f3b
commit ea4e3cd4fa
9 changed files with 192 additions and 17 deletions

View File

@@ -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.

View File

@@ -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 } =

View File

@@ -73,6 +73,10 @@ type LoadPersistedAuthProfileStoreOptions = {
repairOAuthSecretPayloads?: boolean;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function normalizeSecretBackedField(params: {
entry: Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
if (!record.profiles || typeof record.profiles !== "object") {
const record = raw;
if (!isRecord(record.profiles)) {
return null;
}
const profiles = record.profiles as Record<string, unknown>;
const profiles = record.profiles;
const normalized: Record<string, AuthProfileCredential> = {};
const rejected: RejectedCredentialEntry[] = [];
for (const [key, value] of Object.entries(profiles)) {

View File

@@ -324,6 +324,20 @@ describe("session store writer queue", () => {
expect((store[key] as Record<string, unknown>).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<string, SessionEntry>);
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({

View File

@@ -33,6 +33,10 @@ function isSessionStoreRecord(value: unknown): value is Record<string, SessionEn
return !!value && typeof value === "object" && !Array.isArray(value);
}
function isSessionEntryRecord(value: unknown): value is SessionEntry {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function normalizeSessionEntryDelivery(entry: SessionEntry): SessionEntry {
const normalized = normalizeSessionDeliveryFields({
channel: entry.channel,
@@ -84,7 +88,9 @@ function stripPersistedSkillsCache(entry: SessionEntry): SessionEntry {
export function normalizeSessionStore(store: Record<string, SessionEntry>): 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(

View File

@@ -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();
});
});
});

View File

@@ -7,6 +7,10 @@ const SessionStoreSchema = z.record(z.string(), z.unknown()) as z.ZodType<
Record<string, SessionEntry | undefined>
>;
function isSessionEntryRecord(value: unknown): value is SessionEntry {
return !!value && typeof value === "object" && !Array.isArray(value);
}
export function readSessionStoreReadOnly(
storePath: string,
): Record<string, SessionEntry | undefined> {
@@ -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 {};
}

View File

@@ -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 () => {

View File

@@ -50,6 +50,10 @@ type CronStateFile = {
jobs: Record<string, CronStateFileEntry>;
};
function isRecord(value: unknown): value is Record<string, unknown> {
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<Record<string, unknown> | null | undefined>)
return jobs.some(
(job) =>
job != null &&
job.state !== undefined &&
typeof job.state === "object" &&
job.state !== null &&
Object.keys(job.state as Record<string, unknown>).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<string, unknown>)
@@ -213,7 +219,9 @@ export async function loadCronStore(storePath: string): Promise<CronStoreFile> {
parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
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<string, unknown>)
: {};
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"],