mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 01:11:04 +00:00
Gateway: harden session discovery fallbacks
This commit is contained in:
@@ -3,7 +3,11 @@ import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../../test/helpers/temp-home.js";
|
||||
import type { OpenClawConfig } from "../config.js";
|
||||
import { resolveAllAgentSessionStoreTargets, resolveSessionStoreTargets } from "./targets.js";
|
||||
import {
|
||||
resolveAllAgentSessionStoreTargets,
|
||||
resolveAllAgentSessionStoreTargetsSync,
|
||||
resolveSessionStoreTargets,
|
||||
} from "./targets.js";
|
||||
|
||||
describe("resolveSessionStoreTargets", () => {
|
||||
it("resolves all configured agent stores", () => {
|
||||
@@ -209,4 +213,82 @@ describe("resolveAllAgentSessionStoreTargets", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips unreadable or invalid discovery roots when other roots are still readable", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
await fs.mkdir(customRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8");
|
||||
|
||||
const envStateDir = path.join(home, "env-state");
|
||||
const mainSessionsDir = path.join(envStateDir, "agents", "main", "sessions");
|
||||
const retiredSessionsDir = path.join(envStateDir, "agents", "retired", "sessions");
|
||||
await fs.mkdir(mainSessionsDir, { recursive: true });
|
||||
await fs.mkdir(retiredSessionsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
session: {
|
||||
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: envStateDir,
|
||||
};
|
||||
|
||||
await expect(resolveAllAgentSessionStoreTargets(cfg, { env })).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
agentId: "retired",
|
||||
storePath: path.join(retiredSessionsDir, "sessions.json"),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAllAgentSessionStoreTargetsSync", () => {
|
||||
it("skips unreadable or invalid discovery roots when other roots are still readable", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
await fs.mkdir(customRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8");
|
||||
|
||||
const envStateDir = path.join(home, "env-state");
|
||||
const mainSessionsDir = path.join(envStateDir, "agents", "main", "sessions");
|
||||
const retiredSessionsDir = path.join(envStateDir, "agents", "retired", "sessions");
|
||||
await fs.mkdir(mainSessionsDir, { recursive: true });
|
||||
await fs.mkdir(retiredSessionsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
session: {
|
||||
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: envStateDir,
|
||||
};
|
||||
|
||||
expect(resolveAllAgentSessionStoreTargetsSync(cfg, { env })).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
agentId: "retired",
|
||||
storePath: path.join(retiredSessionsDir, "sessions.json"),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,15 @@ export type SessionStoreTarget = {
|
||||
storePath: string;
|
||||
};
|
||||
|
||||
const NON_FATAL_DISCOVERY_ERROR_CODES = new Set([
|
||||
"EACCES",
|
||||
"ELOOP",
|
||||
"ENOENT",
|
||||
"ENOTDIR",
|
||||
"EPERM",
|
||||
"ESTALE",
|
||||
]);
|
||||
|
||||
function dedupeTargetsByStorePath(targets: SessionStoreTarget[]): SessionStoreTarget[] {
|
||||
const deduped = new Map<string, SessionStoreTarget>();
|
||||
for (const target of targets) {
|
||||
@@ -30,6 +39,11 @@ function dedupeTargetsByStorePath(targets: SessionStoreTarget[]): SessionStoreTa
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
function shouldSkipDiscoveryError(err: unknown): boolean {
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||||
return typeof code === "string" && NON_FATAL_DISCOVERY_ERROR_CODES.has(code);
|
||||
}
|
||||
|
||||
function resolveSessionStoreDiscoveryState(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv,
|
||||
@@ -71,7 +85,16 @@ export function resolveAllAgentSessionStoreTargetsSync(
|
||||
const env = params.env ?? process.env;
|
||||
const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env);
|
||||
const discoveredTargets = toDiscoveredSessionStoreTargets(
|
||||
agentsRoots.flatMap((agentsDir) => resolveAgentSessionDirsFromAgentsDirSync(agentsDir)),
|
||||
agentsRoots.flatMap((agentsDir) => {
|
||||
try {
|
||||
return resolveAgentSessionDirsFromAgentsDirSync(agentsDir);
|
||||
} catch (err) {
|
||||
if (shouldSkipDiscoveryError(err)) {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return dedupeTargetsByStorePath([...configuredTargets, ...discoveredTargets]);
|
||||
}
|
||||
@@ -84,7 +107,16 @@ export async function resolveAllAgentSessionStoreTargets(
|
||||
const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env);
|
||||
|
||||
const discoveredDirs = await Promise.all(
|
||||
agentsRoots.map((agentsDir) => resolveAgentSessionDirsFromAgentsDir(agentsDir)),
|
||||
agentsRoots.map(async (agentsDir) => {
|
||||
try {
|
||||
return await resolveAgentSessionDirsFromAgentsDir(agentsDir);
|
||||
} catch (err) {
|
||||
if (shouldSkipDiscoveryError(err)) {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const discoveredTargets = toDiscoveredSessionStoreTargets(discoveredDirs.flat());
|
||||
return dedupeTargetsByStorePath([...configuredTargets, ...discoveredTargets]);
|
||||
|
||||
@@ -56,7 +56,9 @@ describe("resolveSessionKeyForRun", () => {
|
||||
expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(cfg);
|
||||
});
|
||||
|
||||
it("caches misses so repeated lookups do not rebuild the combined store", () => {
|
||||
it("caches misses briefly before re-checking the combined store", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-12T15:00:00Z"));
|
||||
hoisted.loadConfigMock.mockReturnValue({});
|
||||
hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({
|
||||
storePath: "(multiple)",
|
||||
@@ -66,5 +68,11 @@ describe("resolveSessionKeyForRun", () => {
|
||||
expect(resolveSessionKeyForRun("missing-run")).toBeUndefined();
|
||||
expect(resolveSessionKeyForRun("missing-run")).toBeUndefined();
|
||||
expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(1_001);
|
||||
|
||||
expect(resolveSessionKeyForRun("missing-run")).toBeUndefined();
|
||||
expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledTimes(2);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,14 @@ import { toAgentRequestSessionKey } from "../routing/session-key.js";
|
||||
import { loadCombinedSessionStoreForGateway } from "./session-utils.js";
|
||||
|
||||
const RUN_LOOKUP_CACHE_LIMIT = 256;
|
||||
const resolvedSessionKeyByRunId = new Map<string, string | null>();
|
||||
const RUN_LOOKUP_MISS_TTL_MS = 1_000;
|
||||
|
||||
type RunLookupCacheEntry = {
|
||||
sessionKey: string | null;
|
||||
expiresAt: number | null;
|
||||
};
|
||||
|
||||
const resolvedSessionKeyByRunId = new Map<string, RunLookupCacheEntry>();
|
||||
|
||||
function setResolvedSessionKeyCache(runId: string, sessionKey: string | null): void {
|
||||
if (!runId) {
|
||||
@@ -19,7 +26,10 @@ function setResolvedSessionKeyCache(runId: string, sessionKey: string | null): v
|
||||
resolvedSessionKeyByRunId.delete(oldest);
|
||||
}
|
||||
}
|
||||
resolvedSessionKeyByRunId.set(runId, sessionKey);
|
||||
resolvedSessionKeyByRunId.set(runId, {
|
||||
sessionKey,
|
||||
expiresAt: sessionKey === null ? Date.now() + RUN_LOOKUP_MISS_TTL_MS : null,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSessionKeyForRun(runId: string) {
|
||||
@@ -29,7 +39,13 @@ export function resolveSessionKeyForRun(runId: string) {
|
||||
}
|
||||
const cachedLookup = resolvedSessionKeyByRunId.get(runId);
|
||||
if (cachedLookup !== undefined) {
|
||||
return cachedLookup ?? undefined;
|
||||
if (cachedLookup.sessionKey !== null) {
|
||||
return cachedLookup.sessionKey;
|
||||
}
|
||||
if ((cachedLookup.expiresAt ?? 0) > Date.now()) {
|
||||
return undefined;
|
||||
}
|
||||
resolvedSessionKeyByRunId.delete(runId);
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
const { store } = loadCombinedSessionStoreForGateway(cfg);
|
||||
|
||||
Reference in New Issue
Block a user