Skip to content

Commit 46f0bfc

Browse files
authored
Gateway: harden custom session-store discovery (#44176)
Merged via squash. Prepared head SHA: 52ebbf5 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras
1 parent dc3bb18 commit 46f0bfc

20 files changed

Lines changed: 1146 additions & 183 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
4747
- 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.
4848
- 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.
4949
- 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.
50+
- 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.
5051

5152
## 2026.3.11
5253

docs/channels/channel-routing.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ Session stores live under the state directory (default `~/.openclaw`):
118118

119119
You can override the store path via `session.store` and `{agentId}` templating.
120120

121+
Gateway and ACP session discovery also scans disk-backed agent stores under the
122+
default `agents/` root and under templated `session.store` roots. Discovered
123+
stores must stay inside that resolved agent root and use a regular
124+
`sessions.json` file. Symlinks and out-of-root paths are ignored.
125+
121126
## WebChat behavior
122127

123128
WebChat attaches to the **selected agent** and defaults to the agent’s main

docs/cli/sessions.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ Scope selection:
2424
- `--all-agents`: aggregate all configured agent stores
2525
- `--store <path>`: explicit store path (cannot be combined with `--agent` or `--all-agents`)
2626

27+
`openclaw sessions --all-agents` reads configured agent stores. Gateway and ACP
28+
session discovery are broader: they also include disk-only stores found under
29+
the default `agents/` root or a templated `session.store` root. Those
30+
discovered stores must resolve to regular `sessions.json` files inside the
31+
agent root; symlinks and out-of-root paths are skipped.
32+
2733
JSON examples:
2834

2935
`openclaw sessions --all-agents --json`:

docs/tools/acp-agents.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,8 @@ Some controls depend on backend capabilities. If a backend does not support a co
421421
| `/acp doctor` | Backend health, capabilities, actionable fixes. | `/acp doctor` |
422422
| `/acp install` | Print deterministic install and enable steps. | `/acp install` |
423423

424+
`/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.
425+
424426
## Runtime options mapping
425427

426428
`/acp` has convenience commands and a generic setter.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { OpenClawConfig } from "../../config/config.js";
3+
4+
const hoisted = vi.hoisted(() => {
5+
const resolveAllAgentSessionStoreTargetsMock = vi.fn();
6+
const loadSessionStoreMock = vi.fn();
7+
return {
8+
resolveAllAgentSessionStoreTargetsMock,
9+
loadSessionStoreMock,
10+
};
11+
});
12+
13+
vi.mock("../../config/sessions.js", async () => {
14+
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
15+
"../../config/sessions.js",
16+
);
17+
return {
18+
...actual,
19+
resolveAllAgentSessionStoreTargets: (cfg: OpenClawConfig, opts: unknown) =>
20+
hoisted.resolveAllAgentSessionStoreTargetsMock(cfg, opts),
21+
loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath),
22+
};
23+
});
24+
25+
const { listAcpSessionEntries } = await import("./session-meta.js");
26+
27+
describe("listAcpSessionEntries", () => {
28+
beforeEach(() => {
29+
vi.clearAllMocks();
30+
});
31+
32+
it("reads ACP sessions from resolved configured store targets", async () => {
33+
const cfg = {
34+
session: {
35+
store: "/custom/sessions/{agentId}.json",
36+
},
37+
} as OpenClawConfig;
38+
hoisted.resolveAllAgentSessionStoreTargetsMock.mockResolvedValue([
39+
{
40+
agentId: "ops",
41+
storePath: "/custom/sessions/ops.json",
42+
},
43+
]);
44+
hoisted.loadSessionStoreMock.mockReturnValue({
45+
"agent:ops:acp:s1": {
46+
updatedAt: 123,
47+
acp: {
48+
backend: "acpx",
49+
agent: "ops",
50+
mode: "persistent",
51+
state: "idle",
52+
},
53+
},
54+
});
55+
56+
const entries = await listAcpSessionEntries({ cfg });
57+
58+
expect(hoisted.resolveAllAgentSessionStoreTargetsMock).toHaveBeenCalledWith(cfg, undefined);
59+
expect(hoisted.loadSessionStoreMock).toHaveBeenCalledWith("/custom/sessions/ops.json");
60+
expect(entries).toEqual([
61+
expect.objectContaining({
62+
cfg,
63+
storePath: "/custom/sessions/ops.json",
64+
sessionKey: "agent:ops:acp:s1",
65+
storeSessionKey: "agent:ops:acp:s1",
66+
}),
67+
]);
68+
});
69+
});

src/acp/runtime/session-meta.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import path from "node:path";
2-
import { resolveAgentSessionDirs } from "../../agents/session-dirs.js";
31
import type { OpenClawConfig } from "../../config/config.js";
42
import { loadConfig } from "../../config/config.js";
5-
import { resolveStateDir } from "../../config/paths.js";
6-
import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js";
3+
import {
4+
loadSessionStore,
5+
resolveAllAgentSessionStoreTargets,
6+
resolveStorePath,
7+
updateSessionStore,
8+
} from "../../config/sessions.js";
79
import {
810
mergeSessionEntry,
911
type SessionAcpMeta,
@@ -88,14 +90,17 @@ export function readAcpSessionEntry(params: {
8890

8991
export async function listAcpSessionEntries(params: {
9092
cfg?: OpenClawConfig;
93+
env?: NodeJS.ProcessEnv;
9194
}): Promise<AcpSessionStoreEntry[]> {
9295
const cfg = params.cfg ?? loadConfig();
93-
const stateDir = resolveStateDir(process.env);
94-
const sessionDirs = await resolveAgentSessionDirs(stateDir);
96+
const storeTargets = await resolveAllAgentSessionStoreTargets(
97+
cfg,
98+
params.env ? { env: params.env } : undefined,
99+
);
95100
const entries: AcpSessionStoreEntry[] = [];
96101

97-
for (const sessionsDir of sessionDirs) {
98-
const storePath = path.join(sessionsDir, "sessions.json");
102+
for (const target of storeTargets) {
103+
const storePath = target.storePath;
99104
let store: Record<string, SessionEntry>;
100105
try {
101106
store = loadSessionStore(storePath);

src/agents/openclaw-tools.session-status.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
33
const loadSessionStoreMock = vi.fn();
44
const updateSessionStoreMock = vi.fn();
55
const callGatewayMock = vi.fn();
6+
const loadCombinedSessionStoreForGatewayMock = vi.fn();
67

78
const createMockConfig = () => ({
89
session: { mainKey: "main", scope: "per-sender" },
@@ -42,6 +43,15 @@ vi.mock("../gateway/call.js", () => ({
4243
callGateway: (opts: unknown) => callGatewayMock(opts),
4344
}));
4445

46+
vi.mock("../gateway/session-utils.js", async (importOriginal) => {
47+
const actual = await importOriginal<typeof import("../gateway/session-utils.js")>();
48+
return {
49+
...actual,
50+
loadCombinedSessionStoreForGateway: (cfg: unknown) =>
51+
loadCombinedSessionStoreForGatewayMock(cfg),
52+
};
53+
});
54+
4555
vi.mock("../config/config.js", async (importOriginal) => {
4656
const actual = await importOriginal<typeof import("../config/config.js")>();
4757
return {
@@ -95,7 +105,12 @@ function resetSessionStore(store: Record<string, unknown>) {
95105
loadSessionStoreMock.mockClear();
96106
updateSessionStoreMock.mockClear();
97107
callGatewayMock.mockClear();
108+
loadCombinedSessionStoreForGatewayMock.mockClear();
98109
loadSessionStoreMock.mockReturnValue(store);
110+
loadCombinedSessionStoreForGatewayMock.mockReturnValue({
111+
storePath: "(multiple)",
112+
store,
113+
});
99114
callGatewayMock.mockResolvedValue({});
100115
mockConfig = createMockConfig();
101116
}
@@ -161,6 +176,30 @@ describe("session_status tool", () => {
161176
expect(details.sessionKey).toBe("agent:main:main");
162177
});
163178

179+
it("resolves duplicate sessionId inputs deterministically", async () => {
180+
resetSessionStore({
181+
"agent:main:main": {
182+
sessionId: "current",
183+
updatedAt: 10,
184+
},
185+
"agent:main:other": {
186+
sessionId: "run-dup",
187+
updatedAt: 999,
188+
},
189+
"agent:main:acp:run-dup": {
190+
sessionId: "run-dup",
191+
updatedAt: 100,
192+
},
193+
});
194+
195+
const tool = getSessionStatusTool();
196+
197+
const result = await tool.execute("call-dup", { sessionKey: "run-dup" });
198+
const details = result.details as { ok?: boolean; sessionKey?: string };
199+
expect(details.ok).toBe(true);
200+
expect(details.sessionKey).toBe("agent:main:acp:run-dup");
201+
});
202+
164203
it("uses non-standard session keys without sessionId resolution", async () => {
165204
resetSessionStore({
166205
"temp:slug-generator": {

src/agents/session-dirs.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
import type { Dirent } from "node:fs";
1+
import fsSync, { type Dirent } from "node:fs";
22
import fs from "node:fs/promises";
33
import path from "node:path";
44

5-
export async function resolveAgentSessionDirs(stateDir: string): Promise<string[]> {
6-
const agentsDir = path.join(stateDir, "agents");
5+
function mapAgentSessionDirs(agentsDir: string, entries: Dirent[]): string[] {
6+
return entries
7+
.filter((entry) => entry.isDirectory())
8+
.map((entry) => path.join(agentsDir, entry.name, "sessions"))
9+
.toSorted((a, b) => a.localeCompare(b));
10+
}
11+
12+
export async function resolveAgentSessionDirsFromAgentsDir(agentsDir: string): Promise<string[]> {
713
let entries: Dirent[] = [];
814
try {
915
entries = await fs.readdir(agentsDir, { withFileTypes: true });
@@ -15,8 +21,24 @@ export async function resolveAgentSessionDirs(stateDir: string): Promise<string[
1521
throw err;
1622
}
1723

18-
return entries
19-
.filter((entry) => entry.isDirectory())
20-
.map((entry) => path.join(agentsDir, entry.name, "sessions"))
21-
.toSorted((a, b) => a.localeCompare(b));
24+
return mapAgentSessionDirs(agentsDir, entries);
25+
}
26+
27+
export function resolveAgentSessionDirsFromAgentsDirSync(agentsDir: string): string[] {
28+
let entries: Dirent[] = [];
29+
try {
30+
entries = fsSync.readdirSync(agentsDir, { withFileTypes: true });
31+
} catch (err) {
32+
const code = (err as { code?: string }).code;
33+
if (code === "ENOENT") {
34+
return [];
35+
}
36+
throw err;
37+
}
38+
39+
return mapAgentSessionDirs(agentsDir, entries);
40+
}
41+
42+
export async function resolveAgentSessionDirs(stateDir: string): Promise<string[]> {
43+
return await resolveAgentSessionDirsFromAgentsDir(path.join(stateDir, "agents"));
2244
}

src/agents/tools/session-status-tool.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
resolveAgentIdFromSessionKey,
2424
} from "../../routing/session-key.js";
2525
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
26+
import { resolvePreferredSessionKeyForSessionIdMatches } from "../../sessions/session-id-resolution.js";
2627
import { resolveAgentDir } from "../agent-scope.js";
2728
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
2829
import { resolveModelAuthLabel } from "../model-auth-label.js";
@@ -100,16 +101,12 @@ function resolveSessionKeyFromSessionId(params: {
100101
return null;
101102
}
102103
const { store } = loadCombinedSessionStoreForGateway(params.cfg);
103-
const match = Object.entries(store).find(([key, entry]) => {
104-
if (entry?.sessionId !== trimmed) {
105-
return false;
106-
}
107-
if (!params.agentId) {
108-
return true;
109-
}
110-
return resolveAgentIdFromSessionKey(key) === params.agentId;
111-
});
112-
return match?.[0] ?? null;
104+
const matches = Object.entries(store).filter(
105+
(entry): entry is [string, SessionEntry] =>
106+
entry[1]?.sessionId === trimmed &&
107+
(!params.agentId || resolveAgentIdFromSessionKey(entry[0]) === params.agentId),
108+
);
109+
return resolvePreferredSessionKeyForSessionIdMatches(matches, trimmed) ?? null;
113110
}
114111

115112
async function resolveModelOverride(params: {
Lines changed: 7 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,25 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import { resolveSessionStoreTargets } from "./session-store-targets.js";
33

4-
const resolveStorePathMock = vi.hoisted(() => vi.fn());
5-
const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn());
6-
const listAgentIdsMock = vi.hoisted(() => vi.fn());
4+
const resolveSessionStoreTargetsMock = vi.hoisted(() => vi.fn());
75

86
vi.mock("../config/sessions.js", () => ({
9-
resolveStorePath: resolveStorePathMock,
10-
}));
11-
12-
vi.mock("../agents/agent-scope.js", () => ({
13-
resolveDefaultAgentId: resolveDefaultAgentIdMock,
14-
listAgentIds: listAgentIdsMock,
7+
resolveSessionStoreTargets: resolveSessionStoreTargetsMock,
158
}));
169

1710
describe("resolveSessionStoreTargets", () => {
1811
beforeEach(() => {
1912
vi.clearAllMocks();
2013
});
2114

22-
it("resolves the default agent store when no selector is provided", () => {
23-
resolveDefaultAgentIdMock.mockReturnValue("main");
24-
resolveStorePathMock.mockReturnValue("/tmp/main-sessions.json");
25-
26-
const targets = resolveSessionStoreTargets({}, {});
27-
28-
expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/main-sessions.json" }]);
29-
expect(resolveStorePathMock).toHaveBeenCalledWith(undefined, { agentId: "main" });
30-
});
31-
32-
it("resolves all configured agent stores", () => {
33-
listAgentIdsMock.mockReturnValue(["main", "work"]);
34-
resolveStorePathMock
35-
.mockReturnValueOnce("/tmp/main-sessions.json")
36-
.mockReturnValueOnce("/tmp/work-sessions.json");
37-
38-
const targets = resolveSessionStoreTargets(
39-
{
40-
session: { store: "~/.openclaw/agents/{agentId}/sessions/sessions.json" },
41-
},
42-
{ allAgents: true },
43-
);
44-
45-
expect(targets).toEqual([
15+
it("delegates session store target resolution to the shared config helper", () => {
16+
resolveSessionStoreTargetsMock.mockReturnValue([
4617
{ agentId: "main", storePath: "/tmp/main-sessions.json" },
47-
{ agentId: "work", storePath: "/tmp/work-sessions.json" },
4818
]);
49-
});
50-
51-
it("dedupes shared store paths for --all-agents", () => {
52-
listAgentIdsMock.mockReturnValue(["main", "work"]);
53-
resolveStorePathMock.mockReturnValue("/tmp/shared-sessions.json");
5419

55-
const targets = resolveSessionStoreTargets(
56-
{
57-
session: { store: "/tmp/shared-sessions.json" },
58-
},
59-
{ allAgents: true },
60-
);
61-
62-
expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/shared-sessions.json" }]);
63-
expect(resolveStorePathMock).toHaveBeenCalledTimes(2);
64-
});
65-
66-
it("rejects unknown agent ids", () => {
67-
listAgentIdsMock.mockReturnValue(["main", "work"]);
68-
expect(() => resolveSessionStoreTargets({}, { agent: "ghost" })).toThrow(/Unknown agent id/);
69-
});
20+
const targets = resolveSessionStoreTargets({}, {});
7021

71-
it("rejects conflicting selectors", () => {
72-
expect(() => resolveSessionStoreTargets({}, { agent: "main", allAgents: true })).toThrow(
73-
/cannot be used together/i,
74-
);
75-
expect(() =>
76-
resolveSessionStoreTargets({}, { store: "/tmp/sessions.json", allAgents: true }),
77-
).toThrow(/cannot be combined/i);
22+
expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/main-sessions.json" }]);
23+
expect(resolveSessionStoreTargetsMock).toHaveBeenCalledWith({}, {});
7824
});
7925
});

0 commit comments

Comments
 (0)