[codex] fix agent session-id routing (#70985)

Merged via squash.

Prepared head SHA: f092b0c5c8
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
This commit is contained in:
Frank Yang
2026-04-24 17:31:34 +08:00
committed by Peter Steinberger
parent aee7c4ef86
commit dc48fb756c
8 changed files with 256 additions and 23 deletions

View File

@@ -81,6 +81,8 @@ Docs: https://docs.openclaw.ai
- Codex harness/status: pin embedded harness selection per session, show active non-PI harness ids such as `codex` in `/status`, and keep legacy transcripts on PI until `/new` or `/reset` so config changes cannot hot-switch existing sessions.
- Gateway/security: fail closed on agent-driven `gateway config.apply`/`config.patch` runtime edits by allowlisting a narrow set of agent-tunable prompt, model, and mention-gating paths (including Telegram topic-level `requireMention`) instead of relying on a hand-maintained denylist of protected subtrees that could miss new sensitive config keys. (#70726) Thanks @drobison00.
- Webhooks/security: re-resolve `SecretRef`-backed webhook route secrets on each request so `openclaw secrets reload` revokes the previous secret immediately instead of waiting for a gateway restart. (#70727) Thanks @drobison00.
- Memory/dreaming: decouple the managed dreaming cron from heartbeat by running it as an isolated lightweight agent turn, so dreaming runs even when heartbeat is disabled for the default agent and is no longer skipped by `heartbeat.activeHours`. `openclaw doctor --fix` migrates stale main-session dreaming jobs in persisted cron configs to the new shape. Fixes #69811, #67397, #68972. (#70737) Thanks @jalehman.
- Agents/CLI: keep `--agent` plus `--session-id` lookup scoped to the requested agent store, so explicit agent resumes cannot select another agent's session. (#70985) Thanks @frankekn.
## 2026.4.22

View File

@@ -58,6 +58,7 @@ function collectSessionIdMatchesForRequest(opts: {
storePath: string;
storeAgentId?: string;
sessionId: string;
searchOtherAgentStores: boolean;
}): SessionIdMatchSet {
const matches: Array<[string, SessionEntry]> = [];
const primaryStoreMatches: Array<[string, SessionEntry]> = [];
@@ -85,6 +86,10 @@ function collectSessionIdMatchesForRequest(opts: {
};
addMatches(opts.sessionStore, opts.storePath, { primary: true });
if (!opts.searchOtherAgentStores) {
return { matches, primaryStoreMatches, storeByKey };
}
for (const agentId of listAgentIds(opts.cfg)) {
if (agentId === opts.storeAgentId) {
continue;
@@ -137,13 +142,19 @@ export function resolveSessionKeyForRequest(opts: {
const sessionCfg = opts.cfg.session;
const scope = sessionCfg?.scope ?? "per-sender";
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
const requestedAgentId = opts.agentId?.trim() ? normalizeAgentId(opts.agentId) : undefined;
const requestedSessionId = opts.sessionId?.trim() || undefined;
const explicitSessionKey =
opts.sessionKey?.trim() ||
resolveExplicitAgentSessionKey({
cfg: opts.cfg,
agentId: opts.agentId,
});
const storeAgentId = resolveAgentIdFromSessionKey(explicitSessionKey);
(!requestedSessionId
? resolveExplicitAgentSessionKey({
cfg: opts.cfg,
agentId: requestedAgentId,
})
: undefined);
const storeAgentId = explicitSessionKey
? resolveAgentIdFromSessionKey(explicitSessionKey)
: (requestedAgentId ?? normalizeAgentId(undefined));
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: storeAgentId,
});
@@ -158,22 +169,23 @@ export function resolveSessionKeyForRequest(opts: {
// by the shared gateway/session resolver helpers instead of whichever store happens to be scanned
// first.
if (
opts.sessionId &&
requestedSessionId &&
!explicitSessionKey &&
(!sessionKey || sessionStore[sessionKey]?.sessionId !== opts.sessionId)
(!sessionKey || sessionStore[sessionKey]?.sessionId !== requestedSessionId)
) {
const { matches, primaryStoreMatches, storeByKey } = collectSessionIdMatchesForRequest({
cfg: opts.cfg,
sessionStore,
storePath,
storeAgentId,
sessionId: opts.sessionId,
sessionId: requestedSessionId,
searchOtherAgentStores: requestedAgentId === undefined,
});
const preferredSelection = resolveSessionIdMatchSelection(matches, opts.sessionId);
const preferredSelection = resolveSessionIdMatchSelection(matches, requestedSessionId);
const currentStoreSelection =
preferredSelection.kind === "selected"
? preferredSelection
: resolveSessionIdMatchSelection(primaryStoreMatches, opts.sessionId);
: resolveSessionIdMatchSelection(primaryStoreMatches, requestedSessionId);
if (currentStoreSelection.kind === "selected") {
const preferred = storeByKey.get(currentStoreSelection.sessionKey);
if (preferred) {
@@ -183,9 +195,9 @@ export function resolveSessionKeyForRequest(opts: {
}
}
if (opts.sessionId && !sessionKey) {
if (requestedSessionId && !sessionKey) {
sessionKey = buildExplicitSessionIdSessionKey({
sessionId: opts.sessionId,
sessionId: requestedSessionId,
agentId: opts.agentId,
});
}

View File

@@ -1,6 +1,8 @@
import fs from "node:fs/promises";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { slugifySessionKey } from "../agents/sandbox/shared.js";
import { CONFIG_DIR } from "../utils.js";
import {
createSandboxMediaContexts,
createSandboxMediaStageConfig,
@@ -50,7 +52,7 @@ function createRemoteStageParams(home: string): {
cfg: createSandboxMediaStageConfig(home),
workspaceDir: join(home, "openclaw"),
sessionKey,
remoteCacheDir: join(home, ".openclaw", "media", "remote-cache", sessionKey),
remoteCacheDir: join(home, ".openclaw", "media", "remote-cache", slugifySessionKey(sessionKey)),
};
}
@@ -86,4 +88,33 @@ describe("stageSandboxMedia scp remote paths", () => {
expect(sessionCtx.MediaUrl).toBe(remotePath);
});
});
it("uses a slugged remote cache directory for session keys with path separators", async () => {
await withSandboxMediaTempHome("openclaw-triggers-", async (home) => {
const { cfg, workspaceDir } = createRemoteStageParams(home);
const sessionKey = "agent:main:explicit:../../escape";
const remotePath = "/Users/demo/Library/Messages/Attachments/ab/cd/photo.jpg";
const { ctx, sessionCtx } = createRemoteContexts(remotePath);
childProcessMocks.spawn.mockImplementation(() => {
throw new Error("stop before scp");
});
await stageSandboxMedia({
ctx,
sessionCtx,
cfg,
sessionKey,
workspaceDir,
});
const remoteCacheRoot = join(CONFIG_DIR, "media", "remote-cache");
const expectedSafeDir = join(remoteCacheRoot, slugifySessionKey(sessionKey));
try {
await expect(fs.stat(expectedSafeDir)).resolves.toBeTruthy();
await expect(fs.stat(join(CONFIG_DIR, "escape"))).rejects.toThrow();
} finally {
await fs.rm(expectedSafeDir, { recursive: true, force: true });
}
});
});
});

View File

@@ -41,7 +41,13 @@ vi.mock("../agents/sandbox.js", () => sandboxMocks);
vi.mock("../agents/sandbox-paths.js", () => ({
assertSandboxPath: sandboxMocks.assertSandboxPath,
}));
vi.mock("node:child_process", () => childProcessMocks);
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawn: childProcessMocks.spawn,
};
});
vi.mock("../infra/fs-safe.js", () => fsSafeMocks);
vi.mock("../media/channel-inbound-roots.js", () => mediaRootMocks);

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { assertSandboxPath } from "../../agents/sandbox-paths.js";
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
import { slugifySessionKey } from "../../agents/sandbox/shared.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { logVerbose } from "../../globals.js";
import { copyFileWithinRoot, SafeOpenError } from "../../infra/fs-safe.js";
@@ -40,7 +41,7 @@ export async function stageSandboxMedia(params: {
// For remote attachments without sandbox, use ~/.openclaw/media (not agent workspace for privacy)
const remoteMediaCacheDir = ctx.MediaRemoteHost
? path.join(CONFIG_DIR, "media", "remote-cache", sessionKey)
? path.join(CONFIG_DIR, "media", "remote-cache", slugifySessionKey(sessionKey))
: null;
const effectiveWorkspaceDir = sandbox?.workspaceDir ?? remoteMediaCacheDir;
if (!effectiveWorkspaceDir) {

View File

@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
loadSessionStore: vi.fn(),
resolveStorePath: vi.fn(),
listAgentIds: vi.fn(),
resolveExplicitAgentSessionKey: vi.fn(),
}));
vi.mock("../../config/sessions/main-session.js", async () => {
@@ -14,7 +15,7 @@ vi.mock("../../config/sessions/main-session.js", async () => {
);
return {
...actual,
resolveExplicitAgentSessionKey: () => undefined,
resolveExplicitAgentSessionKey: mocks.resolveExplicitAgentSessionKey,
};
});
@@ -55,6 +56,7 @@ describe("resolveSessionKeyForRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.listAgentIds.mockReturnValue(["main"]);
mocks.resolveExplicitAgentSessionKey.mockReturnValue(undefined);
});
const baseCfg: OpenClawConfig = {};
@@ -101,6 +103,72 @@ describe("resolveSessionKeyForRequest", () => {
expect(result.storePath).toBe(MYBOT_STORE_PATH);
});
it("does not let --agent short-circuit --session-id back to the agent main session", async () => {
setupMainAndMybotStorePaths();
mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:mybot:main");
mockStoresByPath({
[MYBOT_STORE_PATH]: {
"agent:mybot:main": { sessionId: "other-session-id", updatedAt: 0 },
"agent:mybot:whatsapp:direct:+15551234567": {
sessionId: "target-session-id",
updatedAt: 1,
},
},
});
const result = resolveSessionKeyForRequest({
cfg: baseCfg,
agentId: "mybot",
sessionId: "target-session-id",
});
expect(result.sessionKey).toBe("agent:mybot:whatsapp:direct:+15551234567");
expect(result.storePath).toBe(MYBOT_STORE_PATH);
});
it("treats whitespace --session-id as absent when resolving --agent", async () => {
setupMainAndMybotStorePaths();
mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:mybot:main");
mockStoresByPath({
[MYBOT_STORE_PATH]: {
"agent:mybot:main": { sessionId: "existing-session-id", updatedAt: 1 },
},
});
const result = resolveSessionKeyForRequest({
cfg: baseCfg,
agentId: "mybot",
sessionId: " ",
});
expect(result.sessionKey).toBe("agent:mybot:main");
expect(result.storePath).toBe(MYBOT_STORE_PATH);
});
it("does not search other agent stores when --agent scopes --session-id", async () => {
setupMainAndMybotStorePaths();
mockStoresByPath({
[MAIN_STORE_PATH]: {
"agent:main:whatsapp:direct:+15550000000": {
sessionId: "target-session-id",
updatedAt: 10,
},
},
[MYBOT_STORE_PATH]: {},
});
const result = resolveSessionKeyForRequest({
cfg: baseCfg,
agentId: "mybot",
sessionId: "target-session-id",
});
expect(result.sessionKey).toBe("agent:mybot:explicit:target-session-id");
expect(result.storePath).toBe(MYBOT_STORE_PATH);
expect(mocks.loadSessionStore).toHaveBeenCalledTimes(1);
expect(mocks.loadSessionStore).toHaveBeenCalledWith(MYBOT_STORE_PATH);
});
it("returns correct sessionStore when session found in non-primary agent store", async () => {
const mybotStore = {
"agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 },

View File

@@ -23,7 +23,9 @@ const mocks = vi.hoisted(() => ({
performGatewaySessionReset: vi.fn(),
getLatestSubagentRunByChildSessionKey: vi.fn(),
replaceSubagentRunAfterSteer: vi.fn(),
resolveExplicitAgentSessionKey: vi.fn(),
resolveBareResetBootstrapFileAccess: vi.fn(() => true),
listAgentIds: vi.fn(() => ["main"]),
loadConfigReturn: {} as Record<string, unknown>,
}));
@@ -44,7 +46,7 @@ vi.mock("../../config/sessions.js", async () => {
...actual,
updateSessionStore: mocks.updateSessionStore,
resolveAgentIdFromSessionKey: () => "main",
resolveExplicitAgentSessionKey: () => undefined,
resolveExplicitAgentSessionKey: mocks.resolveExplicitAgentSessionKey,
resolveAgentMainSessionKey: ({
cfg,
agentId,
@@ -70,7 +72,8 @@ vi.mock("../../config/config.js", async () => {
});
vi.mock("../../agents/agent-scope.js", () => ({
listAgentIds: () => ["main"],
listAgentIds: mocks.listAgentIds,
resolveDefaultAgentId: () => "main",
resolveAgentWorkspaceDir: (cfg: { agents?: { defaults?: { workspace?: string } } }) =>
cfg?.agents?.defaults?.workspace ?? "/tmp/workspace",
resolveAgentEffectiveModelPrimary: () => undefined,
@@ -334,7 +337,9 @@ describe("gateway agent handler", () => {
}
resetDetachedTaskLifecycleRuntimeForTests();
resetTaskRegistryForTests();
mocks.resolveExplicitAgentSessionKey.mockReset().mockReturnValue(undefined);
mocks.resolveBareResetBootstrapFileAccess.mockReset().mockReturnValue(true);
mocks.listAgentIds.mockReset().mockReturnValue(["main"]);
});
it("preserves ACP metadata from the current stored session entry", async () => {
@@ -996,6 +1001,105 @@ describe("gateway agent handler", () => {
});
});
it("does not let --agent force the agent main session when --session-id is provided", async () => {
mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:main:main");
mockMainSessionEntry({ sessionId: "resume-whatsapp-session" });
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await invokeAgent(
{
message: "resume channel session",
agentId: "main",
sessionId: "resume-whatsapp-session",
idempotencyKey: "session-id-agent-resume",
},
{ reqId: "session-id-agent-resume" },
);
await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled());
const call = mocks.agentCommand.mock.calls.at(-1)?.[0] as {
agentId?: string;
sessionId?: string;
sessionKey?: string;
};
expect(call?.agentId).toBe("main");
expect(call?.sessionId).toBe("resume-whatsapp-session");
expect(call?.sessionKey).toBeUndefined();
});
it("treats whitespace sessionId as absent before resolving the agent session key", async () => {
mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:main:main");
mockMainSessionEntry({ sessionId: "existing-session-id" });
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await invokeAgent(
{
message: "resume main",
agentId: "main",
sessionId: " ",
idempotencyKey: "blank-session-id-agent-resume",
},
{ reqId: "blank-session-id-agent-resume" },
);
await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled());
const call = mocks.agentCommand.mock.calls.at(-1)?.[0] as {
agentId?: string;
sessionId?: string;
sessionKey?: string;
};
expect(call?.agentId).toBe("main");
expect(call?.sessionId).toBe("existing-session-id");
expect(call?.sessionKey).toBe("agent:main:main");
});
it("does not forward a non-main agent id with canonical global session keys", async () => {
mocks.listAgentIds.mockReturnValue(["main", "ops"]);
mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:ops:main");
mocks.loadSessionEntry.mockReturnValue({
cfg: { session: { scope: "global" } },
storePath: "/tmp/sessions.json",
entry: {
sessionId: "global-session-id",
updatedAt: Date.now(),
},
canonicalKey: "global",
});
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {
global: { sessionId: "global-session-id", updatedAt: Date.now() },
};
return await updater(store);
});
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await invokeAgent(
{
message: "global session",
agentId: "ops",
idempotencyKey: "global-session-agent-id",
},
{ reqId: "global-session-agent-id" },
);
await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled());
const call = mocks.agentCommand.mock.calls.at(-1)?.[0] as {
agentId?: string;
sessionKey?: string;
};
expect(call?.agentId).toBeUndefined();
expect(call?.sessionKey).toBe("global");
});
it("dispatches async gateway agent task creation through the detached task runtime seam", async () => {
await withTempDir({ prefix: "openclaw-gateway-agent-seam-" }, async (root) => {
process.env.OPENCLAW_STATE_DIR = root;

View File

@@ -500,12 +500,15 @@ export const agentHandlers: GatewayRequestHandlers = {
);
return;
}
const requestedSessionId = normalizeOptionalString(request.sessionId);
let requestedSessionKey =
requestedSessionKeyRaw ??
resolveExplicitAgentSessionKey({
cfg,
agentId,
});
(!requestedSessionId
? resolveExplicitAgentSessionKey({
cfg,
agentId,
})
: undefined);
if (agentId && requestedSessionKeyRaw) {
const sessionAgentId = resolveAgentIdFromSessionKey(requestedSessionKeyRaw);
if (sessionAgentId !== agentId) {
@@ -520,7 +523,7 @@ export const agentHandlers: GatewayRequestHandlers = {
return;
}
}
let resolvedSessionId = normalizeOptionalString(request.sessionId);
let resolvedSessionId = requestedSessionId;
let sessionEntry: SessionEntry | undefined;
let bestEffortDeliver = requestedBestEffortDeliver ?? false;
let cfgForAgent: OpenClawConfig | undefined;
@@ -913,12 +916,18 @@ export const agentHandlers: GatewayRequestHandlers = {
}
const resolvedThreadId = explicitThreadId ?? deliveryPlan.resolvedThreadId;
const ingressAgentId =
agentId &&
(!resolvedSessionKey || resolveAgentIdFromSessionKey(resolvedSessionKey) === agentId)
? agentId
: undefined;
dispatchAgentRunFromGateway({
ingressOpts: {
message,
images,
imageOrder,
agentId: ingressAgentId,
provider: providerOverride,
model: modelOverride,
to: resolvedTo,