Gateway: harden custom session-store discovery (#44176)
Merged via squash. Prepared head SHA: 52ebbf5188b47386f2a78ac4715993bc082e911b Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras
This commit is contained in:
committed by
GitHub
parent
dc3bb1890b
commit
46f0bfc55b
@@ -11,3 +11,4 @@ export * from "./sessions/transcript.js";
|
||||
export * from "./sessions/session-file.js";
|
||||
export * from "./sessions/delivery-info.js";
|
||||
export * from "./sessions/disk-budget.js";
|
||||
export * from "./sessions/targets.js";
|
||||
|
||||
@@ -276,19 +276,24 @@ export function resolveSessionFilePath(
|
||||
return resolveSessionTranscriptPathInDir(sessionId, sessionsDir);
|
||||
}
|
||||
|
||||
export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
|
||||
export function resolveStorePath(
|
||||
store?: string,
|
||||
opts?: { agentId?: string; env?: NodeJS.ProcessEnv },
|
||||
) {
|
||||
const agentId = normalizeAgentId(opts?.agentId ?? DEFAULT_AGENT_ID);
|
||||
const env = opts?.env ?? process.env;
|
||||
const homedir = () => resolveRequiredHomeDir(env, os.homedir);
|
||||
if (!store) {
|
||||
return resolveDefaultSessionStorePath(agentId);
|
||||
return path.join(resolveAgentSessionsDir(agentId, env, homedir), "sessions.json");
|
||||
}
|
||||
if (store.includes("{agentId}")) {
|
||||
const expanded = store.replaceAll("{agentId}", agentId);
|
||||
if (expanded.startsWith("~")) {
|
||||
return path.resolve(
|
||||
expandHomePrefix(expanded, {
|
||||
home: resolveRequiredHomeDir(process.env, os.homedir),
|
||||
env: process.env,
|
||||
homedir: os.homedir,
|
||||
home: resolveRequiredHomeDir(env, homedir),
|
||||
env,
|
||||
homedir,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -297,11 +302,28 @@ export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
|
||||
if (store.startsWith("~")) {
|
||||
return path.resolve(
|
||||
expandHomePrefix(store, {
|
||||
home: resolveRequiredHomeDir(process.env, os.homedir),
|
||||
env: process.env,
|
||||
homedir: os.homedir,
|
||||
home: resolveRequiredHomeDir(env, homedir),
|
||||
env,
|
||||
homedir,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return path.resolve(store);
|
||||
}
|
||||
|
||||
export function resolveAgentsDirFromSessionStorePath(storePath: string): string | undefined {
|
||||
const candidateAbsPath = path.resolve(storePath);
|
||||
if (path.basename(candidateAbsPath) !== "sessions.json") {
|
||||
return undefined;
|
||||
}
|
||||
const sessionsDir = path.dirname(candidateAbsPath);
|
||||
if (path.basename(sessionsDir) !== "sessions") {
|
||||
return undefined;
|
||||
}
|
||||
const agentDir = path.dirname(sessionsDir);
|
||||
const agentsDir = path.dirname(agentDir);
|
||||
if (path.basename(agentsDir) !== "agents") {
|
||||
return undefined;
|
||||
}
|
||||
return agentsDir;
|
||||
}
|
||||
|
||||
385
src/config/sessions/targets.test.ts
Normal file
385
src/config/sessions/targets.test.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import fs from "node:fs/promises";
|
||||
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,
|
||||
resolveAllAgentSessionStoreTargetsSync,
|
||||
resolveSessionStoreTargets,
|
||||
} from "./targets.js";
|
||||
|
||||
async function resolveRealStorePath(sessionsDir: string): Promise<string> {
|
||||
return await fs.realpath(path.join(sessionsDir, "sessions.json"));
|
||||
}
|
||||
|
||||
describe("resolveSessionStoreTargets", () => {
|
||||
it("resolves all configured agent stores", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
session: {
|
||||
store: "~/.openclaw/agents/{agentId}/sessions/sessions.json",
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }, { id: "work" }],
|
||||
},
|
||||
};
|
||||
|
||||
const targets = resolveSessionStoreTargets(cfg, { allAgents: true });
|
||||
|
||||
expect(targets).toEqual([
|
||||
{
|
||||
agentId: "main",
|
||||
storePath: path.resolve(
|
||||
path.join(process.env.HOME ?? "", ".openclaw/agents/main/sessions/sessions.json"),
|
||||
),
|
||||
},
|
||||
{
|
||||
agentId: "work",
|
||||
storePath: path.resolve(
|
||||
path.join(process.env.HOME ?? "", ".openclaw/agents/work/sessions/sessions.json"),
|
||||
),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes shared store paths for --all-agents", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
session: {
|
||||
store: "/tmp/shared-sessions.json",
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }, { id: "work" }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveSessionStoreTargets(cfg, { allAgents: true })).toEqual([
|
||||
{ agentId: "main", storePath: path.resolve("/tmp/shared-sessions.json") },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unknown agent ids", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }, { id: "work" }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => resolveSessionStoreTargets(cfg, { agent: "ghost" })).toThrow(/Unknown agent id/);
|
||||
});
|
||||
|
||||
it("rejects conflicting selectors", () => {
|
||||
expect(() => resolveSessionStoreTargets({}, { agent: "main", allAgents: true })).toThrow(
|
||||
/cannot be used together/i,
|
||||
);
|
||||
expect(() =>
|
||||
resolveSessionStoreTargets({}, { store: "/tmp/sessions.json", allAgents: true }),
|
||||
).toThrow(/cannot be combined/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAllAgentSessionStoreTargets", () => {
|
||||
it("includes discovered on-disk agent stores alongside configured targets", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const opsSessionsDir = path.join(stateDir, "agents", "ops", "sessions");
|
||||
const retiredSessionsDir = path.join(stateDir, "agents", "retired", "sessions");
|
||||
await fs.mkdir(opsSessionsDir, { recursive: true });
|
||||
await fs.mkdir(retiredSessionsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [{ id: "ops", default: true }],
|
||||
},
|
||||
};
|
||||
const opsStorePath = await resolveRealStorePath(opsSessionsDir);
|
||||
const retiredStorePath = await resolveRealStorePath(retiredSessionsDir);
|
||||
|
||||
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
||||
|
||||
expect(targets).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
agentId: "ops",
|
||||
storePath: opsStorePath,
|
||||
},
|
||||
{
|
||||
agentId: "retired",
|
||||
storePath: retiredStorePath,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(targets.filter((target) => target.storePath === opsStorePath)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("discovers retired agent stores under a configured custom session root", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions");
|
||||
const retiredSessionsDir = path.join(customRoot, "agents", "retired", "sessions");
|
||||
await fs.mkdir(opsSessionsDir, { recursive: true });
|
||||
await fs.mkdir(retiredSessionsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(opsSessionsDir, "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: "ops", default: true }],
|
||||
},
|
||||
};
|
||||
const opsStorePath = await resolveRealStorePath(opsSessionsDir);
|
||||
const retiredStorePath = await resolveRealStorePath(retiredSessionsDir);
|
||||
|
||||
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
||||
|
||||
expect(targets).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
agentId: "ops",
|
||||
storePath: opsStorePath,
|
||||
},
|
||||
{
|
||||
agentId: "retired",
|
||||
storePath: retiredStorePath,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(targets.filter((target) => target.storePath === opsStorePath)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the actual on-disk store path for discovered retired agents", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions");
|
||||
const retiredSessionsDir = path.join(customRoot, "agents", "Retired Agent", "sessions");
|
||||
await fs.mkdir(opsSessionsDir, { recursive: true });
|
||||
await fs.mkdir(retiredSessionsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(opsSessionsDir, "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: "ops", default: true }],
|
||||
},
|
||||
};
|
||||
const retiredStorePath = await resolveRealStorePath(retiredSessionsDir);
|
||||
|
||||
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
||||
|
||||
expect(targets).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
agentId: "retired-agent",
|
||||
storePath: retiredStorePath,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("respects the caller env when resolving configured and discovered store roots", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
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 env = {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: envStateDir,
|
||||
};
|
||||
const cfg: OpenClawConfig = {};
|
||||
const mainStorePath = await resolveRealStorePath(mainSessionsDir);
|
||||
const retiredStorePath = await resolveRealStorePath(retiredSessionsDir);
|
||||
|
||||
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env });
|
||||
|
||||
expect(targets).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
agentId: "main",
|
||||
storePath: mainStorePath,
|
||||
},
|
||||
{
|
||||
agentId: "retired",
|
||||
storePath: retiredStorePath,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
const retiredStorePath = await resolveRealStorePath(retiredSessionsDir);
|
||||
|
||||
await expect(resolveAllAgentSessionStoreTargets(cfg, { env })).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
agentId: "retired",
|
||||
storePath: retiredStorePath,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips symlinked discovered stores under templated agents roots", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions");
|
||||
const leakedFile = path.join(home, "outside.json");
|
||||
await fs.mkdir(opsSessionsDir, { recursive: true });
|
||||
await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8");
|
||||
await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json"));
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
session: {
|
||||
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "ops", default: true }],
|
||||
},
|
||||
};
|
||||
|
||||
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
||||
expect(targets).not.toContainEqual({
|
||||
agentId: "ops",
|
||||
storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("skips discovered directories that only normalize into the default main agent", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const mainSessionsDir = path.join(stateDir, "agents", "main", "sessions");
|
||||
const junkSessionsDir = path.join(stateDir, "agents", "###", "sessions");
|
||||
await fs.mkdir(mainSessionsDir, { recursive: true });
|
||||
await fs.mkdir(junkSessionsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
await fs.writeFile(path.join(junkSessionsDir, "sessions.json"), "{}", "utf8");
|
||||
|
||||
const cfg: OpenClawConfig = {};
|
||||
const mainStorePath = await resolveRealStorePath(mainSessionsDir);
|
||||
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
||||
|
||||
expect(targets).toContainEqual({
|
||||
agentId: "main",
|
||||
storePath: mainStorePath,
|
||||
});
|
||||
expect(
|
||||
targets.some((target) => target.storePath === path.join(junkSessionsDir, "sessions.json")),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
const retiredStorePath = await resolveRealStorePath(retiredSessionsDir);
|
||||
|
||||
expect(resolveAllAgentSessionStoreTargetsSync(cfg, { env })).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
agentId: "retired",
|
||||
storePath: retiredStorePath,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips symlinked discovered stores under templated agents roots", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions");
|
||||
const leakedFile = path.join(home, "outside.json");
|
||||
await fs.mkdir(opsSessionsDir, { recursive: true });
|
||||
await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8");
|
||||
await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json"));
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
session: {
|
||||
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "ops", default: true }],
|
||||
},
|
||||
};
|
||||
|
||||
const targets = resolveAllAgentSessionStoreTargetsSync(cfg, { env: process.env });
|
||||
expect(targets).not.toContainEqual({
|
||||
agentId: "ops",
|
||||
storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
344
src/config/sessions/targets.ts
Normal file
344
src/config/sessions/targets.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
resolveAgentSessionDirsFromAgentsDir,
|
||||
resolveAgentSessionDirsFromAgentsDirSync,
|
||||
} from "../../agents/session-dirs.js";
|
||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { resolveStateDir } from "../paths.js";
|
||||
import type { OpenClawConfig } from "../types.openclaw.js";
|
||||
import { resolveAgentsDirFromSessionStorePath, resolveStorePath } from "./paths.js";
|
||||
|
||||
export type SessionStoreSelectionOptions = {
|
||||
store?: string;
|
||||
agent?: string;
|
||||
allAgents?: boolean;
|
||||
};
|
||||
|
||||
export type SessionStoreTarget = {
|
||||
agentId: string;
|
||||
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) {
|
||||
if (!deduped.has(target.storePath)) {
|
||||
deduped.set(target.storePath, target);
|
||||
}
|
||||
}
|
||||
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 isWithinRoot(realPath: string, realRoot: string): boolean {
|
||||
return realPath === realRoot || realPath.startsWith(`${realRoot}${path.sep}`);
|
||||
}
|
||||
|
||||
function shouldSkipDiscoveredAgentDirName(dirName: string, agentId: string): boolean {
|
||||
// Avoid collapsing arbitrary directory names like "###" into the default main agent.
|
||||
// Human-friendly names like "Retired Agent" are still allowed because they normalize to
|
||||
// a non-default stable id and preserve the intended retired-store discovery behavior.
|
||||
return agentId === DEFAULT_AGENT_ID && dirName.trim().toLowerCase() !== DEFAULT_AGENT_ID;
|
||||
}
|
||||
|
||||
function resolveValidatedDiscoveredStorePathSync(params: {
|
||||
sessionsDir: string;
|
||||
agentsRoot: string;
|
||||
realAgentsRoot?: string;
|
||||
}): string | undefined {
|
||||
const storePath = path.join(params.sessionsDir, "sessions.json");
|
||||
try {
|
||||
const stat = fsSync.lstatSync(storePath);
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
return undefined;
|
||||
}
|
||||
const realStorePath = fsSync.realpathSync(storePath);
|
||||
const realAgentsRoot = params.realAgentsRoot ?? fsSync.realpathSync(params.agentsRoot);
|
||||
return isWithinRoot(realStorePath, realAgentsRoot) ? realStorePath : undefined;
|
||||
} catch (err) {
|
||||
if (shouldSkipDiscoveryError(err)) {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveValidatedDiscoveredStorePath(params: {
|
||||
sessionsDir: string;
|
||||
agentsRoot: string;
|
||||
realAgentsRoot?: string;
|
||||
}): Promise<string | undefined> {
|
||||
const storePath = path.join(params.sessionsDir, "sessions.json");
|
||||
try {
|
||||
const stat = await fs.lstat(storePath);
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
return undefined;
|
||||
}
|
||||
const realStorePath = await fs.realpath(storePath);
|
||||
const realAgentsRoot = params.realAgentsRoot ?? (await fs.realpath(params.agentsRoot));
|
||||
return isWithinRoot(realStorePath, realAgentsRoot) ? realStorePath : undefined;
|
||||
} catch (err) {
|
||||
if (shouldSkipDiscoveryError(err)) {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSessionStoreDiscoveryState(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): {
|
||||
configuredTargets: SessionStoreTarget[];
|
||||
agentsRoots: string[];
|
||||
} {
|
||||
const configuredTargets = resolveSessionStoreTargets(cfg, { allAgents: true }, { env });
|
||||
const agentsRoots = new Set<string>();
|
||||
for (const target of configuredTargets) {
|
||||
const agentsDir = resolveAgentsDirFromSessionStorePath(target.storePath);
|
||||
if (agentsDir) {
|
||||
agentsRoots.add(agentsDir);
|
||||
}
|
||||
}
|
||||
agentsRoots.add(path.join(resolveStateDir(env), "agents"));
|
||||
return {
|
||||
configuredTargets,
|
||||
agentsRoots: [...agentsRoots],
|
||||
};
|
||||
}
|
||||
|
||||
function toDiscoveredSessionStoreTarget(
|
||||
sessionsDir: string,
|
||||
storePath: string,
|
||||
): SessionStoreTarget | undefined {
|
||||
const dirName = path.basename(path.dirname(sessionsDir));
|
||||
const agentId = normalizeAgentId(dirName);
|
||||
if (shouldSkipDiscoveredAgentDirName(dirName, agentId)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
agentId,
|
||||
// Keep the actual on-disk store path so retired/manual agent dirs remain discoverable
|
||||
// even if their directory name no longer round-trips through normalizeAgentId().
|
||||
storePath,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAllAgentSessionStoreTargetsSync(
|
||||
cfg: OpenClawConfig,
|
||||
params: { env?: NodeJS.ProcessEnv } = {},
|
||||
): SessionStoreTarget[] {
|
||||
const env = params.env ?? process.env;
|
||||
const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env);
|
||||
const realAgentsRoots = new Map<string, string>();
|
||||
const getRealAgentsRoot = (agentsRoot: string): string | undefined => {
|
||||
const cached = realAgentsRoots.get(agentsRoot);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const realAgentsRoot = fsSync.realpathSync(agentsRoot);
|
||||
realAgentsRoots.set(agentsRoot, realAgentsRoot);
|
||||
return realAgentsRoot;
|
||||
} catch (err) {
|
||||
if (shouldSkipDiscoveryError(err)) {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
const validatedConfiguredTargets = configuredTargets.flatMap((target) => {
|
||||
const agentsRoot = resolveAgentsDirFromSessionStorePath(target.storePath);
|
||||
if (!agentsRoot) {
|
||||
return [target];
|
||||
}
|
||||
const realAgentsRoot = getRealAgentsRoot(agentsRoot);
|
||||
if (!realAgentsRoot) {
|
||||
return [];
|
||||
}
|
||||
const validatedStorePath = resolveValidatedDiscoveredStorePathSync({
|
||||
sessionsDir: path.dirname(target.storePath),
|
||||
agentsRoot,
|
||||
realAgentsRoot,
|
||||
});
|
||||
return validatedStorePath ? [{ ...target, storePath: validatedStorePath }] : [];
|
||||
});
|
||||
const discoveredTargets = agentsRoots.flatMap((agentsDir) => {
|
||||
try {
|
||||
const realAgentsRoot = getRealAgentsRoot(agentsDir);
|
||||
if (!realAgentsRoot) {
|
||||
return [];
|
||||
}
|
||||
return resolveAgentSessionDirsFromAgentsDirSync(agentsDir).flatMap((sessionsDir) => {
|
||||
const validatedStorePath = resolveValidatedDiscoveredStorePathSync({
|
||||
sessionsDir,
|
||||
agentsRoot: agentsDir,
|
||||
realAgentsRoot,
|
||||
});
|
||||
const target = validatedStorePath
|
||||
? toDiscoveredSessionStoreTarget(sessionsDir, validatedStorePath)
|
||||
: undefined;
|
||||
return target ? [target] : [];
|
||||
});
|
||||
} catch (err) {
|
||||
if (shouldSkipDiscoveryError(err)) {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
return dedupeTargetsByStorePath([...validatedConfiguredTargets, ...discoveredTargets]);
|
||||
}
|
||||
|
||||
export async function resolveAllAgentSessionStoreTargets(
|
||||
cfg: OpenClawConfig,
|
||||
params: { env?: NodeJS.ProcessEnv } = {},
|
||||
): Promise<SessionStoreTarget[]> {
|
||||
const env = params.env ?? process.env;
|
||||
const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env);
|
||||
const realAgentsRoots = new Map<string, string>();
|
||||
const getRealAgentsRoot = async (agentsRoot: string): Promise<string | undefined> => {
|
||||
const cached = realAgentsRoots.get(agentsRoot);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const realAgentsRoot = await fs.realpath(agentsRoot);
|
||||
realAgentsRoots.set(agentsRoot, realAgentsRoot);
|
||||
return realAgentsRoot;
|
||||
} catch (err) {
|
||||
if (shouldSkipDiscoveryError(err)) {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
const validatedConfiguredTargets = (
|
||||
await Promise.all(
|
||||
configuredTargets.map(async (target) => {
|
||||
const agentsRoot = resolveAgentsDirFromSessionStorePath(target.storePath);
|
||||
if (!agentsRoot) {
|
||||
return target;
|
||||
}
|
||||
const realAgentsRoot = await getRealAgentsRoot(agentsRoot);
|
||||
if (!realAgentsRoot) {
|
||||
return undefined;
|
||||
}
|
||||
const validatedStorePath = await resolveValidatedDiscoveredStorePath({
|
||||
sessionsDir: path.dirname(target.storePath),
|
||||
agentsRoot,
|
||||
realAgentsRoot,
|
||||
});
|
||||
return validatedStorePath ? { ...target, storePath: validatedStorePath } : undefined;
|
||||
}),
|
||||
)
|
||||
).filter((target): target is SessionStoreTarget => Boolean(target));
|
||||
|
||||
const discoveredTargets = (
|
||||
await Promise.all(
|
||||
agentsRoots.map(async (agentsDir) => {
|
||||
try {
|
||||
const realAgentsRoot = await getRealAgentsRoot(agentsDir);
|
||||
if (!realAgentsRoot) {
|
||||
return [];
|
||||
}
|
||||
const sessionsDirs = await resolveAgentSessionDirsFromAgentsDir(agentsDir);
|
||||
return (
|
||||
await Promise.all(
|
||||
sessionsDirs.map(async (sessionsDir) => {
|
||||
const validatedStorePath = await resolveValidatedDiscoveredStorePath({
|
||||
sessionsDir,
|
||||
agentsRoot: agentsDir,
|
||||
realAgentsRoot,
|
||||
});
|
||||
return validatedStorePath
|
||||
? toDiscoveredSessionStoreTarget(sessionsDir, validatedStorePath)
|
||||
: undefined;
|
||||
}),
|
||||
)
|
||||
).filter((target): target is SessionStoreTarget => Boolean(target));
|
||||
} catch (err) {
|
||||
if (shouldSkipDiscoveryError(err)) {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
|
||||
return dedupeTargetsByStorePath([...validatedConfiguredTargets, ...discoveredTargets]);
|
||||
}
|
||||
|
||||
export function resolveSessionStoreTargets(
|
||||
cfg: OpenClawConfig,
|
||||
opts: SessionStoreSelectionOptions,
|
||||
params: { env?: NodeJS.ProcessEnv } = {},
|
||||
): SessionStoreTarget[] {
|
||||
const env = params.env ?? process.env;
|
||||
const defaultAgentId = resolveDefaultAgentId(cfg);
|
||||
const hasAgent = Boolean(opts.agent?.trim());
|
||||
const allAgents = opts.allAgents === true;
|
||||
if (hasAgent && allAgents) {
|
||||
throw new Error("--agent and --all-agents cannot be used together");
|
||||
}
|
||||
if (opts.store && (hasAgent || allAgents)) {
|
||||
throw new Error("--store cannot be combined with --agent or --all-agents");
|
||||
}
|
||||
|
||||
if (opts.store) {
|
||||
return [
|
||||
{
|
||||
agentId: defaultAgentId,
|
||||
storePath: resolveStorePath(opts.store, { agentId: defaultAgentId, env }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (allAgents) {
|
||||
const targets = listAgentIds(cfg).map((agentId) => ({
|
||||
agentId,
|
||||
storePath: resolveStorePath(cfg.session?.store, { agentId, env }),
|
||||
}));
|
||||
return dedupeTargetsByStorePath(targets);
|
||||
}
|
||||
|
||||
if (hasAgent) {
|
||||
const knownAgents = listAgentIds(cfg);
|
||||
const requested = normalizeAgentId(opts.agent ?? "");
|
||||
if (!knownAgents.includes(requested)) {
|
||||
throw new Error(
|
||||
`Unknown agent id "${opts.agent}". Use "openclaw agents list" to see configured agents.`,
|
||||
);
|
||||
}
|
||||
return [
|
||||
{
|
||||
agentId: requested,
|
||||
storePath: resolveStorePath(cfg.session?.store, { agentId: requested, env }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
agentId: defaultAgentId,
|
||||
storePath: resolveStorePath(cfg.session?.store, { agentId: defaultAgentId, env }),
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user