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
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb.
|
- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb.
|
||||||
- Mattermost/reply media delivery: pass agent-scoped `mediaLocalRoots` through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.
|
- Mattermost/reply media delivery: pass agent-scoped `mediaLocalRoots` through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.
|
||||||
- Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process `HOME`/`OPENCLAW_HOME` changes no longer reuse stale plugin state or misreport `~/...` plugins as untracked. (#44046) thanks @gumadeiras.
|
- Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process `HOME`/`OPENCLAW_HOME` changes no longer reuse stale plugin state or misreport `~/...` plugins as untracked. (#44046) thanks @gumadeiras.
|
||||||
|
- Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated `session.store` roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.
|
||||||
|
|
||||||
## 2026.3.11
|
## 2026.3.11
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ Session stores live under the state directory (default `~/.openclaw`):
|
|||||||
|
|
||||||
You can override the store path via `session.store` and `{agentId}` templating.
|
You can override the store path via `session.store` and `{agentId}` templating.
|
||||||
|
|
||||||
|
Gateway and ACP session discovery also scans disk-backed agent stores under the
|
||||||
|
default `agents/` root and under templated `session.store` roots. Discovered
|
||||||
|
stores must stay inside that resolved agent root and use a regular
|
||||||
|
`sessions.json` file. Symlinks and out-of-root paths are ignored.
|
||||||
|
|
||||||
## WebChat behavior
|
## WebChat behavior
|
||||||
|
|
||||||
WebChat attaches to the **selected agent** and defaults to the agent’s main
|
WebChat attaches to the **selected agent** and defaults to the agent’s main
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ Scope selection:
|
|||||||
- `--all-agents`: aggregate all configured agent stores
|
- `--all-agents`: aggregate all configured agent stores
|
||||||
- `--store <path>`: explicit store path (cannot be combined with `--agent` or `--all-agents`)
|
- `--store <path>`: explicit store path (cannot be combined with `--agent` or `--all-agents`)
|
||||||
|
|
||||||
|
`openclaw sessions --all-agents` reads configured agent stores. Gateway and ACP
|
||||||
|
session discovery are broader: they also include disk-only stores found under
|
||||||
|
the default `agents/` root or a templated `session.store` root. Those
|
||||||
|
discovered stores must resolve to regular `sessions.json` files inside the
|
||||||
|
agent root; symlinks and out-of-root paths are skipped.
|
||||||
|
|
||||||
JSON examples:
|
JSON examples:
|
||||||
|
|
||||||
`openclaw sessions --all-agents --json`:
|
`openclaw sessions --all-agents --json`:
|
||||||
|
|||||||
@@ -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 doctor` | Backend health, capabilities, actionable fixes. | `/acp doctor` |
|
||||||
| `/acp install` | Print deterministic install and enable steps. | `/acp install` |
|
| `/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
|
## Runtime options mapping
|
||||||
|
|
||||||
`/acp` has convenience commands and a generic setter.
|
`/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 type { OpenClawConfig } from "../../config/config.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { resolveStateDir } from "../../config/paths.js";
|
import {
|
||||||
import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js";
|
loadSessionStore,
|
||||||
|
resolveAllAgentSessionStoreTargets,
|
||||||
|
resolveStorePath,
|
||||||
|
updateSessionStore,
|
||||||
|
} from "../../config/sessions.js";
|
||||||
import {
|
import {
|
||||||
mergeSessionEntry,
|
mergeSessionEntry,
|
||||||
type SessionAcpMeta,
|
type SessionAcpMeta,
|
||||||
@@ -88,14 +90,17 @@ export function readAcpSessionEntry(params: {
|
|||||||
|
|
||||||
export async function listAcpSessionEntries(params: {
|
export async function listAcpSessionEntries(params: {
|
||||||
cfg?: OpenClawConfig;
|
cfg?: OpenClawConfig;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
}): Promise<AcpSessionStoreEntry[]> {
|
}): Promise<AcpSessionStoreEntry[]> {
|
||||||
const cfg = params.cfg ?? loadConfig();
|
const cfg = params.cfg ?? loadConfig();
|
||||||
const stateDir = resolveStateDir(process.env);
|
const storeTargets = await resolveAllAgentSessionStoreTargets(
|
||||||
const sessionDirs = await resolveAgentSessionDirs(stateDir);
|
cfg,
|
||||||
|
params.env ? { env: params.env } : undefined,
|
||||||
|
);
|
||||||
const entries: AcpSessionStoreEntry[] = [];
|
const entries: AcpSessionStoreEntry[] = [];
|
||||||
|
|
||||||
for (const sessionsDir of sessionDirs) {
|
for (const target of storeTargets) {
|
||||||
const storePath = path.join(sessionsDir, "sessions.json");
|
const storePath = target.storePath;
|
||||||
let store: Record<string, SessionEntry>;
|
let store: Record<string, SessionEntry>;
|
||||||
try {
|
try {
|
||||||
store = loadSessionStore(storePath);
|
store = loadSessionStore(storePath);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
const loadSessionStoreMock = vi.fn();
|
const loadSessionStoreMock = vi.fn();
|
||||||
const updateSessionStoreMock = vi.fn();
|
const updateSessionStoreMock = vi.fn();
|
||||||
const callGatewayMock = vi.fn();
|
const callGatewayMock = vi.fn();
|
||||||
|
const loadCombinedSessionStoreForGatewayMock = vi.fn();
|
||||||
|
|
||||||
const createMockConfig = () => ({
|
const createMockConfig = () => ({
|
||||||
session: { mainKey: "main", scope: "per-sender" },
|
session: { mainKey: "main", scope: "per-sender" },
|
||||||
@@ -42,6 +43,15 @@ vi.mock("../gateway/call.js", () => ({
|
|||||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../gateway/session-utils.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../gateway/session-utils.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
loadCombinedSessionStoreForGateway: (cfg: unknown) =>
|
||||||
|
loadCombinedSessionStoreForGatewayMock(cfg),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("../config/config.js", async (importOriginal) => {
|
vi.mock("../config/config.js", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||||
return {
|
return {
|
||||||
@@ -95,7 +105,12 @@ function resetSessionStore(store: Record<string, unknown>) {
|
|||||||
loadSessionStoreMock.mockClear();
|
loadSessionStoreMock.mockClear();
|
||||||
updateSessionStoreMock.mockClear();
|
updateSessionStoreMock.mockClear();
|
||||||
callGatewayMock.mockClear();
|
callGatewayMock.mockClear();
|
||||||
|
loadCombinedSessionStoreForGatewayMock.mockClear();
|
||||||
loadSessionStoreMock.mockReturnValue(store);
|
loadSessionStoreMock.mockReturnValue(store);
|
||||||
|
loadCombinedSessionStoreForGatewayMock.mockReturnValue({
|
||||||
|
storePath: "(multiple)",
|
||||||
|
store,
|
||||||
|
});
|
||||||
callGatewayMock.mockResolvedValue({});
|
callGatewayMock.mockResolvedValue({});
|
||||||
mockConfig = createMockConfig();
|
mockConfig = createMockConfig();
|
||||||
}
|
}
|
||||||
@@ -161,6 +176,30 @@ describe("session_status tool", () => {
|
|||||||
expect(details.sessionKey).toBe("agent:main:main");
|
expect(details.sessionKey).toBe("agent:main:main");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves duplicate sessionId inputs deterministically", async () => {
|
||||||
|
resetSessionStore({
|
||||||
|
"agent:main:main": {
|
||||||
|
sessionId: "current",
|
||||||
|
updatedAt: 10,
|
||||||
|
},
|
||||||
|
"agent:main:other": {
|
||||||
|
sessionId: "run-dup",
|
||||||
|
updatedAt: 999,
|
||||||
|
},
|
||||||
|
"agent:main:acp:run-dup": {
|
||||||
|
sessionId: "run-dup",
|
||||||
|
updatedAt: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = getSessionStatusTool();
|
||||||
|
|
||||||
|
const result = await tool.execute("call-dup", { sessionKey: "run-dup" });
|
||||||
|
const details = result.details as { ok?: boolean; sessionKey?: string };
|
||||||
|
expect(details.ok).toBe(true);
|
||||||
|
expect(details.sessionKey).toBe("agent:main:acp:run-dup");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses non-standard session keys without sessionId resolution", async () => {
|
it("uses non-standard session keys without sessionId resolution", async () => {
|
||||||
resetSessionStore({
|
resetSessionStore({
|
||||||
"temp:slug-generator": {
|
"temp:slug-generator": {
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import type { Dirent } from "node:fs";
|
import fsSync, { type Dirent } from "node:fs";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
export async function resolveAgentSessionDirs(stateDir: string): Promise<string[]> {
|
function mapAgentSessionDirs(agentsDir: string, entries: Dirent[]): string[] {
|
||||||
const agentsDir = path.join(stateDir, "agents");
|
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[] = [];
|
let entries: Dirent[] = [];
|
||||||
try {
|
try {
|
||||||
entries = await fs.readdir(agentsDir, { withFileTypes: true });
|
entries = await fs.readdir(agentsDir, { withFileTypes: true });
|
||||||
@@ -15,8 +21,24 @@ export async function resolveAgentSessionDirs(stateDir: string): Promise<string[
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries
|
return mapAgentSessionDirs(agentsDir, entries);
|
||||||
.filter((entry) => entry.isDirectory())
|
}
|
||||||
.map((entry) => path.join(agentsDir, entry.name, "sessions"))
|
|
||||||
.toSorted((a, b) => a.localeCompare(b));
|
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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
resolveAgentIdFromSessionKey,
|
resolveAgentIdFromSessionKey,
|
||||||
} from "../../routing/session-key.js";
|
} from "../../routing/session-key.js";
|
||||||
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||||
|
import { resolvePreferredSessionKeyForSessionIdMatches } from "../../sessions/session-id-resolution.js";
|
||||||
import { resolveAgentDir } from "../agent-scope.js";
|
import { resolveAgentDir } from "../agent-scope.js";
|
||||||
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
|
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
|
||||||
import { resolveModelAuthLabel } from "../model-auth-label.js";
|
import { resolveModelAuthLabel } from "../model-auth-label.js";
|
||||||
@@ -100,16 +101,12 @@ function resolveSessionKeyFromSessionId(params: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { store } = loadCombinedSessionStoreForGateway(params.cfg);
|
const { store } = loadCombinedSessionStoreForGateway(params.cfg);
|
||||||
const match = Object.entries(store).find(([key, entry]) => {
|
const matches = Object.entries(store).filter(
|
||||||
if (entry?.sessionId !== trimmed) {
|
(entry): entry is [string, SessionEntry] =>
|
||||||
return false;
|
entry[1]?.sessionId === trimmed &&
|
||||||
}
|
(!params.agentId || resolveAgentIdFromSessionKey(entry[0]) === params.agentId),
|
||||||
if (!params.agentId) {
|
);
|
||||||
return true;
|
return resolvePreferredSessionKeyForSessionIdMatches(matches, trimmed) ?? null;
|
||||||
}
|
|
||||||
return resolveAgentIdFromSessionKey(key) === params.agentId;
|
|
||||||
});
|
|
||||||
return match?.[0] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveModelOverride(params: {
|
async function resolveModelOverride(params: {
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { resolveSessionStoreTargets } from "./session-store-targets.js";
|
import { resolveSessionStoreTargets } from "./session-store-targets.js";
|
||||||
|
|
||||||
const resolveStorePathMock = vi.hoisted(() => vi.fn());
|
const resolveSessionStoreTargetsMock = vi.hoisted(() => vi.fn());
|
||||||
const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn());
|
|
||||||
const listAgentIdsMock = vi.hoisted(() => vi.fn());
|
|
||||||
|
|
||||||
vi.mock("../config/sessions.js", () => ({
|
vi.mock("../config/sessions.js", () => ({
|
||||||
resolveStorePath: resolveStorePathMock,
|
resolveSessionStoreTargets: resolveSessionStoreTargetsMock,
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../agents/agent-scope.js", () => ({
|
|
||||||
resolveDefaultAgentId: resolveDefaultAgentIdMock,
|
|
||||||
listAgentIds: listAgentIdsMock,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("resolveSessionStoreTargets", () => {
|
describe("resolveSessionStoreTargets", () => {
|
||||||
@@ -19,61 +12,14 @@ describe("resolveSessionStoreTargets", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves the default agent store when no selector is provided", () => {
|
it("delegates session store target resolution to the shared config helper", () => {
|
||||||
resolveDefaultAgentIdMock.mockReturnValue("main");
|
resolveSessionStoreTargetsMock.mockReturnValue([
|
||||||
resolveStorePathMock.mockReturnValue("/tmp/main-sessions.json");
|
{ agentId: "main", storePath: "/tmp/main-sessions.json" },
|
||||||
|
]);
|
||||||
|
|
||||||
const targets = resolveSessionStoreTargets({}, {});
|
const targets = resolveSessionStoreTargets({}, {});
|
||||||
|
|
||||||
expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/main-sessions.json" }]);
|
expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/main-sessions.json" }]);
|
||||||
expect(resolveStorePathMock).toHaveBeenCalledWith(undefined, { agentId: "main" });
|
expect(resolveSessionStoreTargetsMock).toHaveBeenCalledWith({}, {});
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,84 +1,11 @@
|
|||||||
import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import {
|
||||||
import { resolveStorePath } from "../config/sessions.js";
|
resolveSessionStoreTargets,
|
||||||
|
type SessionStoreSelectionOptions,
|
||||||
|
type SessionStoreTarget,
|
||||||
|
} from "../config/sessions.js";
|
||||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||||
import { normalizeAgentId } from "../routing/session-key.js";
|
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
export { resolveSessionStoreTargets, type SessionStoreSelectionOptions, type SessionStoreTarget };
|
||||||
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 function resolveSessionStoreTargetsOrExit(params: {
|
export function resolveSessionStoreTargetsOrExit(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export * from "./sessions/transcript.js";
|
|||||||
export * from "./sessions/session-file.js";
|
export * from "./sessions/session-file.js";
|
||||||
export * from "./sessions/delivery-info.js";
|
export * from "./sessions/delivery-info.js";
|
||||||
export * from "./sessions/disk-budget.js";
|
export * from "./sessions/disk-budget.js";
|
||||||
|
export * from "./sessions/targets.js";
|
||||||
|
|||||||
@@ -276,19 +276,24 @@ export function resolveSessionFilePath(
|
|||||||
return resolveSessionTranscriptPathInDir(sessionId, sessionsDir);
|
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 agentId = normalizeAgentId(opts?.agentId ?? DEFAULT_AGENT_ID);
|
||||||
|
const env = opts?.env ?? process.env;
|
||||||
|
const homedir = () => resolveRequiredHomeDir(env, os.homedir);
|
||||||
if (!store) {
|
if (!store) {
|
||||||
return resolveDefaultSessionStorePath(agentId);
|
return path.join(resolveAgentSessionsDir(agentId, env, homedir), "sessions.json");
|
||||||
}
|
}
|
||||||
if (store.includes("{agentId}")) {
|
if (store.includes("{agentId}")) {
|
||||||
const expanded = store.replaceAll("{agentId}", agentId);
|
const expanded = store.replaceAll("{agentId}", agentId);
|
||||||
if (expanded.startsWith("~")) {
|
if (expanded.startsWith("~")) {
|
||||||
return path.resolve(
|
return path.resolve(
|
||||||
expandHomePrefix(expanded, {
|
expandHomePrefix(expanded, {
|
||||||
home: resolveRequiredHomeDir(process.env, os.homedir),
|
home: resolveRequiredHomeDir(env, homedir),
|
||||||
env: process.env,
|
env,
|
||||||
homedir: os.homedir,
|
homedir,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -297,11 +302,28 @@ export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
|
|||||||
if (store.startsWith("~")) {
|
if (store.startsWith("~")) {
|
||||||
return path.resolve(
|
return path.resolve(
|
||||||
expandHomePrefix(store, {
|
expandHomePrefix(store, {
|
||||||
home: resolveRequiredHomeDir(process.env, os.homedir),
|
home: resolveRequiredHomeDir(env, homedir),
|
||||||
env: process.env,
|
env,
|
||||||
homedir: os.homedir,
|
homedir,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return path.resolve(store);
|
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 }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
104
src/gateway/server-session-key.test.ts
Normal file
104
src/gateway/server-session-key.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
import { resetAgentRunContextForTest } from "../infra/agent-events.js";
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => ({
|
||||||
|
loadConfigMock: vi.fn<() => OpenClawConfig>(),
|
||||||
|
loadCombinedSessionStoreForGatewayMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../config/config.js", () => ({
|
||||||
|
loadConfig: () => hoisted.loadConfigMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./session-utils.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("./session-utils.js")>("./session-utils.js");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
loadCombinedSessionStoreForGateway: (cfg: OpenClawConfig) =>
|
||||||
|
hoisted.loadCombinedSessionStoreForGatewayMock(cfg),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { resolveSessionKeyForRun, resetResolvedSessionKeyForRunCacheForTest } =
|
||||||
|
await import("./server-session-key.js");
|
||||||
|
|
||||||
|
describe("resolveSessionKeyForRun", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
hoisted.loadConfigMock.mockReset();
|
||||||
|
hoisted.loadCombinedSessionStoreForGatewayMock.mockReset();
|
||||||
|
resetAgentRunContextForTest();
|
||||||
|
resetResolvedSessionKeyForRunCacheForTest();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetAgentRunContextForTest();
|
||||||
|
resetResolvedSessionKeyForRunCacheForTest();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves run ids from the combined gateway store and caches the result", () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
session: {
|
||||||
|
store: "/custom/root/agents/{agentId}/sessions/sessions.json",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
hoisted.loadConfigMock.mockReturnValue(cfg);
|
||||||
|
hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({
|
||||||
|
storePath: "(multiple)",
|
||||||
|
store: {
|
||||||
|
"agent:retired:acp:run-1": { sessionId: "run-1", updatedAt: 123 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveSessionKeyForRun("run-1")).toBe("acp:run-1");
|
||||||
|
expect(resolveSessionKeyForRun("run-1")).toBe("acp:run-1");
|
||||||
|
expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(cfg);
|
||||||
|
});
|
||||||
|
|
||||||
|
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)",
|
||||||
|
store: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the structurally matching session key when duplicate session ids exist", () => {
|
||||||
|
hoisted.loadConfigMock.mockReturnValue({});
|
||||||
|
hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({
|
||||||
|
storePath: "(multiple)",
|
||||||
|
store: {
|
||||||
|
"agent:main:other": { sessionId: "run-dup", updatedAt: 999 },
|
||||||
|
"agent:retired:acp:run-dup": { sessionId: "run-dup", updatedAt: 100 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveSessionKeyForRun("run-dup")).toBe("acp:run-dup");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses ambiguous duplicate session ids without a clear best match", () => {
|
||||||
|
hoisted.loadConfigMock.mockReturnValue({});
|
||||||
|
hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({
|
||||||
|
storePath: "(multiple)",
|
||||||
|
store: {
|
||||||
|
"agent:main:first": { sessionId: "run-ambiguous", updatedAt: 100 },
|
||||||
|
"agent:retired:second": { sessionId: "run-ambiguous", updatedAt: 100 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveSessionKeyForRun("run-ambiguous")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,22 +1,70 @@
|
|||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
import type { SessionEntry } from "../config/sessions.js";
|
||||||
import { getAgentRunContext, registerAgentRunContext } from "../infra/agent-events.js";
|
import { getAgentRunContext, registerAgentRunContext } from "../infra/agent-events.js";
|
||||||
import { toAgentRequestSessionKey } from "../routing/session-key.js";
|
import { toAgentRequestSessionKey } from "../routing/session-key.js";
|
||||||
|
import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js";
|
||||||
|
import { loadCombinedSessionStoreForGateway } from "./session-utils.js";
|
||||||
|
|
||||||
|
const RUN_LOOKUP_CACHE_LIMIT = 256;
|
||||||
|
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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!resolvedSessionKeyByRunId.has(runId) &&
|
||||||
|
resolvedSessionKeyByRunId.size >= RUN_LOOKUP_CACHE_LIMIT
|
||||||
|
) {
|
||||||
|
const oldest = resolvedSessionKeyByRunId.keys().next().value;
|
||||||
|
if (oldest) {
|
||||||
|
resolvedSessionKeyByRunId.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolvedSessionKeyByRunId.set(runId, {
|
||||||
|
sessionKey,
|
||||||
|
expiresAt: sessionKey === null ? Date.now() + RUN_LOOKUP_MISS_TTL_MS : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveSessionKeyForRun(runId: string) {
|
export function resolveSessionKeyForRun(runId: string) {
|
||||||
const cached = getAgentRunContext(runId)?.sessionKey;
|
const cached = getAgentRunContext(runId)?.sessionKey;
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
const cachedLookup = resolvedSessionKeyByRunId.get(runId);
|
||||||
|
if (cachedLookup !== undefined) {
|
||||||
|
if (cachedLookup.sessionKey !== null) {
|
||||||
|
return cachedLookup.sessionKey;
|
||||||
|
}
|
||||||
|
if ((cachedLookup.expiresAt ?? 0) > Date.now()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
resolvedSessionKeyByRunId.delete(runId);
|
||||||
|
}
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const storePath = resolveStorePath(cfg.session?.store);
|
const { store } = loadCombinedSessionStoreForGateway(cfg);
|
||||||
const store = loadSessionStore(storePath);
|
const matches = Object.entries(store).filter(
|
||||||
const found = Object.entries(store).find(([, entry]) => entry?.sessionId === runId);
|
(entry): entry is [string, SessionEntry] => entry[1]?.sessionId === runId,
|
||||||
const storeKey = found?.[0];
|
);
|
||||||
|
const storeKey = resolvePreferredSessionKeyForSessionIdMatches(matches, runId);
|
||||||
if (storeKey) {
|
if (storeKey) {
|
||||||
const sessionKey = toAgentRequestSessionKey(storeKey) ?? storeKey;
|
const sessionKey = toAgentRequestSessionKey(storeKey) ?? storeKey;
|
||||||
registerAgentRunContext(runId, { sessionKey });
|
registerAgentRunContext(runId, { sessionKey });
|
||||||
|
setResolvedSessionKeyCache(runId, sessionKey);
|
||||||
return sessionKey;
|
return sessionKey;
|
||||||
}
|
}
|
||||||
|
setResolvedSessionKeyCache(runId, null);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resetResolvedSessionKeyForRunCacheForTest(): void {
|
||||||
|
resolvedSessionKeyByRunId.clear();
|
||||||
|
}
|
||||||
|
|||||||
@@ -767,7 +767,8 @@ describe("listSessionsFromStore search", () => {
|
|||||||
describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)", () => {
|
describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)", () => {
|
||||||
test("ACP agent sessions are visible even when agents.list is configured", async () => {
|
test("ACP agent sessions are visible even when agents.list is configured", async () => {
|
||||||
await withStateDirEnv("openclaw-acp-vis-", async ({ stateDir }) => {
|
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 mainDir = path.join(agentsDir, "main", "sessions");
|
||||||
const codexDir = path.join(agentsDir, "codex", "sessions");
|
const codexDir = path.join(agentsDir, "codex", "sessions");
|
||||||
fs.mkdirSync(mainDir, { recursive: true });
|
fs.mkdirSync(mainDir, { recursive: true });
|
||||||
@@ -792,7 +793,7 @@ describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)"
|
|||||||
const cfg = {
|
const cfg = {
|
||||||
session: {
|
session: {
|
||||||
mainKey: "main",
|
mainKey: "main",
|
||||||
store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json"),
|
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
list: [{ id: "main", default: true }],
|
list: [{ id: "main", default: true }],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
buildGroupDisplayName,
|
buildGroupDisplayName,
|
||||||
canonicalizeMainSessionAlias,
|
canonicalizeMainSessionAlias,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
resolveAllAgentSessionStoreTargetsSync,
|
||||||
resolveAgentMainSessionKey,
|
resolveAgentMainSessionKey,
|
||||||
resolveFreshSessionTotalTokens,
|
resolveFreshSessionTotalTokens,
|
||||||
resolveMainSessionKey,
|
resolveMainSessionKey,
|
||||||
@@ -585,10 +586,11 @@ export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): {
|
|||||||
return { storePath, store: combined };
|
return { storePath, store: combined };
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentIds = listConfiguredAgentIds(cfg);
|
const targets = resolveAllAgentSessionStoreTargetsSync(cfg);
|
||||||
const combined: Record<string, SessionEntry> = {};
|
const combined: Record<string, SessionEntry> = {};
|
||||||
for (const agentId of agentIds) {
|
for (const target of targets) {
|
||||||
const storePath = resolveStorePath(storeConfig, { agentId });
|
const agentId = target.agentId;
|
||||||
|
const storePath = target.storePath;
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
for (const [key, entry] of Object.entries(store)) {
|
for (const [key, entry] of Object.entries(store)) {
|
||||||
const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);
|
const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);
|
||||||
|
|||||||
37
src/sessions/session-id-resolution.ts
Normal file
37
src/sessions/session-id-resolution.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { SessionEntry } from "../config/sessions.js";
|
||||||
|
import { toAgentRequestSessionKey } from "../routing/session-key.js";
|
||||||
|
|
||||||
|
export function resolvePreferredSessionKeyForSessionIdMatches(
|
||||||
|
matches: Array<[string, SessionEntry]>,
|
||||||
|
sessionId: string,
|
||||||
|
): string | undefined {
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (matches.length === 1) {
|
||||||
|
return matches[0][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const loweredSessionId = sessionId.trim().toLowerCase();
|
||||||
|
const structuralMatches = matches.filter(([storeKey]) => {
|
||||||
|
const requestKey = toAgentRequestSessionKey(storeKey)?.toLowerCase();
|
||||||
|
return (
|
||||||
|
storeKey.toLowerCase().endsWith(`:${loweredSessionId}`) ||
|
||||||
|
requestKey === loweredSessionId ||
|
||||||
|
requestKey?.endsWith(`:${loweredSessionId}`) === true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (structuralMatches.length === 1) {
|
||||||
|
return structuralMatches[0][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedMatches = [...matches].toSorted(
|
||||||
|
(a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0),
|
||||||
|
);
|
||||||
|
const [freshest, secondFreshest] = sortedMatches;
|
||||||
|
if ((freshest?.[1]?.updatedAt ?? 0) > (secondFreshest?.[1]?.updatedAt ?? 0)) {
|
||||||
|
return freshest?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user