294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
import fsSync from "node:fs";
|
|
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> {
|
|
// Match the native realpath behavior used by both discovery paths.
|
|
return fsSync.realpathSync.native(path.join(sessionsDir, "sessions.json"));
|
|
}
|
|
|
|
async function createAgentSessionStores(
|
|
root: string,
|
|
agentIds: string[],
|
|
): Promise<Record<string, string>> {
|
|
const storePaths: Record<string, string> = {};
|
|
for (const agentId of agentIds) {
|
|
const sessionsDir = path.join(root, "agents", agentId, "sessions");
|
|
await fs.mkdir(sessionsDir, { recursive: true });
|
|
await fs.writeFile(path.join(sessionsDir, "sessions.json"), "{}", "utf8");
|
|
storePaths[agentId] = await resolveRealStorePath(sessionsDir);
|
|
}
|
|
return storePaths;
|
|
}
|
|
|
|
function createCustomRootCfg(customRoot: string, defaultAgentId = "ops"): OpenClawConfig {
|
|
return {
|
|
session: {
|
|
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
|
},
|
|
agents: {
|
|
list: [{ id: defaultAgentId, default: true }],
|
|
},
|
|
};
|
|
}
|
|
|
|
async function resolveTargetsForCustomRoot(home: string, agentIds: string[]) {
|
|
const customRoot = path.join(home, "custom-state");
|
|
const storePaths = await createAgentSessionStores(customRoot, agentIds);
|
|
const cfg = createCustomRootCfg(customRoot);
|
|
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
|
return { storePaths, targets };
|
|
}
|
|
|
|
function expectTargetsToContainStores(
|
|
targets: Array<{ agentId: string; storePath: string }>,
|
|
stores: Record<string, string>,
|
|
): void {
|
|
expect(targets).toEqual(
|
|
expect.arrayContaining(
|
|
Object.entries(stores).map(([agentId, storePath]) => ({
|
|
agentId,
|
|
storePath,
|
|
})),
|
|
),
|
|
);
|
|
}
|
|
|
|
const discoveryResolvers = [
|
|
{
|
|
label: "async",
|
|
resolve: async (cfg: OpenClawConfig, env: NodeJS.ProcessEnv) =>
|
|
await resolveAllAgentSessionStoreTargets(cfg, { env }),
|
|
},
|
|
{
|
|
label: "sync",
|
|
resolve: async (cfg: OpenClawConfig, env: NodeJS.ProcessEnv) =>
|
|
resolveAllAgentSessionStoreTargetsSync(cfg, { env }),
|
|
},
|
|
] as const;
|
|
|
|
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 storePaths = await createAgentSessionStores(stateDir, ["ops", "retired"]);
|
|
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
list: [{ id: "ops", default: true }],
|
|
},
|
|
};
|
|
|
|
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
|
|
|
expectTargetsToContainStores(targets, storePaths);
|
|
expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
it("discovers retired agent stores under a configured custom session root", async () => {
|
|
await withTempHome(async (home) => {
|
|
const { storePaths, targets } = await resolveTargetsForCustomRoot(home, ["ops", "retired"]);
|
|
|
|
expectTargetsToContainStores(targets, storePaths);
|
|
expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
it("keeps the actual on-disk store path for discovered retired agents", async () => {
|
|
await withTempHome(async (home) => {
|
|
const { storePaths, targets } = await resolveTargetsForCustomRoot(home, [
|
|
"ops",
|
|
"Retired Agent",
|
|
]);
|
|
|
|
expect(targets).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
agentId: "retired-agent",
|
|
storePath: storePaths["Retired Agent"],
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
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,
|
|
},
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
for (const resolver of discoveryResolvers) {
|
|
it(`skips unreadable or invalid discovery roots when other roots are still readable (${resolver.label})`, 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 storePaths = await createAgentSessionStores(envStateDir, ["main", "retired"]);
|
|
const cfg = createCustomRootCfg(customRoot, "main");
|
|
const env = {
|
|
...process.env,
|
|
OPENCLAW_STATE_DIR: envStateDir,
|
|
};
|
|
|
|
await expect(resolver.resolve(cfg, env)).resolves.toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
agentId: "retired",
|
|
storePath: storePaths.retired,
|
|
},
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
it(`skips symlinked discovered stores under templated agents roots (${resolver.label})`, 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 targets = await resolver.resolve(createCustomRootCfg(customRoot), 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);
|
|
});
|
|
});
|
|
});
|