mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 04:41:03 +00:00
Gateway: harden custom session-store discovery
This commit is contained in:
@@ -421,6 +421,8 @@ Some controls depend on backend capabilities. If a backend does not support a co
|
||||
| `/acp doctor` | Backend health, capabilities, actionable fixes. | `/acp doctor` |
|
||||
| `/acp install` | Print deterministic install and enable steps. | `/acp install` |
|
||||
|
||||
`/acp sessions` reads the store for the current bound or requester session. Commands that accept `session-key`, `session-id`, or `session-label` tokens resolve targets through gateway session discovery, including custom per-agent `session.store` roots.
|
||||
|
||||
## Runtime options mapping
|
||||
|
||||
`/acp` has convenience commands and a generic setter.
|
||||
|
||||
69
src/acp/runtime/session-meta.test.ts
Normal file
69
src/acp/runtime/session-meta.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const resolveAllAgentSessionStoreTargetsMock = vi.fn();
|
||||
const loadSessionStoreMock = vi.fn();
|
||||
return {
|
||||
resolveAllAgentSessionStoreTargetsMock,
|
||||
loadSessionStoreMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../config/sessions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
|
||||
"../../config/sessions.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveAllAgentSessionStoreTargets: (cfg: OpenClawConfig, opts: unknown) =>
|
||||
hoisted.resolveAllAgentSessionStoreTargetsMock(cfg, opts),
|
||||
loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath),
|
||||
};
|
||||
});
|
||||
|
||||
const { listAcpSessionEntries } = await import("./session-meta.js");
|
||||
|
||||
describe("listAcpSessionEntries", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("reads ACP sessions from resolved configured store targets", async () => {
|
||||
const cfg = {
|
||||
session: {
|
||||
store: "/custom/sessions/{agentId}.json",
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
hoisted.resolveAllAgentSessionStoreTargetsMock.mockResolvedValue([
|
||||
{
|
||||
agentId: "ops",
|
||||
storePath: "/custom/sessions/ops.json",
|
||||
},
|
||||
]);
|
||||
hoisted.loadSessionStoreMock.mockReturnValue({
|
||||
"agent:ops:acp:s1": {
|
||||
updatedAt: 123,
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
agent: "ops",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const entries = await listAcpSessionEntries({ cfg });
|
||||
|
||||
expect(hoisted.resolveAllAgentSessionStoreTargetsMock).toHaveBeenCalledWith(cfg, undefined);
|
||||
expect(hoisted.loadSessionStoreMock).toHaveBeenCalledWith("/custom/sessions/ops.json");
|
||||
expect(entries).toEqual([
|
||||
expect.objectContaining({
|
||||
cfg,
|
||||
storePath: "/custom/sessions/ops.json",
|
||||
sessionKey: "agent:ops:acp:s1",
|
||||
storeSessionKey: "agent:ops:acp:s1",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import path from "node:path";
|
||||
import { resolveAgentSessionDirs } from "../../agents/session-dirs.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveAllAgentSessionStoreTargets,
|
||||
resolveStorePath,
|
||||
updateSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import {
|
||||
mergeSessionEntry,
|
||||
type SessionAcpMeta,
|
||||
@@ -88,14 +90,17 @@ export function readAcpSessionEntry(params: {
|
||||
|
||||
export async function listAcpSessionEntries(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<AcpSessionStoreEntry[]> {
|
||||
const cfg = params.cfg ?? loadConfig();
|
||||
const stateDir = resolveStateDir(process.env);
|
||||
const sessionDirs = await resolveAgentSessionDirs(stateDir);
|
||||
const storeTargets = await resolveAllAgentSessionStoreTargets(
|
||||
cfg,
|
||||
params.env ? { env: params.env } : undefined,
|
||||
);
|
||||
const entries: AcpSessionStoreEntry[] = [];
|
||||
|
||||
for (const sessionsDir of sessionDirs) {
|
||||
const storePath = path.join(sessionsDir, "sessions.json");
|
||||
for (const target of storeTargets) {
|
||||
const storePath = target.storePath;
|
||||
let store: Record<string, SessionEntry>;
|
||||
try {
|
||||
store = loadSessionStore(storePath);
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import fsSync, { type Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export async function resolveAgentSessionDirs(stateDir: string): Promise<string[]> {
|
||||
const agentsDir = path.join(stateDir, "agents");
|
||||
function mapAgentSessionDirs(agentsDir: string, entries: Dirent[]): string[] {
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(agentsDir, entry.name, "sessions"))
|
||||
.toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export async function resolveAgentSessionDirsFromAgentsDir(agentsDir: string): Promise<string[]> {
|
||||
let entries: Dirent[] = [];
|
||||
try {
|
||||
entries = await fs.readdir(agentsDir, { withFileTypes: true });
|
||||
@@ -15,8 +21,24 @@ export async function resolveAgentSessionDirs(stateDir: string): Promise<string[
|
||||
throw err;
|
||||
}
|
||||
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(agentsDir, entry.name, "sessions"))
|
||||
.toSorted((a, b) => a.localeCompare(b));
|
||||
return mapAgentSessionDirs(agentsDir, entries);
|
||||
}
|
||||
|
||||
export function resolveAgentSessionDirsFromAgentsDirSync(agentsDir: string): string[] {
|
||||
let entries: Dirent[] = [];
|
||||
try {
|
||||
entries = fsSync.readdirSync(agentsDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
if (code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return mapAgentSessionDirs(agentsDir, entries);
|
||||
}
|
||||
|
||||
export async function resolveAgentSessionDirs(stateDir: string): Promise<string[]> {
|
||||
return await resolveAgentSessionDirsFromAgentsDir(path.join(stateDir, "agents"));
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveSessionStoreTargets } from "./session-store-targets.js";
|
||||
|
||||
const resolveStorePathMock = vi.hoisted(() => vi.fn());
|
||||
const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn());
|
||||
const listAgentIdsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveSessionStoreTargetsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
resolveStorePath: resolveStorePathMock,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
resolveDefaultAgentId: resolveDefaultAgentIdMock,
|
||||
listAgentIds: listAgentIdsMock,
|
||||
resolveSessionStoreTargets: resolveSessionStoreTargetsMock,
|
||||
}));
|
||||
|
||||
describe("resolveSessionStoreTargets", () => {
|
||||
@@ -19,61 +12,14 @@ describe("resolveSessionStoreTargets", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("resolves the default agent store when no selector is provided", () => {
|
||||
resolveDefaultAgentIdMock.mockReturnValue("main");
|
||||
resolveStorePathMock.mockReturnValue("/tmp/main-sessions.json");
|
||||
it("delegates session store target resolution to the shared config helper", () => {
|
||||
resolveSessionStoreTargetsMock.mockReturnValue([
|
||||
{ agentId: "main", storePath: "/tmp/main-sessions.json" },
|
||||
]);
|
||||
|
||||
const targets = resolveSessionStoreTargets({}, {});
|
||||
|
||||
expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/main-sessions.json" }]);
|
||||
expect(resolveStorePathMock).toHaveBeenCalledWith(undefined, { agentId: "main" });
|
||||
});
|
||||
|
||||
it("resolves all configured agent stores", () => {
|
||||
listAgentIdsMock.mockReturnValue(["main", "work"]);
|
||||
resolveStorePathMock
|
||||
.mockReturnValueOnce("/tmp/main-sessions.json")
|
||||
.mockReturnValueOnce("/tmp/work-sessions.json");
|
||||
|
||||
const targets = resolveSessionStoreTargets(
|
||||
{
|
||||
session: { store: "~/.openclaw/agents/{agentId}/sessions/sessions.json" },
|
||||
},
|
||||
{ allAgents: true },
|
||||
);
|
||||
|
||||
expect(targets).toEqual([
|
||||
{ agentId: "main", storePath: "/tmp/main-sessions.json" },
|
||||
{ agentId: "work", storePath: "/tmp/work-sessions.json" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes shared store paths for --all-agents", () => {
|
||||
listAgentIdsMock.mockReturnValue(["main", "work"]);
|
||||
resolveStorePathMock.mockReturnValue("/tmp/shared-sessions.json");
|
||||
|
||||
const targets = resolveSessionStoreTargets(
|
||||
{
|
||||
session: { store: "/tmp/shared-sessions.json" },
|
||||
},
|
||||
{ allAgents: true },
|
||||
);
|
||||
|
||||
expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/shared-sessions.json" }]);
|
||||
expect(resolveStorePathMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects unknown agent ids", () => {
|
||||
listAgentIdsMock.mockReturnValue(["main", "work"]);
|
||||
expect(() => resolveSessionStoreTargets({}, { 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);
|
||||
expect(resolveSessionStoreTargetsMock).toHaveBeenCalledWith({}, {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,84 +1,11 @@
|
||||
import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { resolveStorePath } from "../config/sessions.js";
|
||||
import {
|
||||
resolveSessionStoreTargets,
|
||||
type SessionStoreSelectionOptions,
|
||||
type SessionStoreTarget,
|
||||
} from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
export type SessionStoreSelectionOptions = {
|
||||
store?: string;
|
||||
agent?: string;
|
||||
allAgents?: boolean;
|
||||
};
|
||||
|
||||
export type SessionStoreTarget = {
|
||||
agentId: string;
|
||||
storePath: string;
|
||||
};
|
||||
|
||||
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()];
|
||||
}
|
||||
|
||||
export function resolveSessionStoreTargets(
|
||||
cfg: OpenClawConfig,
|
||||
opts: SessionStoreSelectionOptions,
|
||||
): SessionStoreTarget[] {
|
||||
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 }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (allAgents) {
|
||||
const targets = listAgentIds(cfg).map((agentId) => ({
|
||||
agentId,
|
||||
storePath: resolveStorePath(cfg.session?.store, { agentId }),
|
||||
}));
|
||||
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 }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
agentId: defaultAgentId,
|
||||
storePath: resolveStorePath(cfg.session?.store, { agentId: defaultAgentId }),
|
||||
},
|
||||
];
|
||||
}
|
||||
export { resolveSessionStoreTargets, type SessionStoreSelectionOptions, type SessionStoreTarget };
|
||||
|
||||
export function resolveSessionStoreTargetsOrExit(params: {
|
||||
cfg: OpenClawConfig;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
148
src/config/sessions/targets.test.ts
Normal file
148
src/config/sessions/targets.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
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 } from "./targets.js";
|
||||
|
||||
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 targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
||||
|
||||
expect(targets).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
agentId: "ops",
|
||||
storePath: path.join(opsSessionsDir, "sessions.json"),
|
||||
},
|
||||
{
|
||||
agentId: "retired",
|
||||
storePath: path.join(retiredSessionsDir, "sessions.json"),
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
targets.filter((target) => target.storePath === path.join(opsSessionsDir, "sessions.json")),
|
||||
).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 targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
||||
|
||||
expect(targets).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
agentId: "ops",
|
||||
storePath: path.join(opsSessionsDir, "sessions.json"),
|
||||
},
|
||||
{
|
||||
agentId: "retired",
|
||||
storePath: path.join(retiredSessionsDir, "sessions.json"),
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
targets.filter((target) => target.storePath === path.join(opsSessionsDir, "sessions.json")),
|
||||
).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 targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
||||
|
||||
expect(targets).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
agentId: "retired-agent",
|
||||
storePath: path.join(retiredSessionsDir, "sessions.json"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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 targets = await resolveAllAgentSessionStoreTargets(cfg, { env });
|
||||
|
||||
expect(targets).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
agentId: "main",
|
||||
storePath: path.join(mainSessionsDir, "sessions.json"),
|
||||
},
|
||||
{
|
||||
agentId: "retired",
|
||||
storePath: path.join(retiredSessionsDir, "sessions.json"),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
148
src/config/sessions/targets.ts
Normal file
148
src/config/sessions/targets.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import path from "node:path";
|
||||
import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
resolveAgentSessionDirsFromAgentsDir,
|
||||
resolveAgentSessionDirsFromAgentsDirSync,
|
||||
} from "../../agents/session-dirs.js";
|
||||
import { 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;
|
||||
};
|
||||
|
||||
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 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 toDiscoveredSessionStoreTargets(sessionsDirs: string[]): SessionStoreTarget[] {
|
||||
return sessionsDirs.map((sessionsDir) => {
|
||||
const agentId = normalizeAgentId(path.basename(path.dirname(sessionsDir)));
|
||||
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: path.join(sessionsDir, "sessions.json"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAllAgentSessionStoreTargetsSync(
|
||||
cfg: OpenClawConfig,
|
||||
params: { env?: NodeJS.ProcessEnv } = {},
|
||||
): SessionStoreTarget[] {
|
||||
const env = params.env ?? process.env;
|
||||
const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env);
|
||||
const discoveredTargets = toDiscoveredSessionStoreTargets(
|
||||
agentsRoots.flatMap((agentsDir) => resolveAgentSessionDirsFromAgentsDirSync(agentsDir)),
|
||||
);
|
||||
return dedupeTargetsByStorePath([...configuredTargets, ...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 discoveredDirs = await Promise.all(
|
||||
agentsRoots.map((agentsDir) => resolveAgentSessionDirsFromAgentsDir(agentsDir)),
|
||||
);
|
||||
const discoveredTargets = toDiscoveredSessionStoreTargets(discoveredDirs.flat());
|
||||
return dedupeTargetsByStorePath([...configuredTargets, ...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 }),
|
||||
},
|
||||
];
|
||||
}
|
||||
54
src/gateway/server-session-key.test.ts
Normal file
54
src/gateway/server-session-key.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resetAgentRunContextForTest } from "../infra/agent-events.js";
|
||||
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
|
||||
|
||||
const loadConfigMock = vi.hoisted(() => vi.fn<() => OpenClawConfig>());
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: () => loadConfigMock(),
|
||||
}));
|
||||
|
||||
const { resolveSessionKeyForRun } = await import("./server-session-key.js");
|
||||
|
||||
describe("resolveSessionKeyForRun", () => {
|
||||
beforeEach(() => {
|
||||
loadConfigMock.mockReset();
|
||||
resetAgentRunContextForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetAgentRunContextForTest();
|
||||
});
|
||||
|
||||
it("finds run ids in disk-only agent stores under a custom session root", async () => {
|
||||
await withStateDirEnv("openclaw-run-key-", async ({ stateDir }) => {
|
||||
const customRoot = path.join(stateDir, "custom-state");
|
||||
const retiredSessionsDir = path.join(customRoot, "agents", "retired", "sessions");
|
||||
fs.mkdirSync(retiredSessionsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(retiredSessionsDir, "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:retired:acp:run-1": { sessionId: "run-1", updatedAt: 123 },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
loadConfigMock.mockReturnValue({
|
||||
session: {
|
||||
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveSessionKeyForRun("run-1")).toBe("acp:run-1");
|
||||
|
||||
fs.rmSync(customRoot, { recursive: true, force: true });
|
||||
expect(resolveSessionKeyForRun("run-1")).toBe("acp:run-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||
import { getAgentRunContext, registerAgentRunContext } from "../infra/agent-events.js";
|
||||
import { toAgentRequestSessionKey } from "../routing/session-key.js";
|
||||
import { loadCombinedSessionStoreForGateway } from "./session-utils.js";
|
||||
|
||||
export function resolveSessionKeyForRun(runId: string) {
|
||||
const cached = getAgentRunContext(runId)?.sessionKey;
|
||||
@@ -9,8 +9,7 @@ export function resolveSessionKeyForRun(runId: string) {
|
||||
return cached;
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
const storePath = resolveStorePath(cfg.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const { store } = loadCombinedSessionStoreForGateway(cfg);
|
||||
const found = Object.entries(store).find(([, entry]) => entry?.sessionId === runId);
|
||||
const storeKey = found?.[0];
|
||||
if (storeKey) {
|
||||
|
||||
@@ -767,7 +767,8 @@ describe("listSessionsFromStore search", () => {
|
||||
describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)", () => {
|
||||
test("ACP agent sessions are visible even when agents.list is configured", async () => {
|
||||
await withStateDirEnv("openclaw-acp-vis-", async ({ stateDir }) => {
|
||||
const agentsDir = path.join(stateDir, "agents");
|
||||
const customRoot = path.join(stateDir, "custom-state");
|
||||
const agentsDir = path.join(customRoot, "agents");
|
||||
const mainDir = path.join(agentsDir, "main", "sessions");
|
||||
const codexDir = path.join(agentsDir, "codex", "sessions");
|
||||
fs.mkdirSync(mainDir, { recursive: true });
|
||||
@@ -792,7 +793,7 @@ describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)"
|
||||
const cfg = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
buildGroupDisplayName,
|
||||
canonicalizeMainSessionAlias,
|
||||
loadSessionStore,
|
||||
resolveAllAgentSessionStoreTargetsSync,
|
||||
resolveAgentMainSessionKey,
|
||||
resolveFreshSessionTotalTokens,
|
||||
resolveMainSessionKey,
|
||||
@@ -585,10 +586,11 @@ export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): {
|
||||
return { storePath, store: combined };
|
||||
}
|
||||
|
||||
const agentIds = listConfiguredAgentIds(cfg);
|
||||
const targets = resolveAllAgentSessionStoreTargetsSync(cfg);
|
||||
const combined: Record<string, SessionEntry> = {};
|
||||
for (const agentId of agentIds) {
|
||||
const storePath = resolveStorePath(storeConfig, { agentId });
|
||||
for (const target of targets) {
|
||||
const agentId = target.agentId;
|
||||
const storePath = target.storePath;
|
||||
const store = loadSessionStore(storePath);
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);
|
||||
|
||||
Reference in New Issue
Block a user