mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 22:14:47 +00:00
fix(config): harden persisted store shapes
This commit is contained in:
@@ -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 } =
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user