mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
fix: honor explicit auth profile selection (#62744)
* Auth: fix native model profile selection Fix native `/model ...@profile` targeting so profile selections persist onto the intended session, and preserve explicit session auth-profile overrides even when stored auth order prefers another profile. Update the reply/session regressions to use placeholder example.test profile ids. Regeneration-Prompt: | Native `/model ...@profile` commands in chat were acknowledging the requested auth profile but later runs still used another account. Fix the target-session handling so native slash commands mutate the real chat session rather than a slash-session surrogate, and keep explicit session auth-profile overrides from being cleared just because stored provider order prefers another profile. Update the tests to cover the target-session path and the override-preservation behavior, and use placeholder profile ids instead of real email addresses in test fixtures. * Auth: honor explicit user-locked profiles in runner Allow an explicit user-selected auth profile to run even when per-agent auth-state order excludes it. Keep auth-state order for automatic selection and failover, and add an embedded runner regression that seeds stored order with one profile while verifying a different user-locked profile still executes. Regeneration-Prompt: | The remaining bug after fixing native `/model ...@profile` persistence was in the embedded runner itself. A user could explicitly select a valid auth profile for a provider, but the run still failed if per-agent auth-state order did not include that profile. Preserve the intended semantics by validating user-locked profiles directly for provider match and credential eligibility, then using them without requiring membership in resolved auto-order. Add a regression in the embedded auth-profile rotation suite where stored order only includes one OpenAI profile but a different user-locked profile is chosen and must still be used. * Changelog: note explicit auth profile selection fix Add the required Unreleased changelog line for the explicit auth-profile selection and runner honor fix in this PR. Regeneration-Prompt: | The PR needed a mandatory CHANGELOG.md entry under Unreleased/Fixes. Add a concise user-facing line describing that native `/model ...@profile` selections now persist on the target session and explicit user-locked OpenAI Codex auth profiles are honored even when per-agent auth order excludes them, and include the PR number plus thanks attribution for the PR author.
This commit is contained in:
@@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Network/fetch guard: drop request bodies and body-describing headers on cross-origin `307` and `308` redirects by default, so attacker-controlled redirect hops cannot receive secret-bearing POST payloads from SSRF-guarded fetch flows unless a caller explicitly opts in. (#62357) Thanks @pgondhi987.
|
||||
- Media/base64 decode guards: enforce byte limits before decoding missed base64-backed Teams, Signal, QQ Bot, and image-tool payloads so oversized inbound media and data URLs no longer bypass pre-decode size checks. (#62007) Thanks @eleqtrizit.
|
||||
- Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678)
|
||||
- Auth/OpenAI Codex OAuth: keep native `/model ...@profile` selections on the target session and honor explicit user-locked auth profiles even when per-agent auth order excludes them. (#62744) Thanks @jalehman.
|
||||
- Browser/remote CDP: retry the DevTools websocket once after remote browser restarts so healthy remote browser profiles do not fail availability checks during CDP warm-up. (#57397) Thanks @ThanhNguyxn07.
|
||||
- Browser/SSRF: treat main-frame `document` redirect hops as navigations even when Playwright does not flag them as `isNavigationRequest()`, so strict private-network blocking still stops forbidden redirect pivots before the browser reaches the internal target. (#62355) Thanks @pgondhi987.
|
||||
- Gateway/node pairing: require a fresh pairing request when a previously paired node reconnects with additional declared commands, and keep the live session pinned to the earlier approved command set until the upgrade is approved. (#62658) Thanks @eleqtrizit.
|
||||
|
||||
@@ -24,6 +24,32 @@ async function writeAuthStore(agentDir: string) {
|
||||
await fs.writeFile(authPath, JSON.stringify(payload), "utf-8");
|
||||
}
|
||||
|
||||
async function writeAuthStoreWithProfiles(
|
||||
agentDir: string,
|
||||
params: {
|
||||
profiles: Record<string, { type: "api_key"; provider: string; key: string }>;
|
||||
order?: Record<string, string[]>;
|
||||
},
|
||||
) {
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
await fs.writeFile(
|
||||
authPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: params.profiles,
|
||||
...(params.order ? { order: params.order } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
const TEST_PRIMARY_PROFILE_ID = "openai-codex:primary@example.test";
|
||||
const TEST_SECONDARY_PROFILE_ID = "openai-codex:secondary@example.test";
|
||||
|
||||
describe("resolveSessionAuthProfileOverride", () => {
|
||||
it("returns early when no auth sources exist", async () => {
|
||||
await withStateDirEnv("openclaw-auth-", async ({ stateDir }) => {
|
||||
@@ -83,4 +109,51 @@ describe("resolveSessionAuthProfileOverride", () => {
|
||||
expect(sessionEntry.authProfileOverride).toBe("zai:work");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit user override when stored order prefers another profile", async () => {
|
||||
await withStateDirEnv("openclaw-auth-", async ({ stateDir }) => {
|
||||
const agentDir = path.join(stateDir, "agent");
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await writeAuthStoreWithProfiles(agentDir, {
|
||||
profiles: {
|
||||
[TEST_PRIMARY_PROFILE_ID]: {
|
||||
type: "api_key",
|
||||
provider: "openai-codex",
|
||||
key: "sk-josh",
|
||||
},
|
||||
[TEST_SECONDARY_PROFILE_ID]: {
|
||||
type: "api_key",
|
||||
provider: "openai-codex",
|
||||
key: "sk-claude",
|
||||
},
|
||||
},
|
||||
order: {
|
||||
"openai-codex": [TEST_PRIMARY_PROFILE_ID],
|
||||
},
|
||||
});
|
||||
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: Date.now(),
|
||||
authProfileOverride: TEST_SECONDARY_PROFILE_ID,
|
||||
authProfileOverrideSource: "user",
|
||||
};
|
||||
const sessionStore = { "agent:main:main": sessionEntry };
|
||||
|
||||
const resolved = await resolveSessionAuthProfileOverride({
|
||||
cfg: {} as OpenClawConfig,
|
||||
provider: "openai-codex",
|
||||
agentDir,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: undefined,
|
||||
isNewSession: false,
|
||||
});
|
||||
|
||||
expect(resolved).toBe(TEST_SECONDARY_PROFILE_ID);
|
||||
expect(sessionEntry.authProfileOverride).toBe(TEST_SECONDARY_PROFILE_ID);
|
||||
expect(sessionEntry.authProfileOverrideSource).toBe("user");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,6 +85,13 @@ export async function resolveSessionAuthProfileOverride(params: {
|
||||
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||
const order = resolveAuthProfileOrder({ cfg, store, provider });
|
||||
let current = sessionEntry.authProfileOverride?.trim();
|
||||
const source =
|
||||
sessionEntry.authProfileOverrideSource ??
|
||||
(typeof sessionEntry.authProfileOverrideCompactionCount === "number"
|
||||
? "auto"
|
||||
: current
|
||||
? "user"
|
||||
: undefined);
|
||||
|
||||
if (current && !store.profiles[current]) {
|
||||
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
|
||||
@@ -96,7 +103,8 @@ export async function resolveSessionAuthProfileOverride(params: {
|
||||
current = undefined;
|
||||
}
|
||||
|
||||
if (current && order.length > 0 && !order.includes(current)) {
|
||||
// Explicit user picks should survive provider rotation order changes.
|
||||
if (current && order.length > 0 && !order.includes(current) && source !== "user") {
|
||||
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
|
||||
current = undefined;
|
||||
}
|
||||
@@ -126,14 +134,6 @@ export async function resolveSessionAuthProfileOverride(params: {
|
||||
typeof sessionEntry.authProfileOverrideCompactionCount === "number"
|
||||
? sessionEntry.authProfileOverrideCompactionCount
|
||||
: compactionCount;
|
||||
|
||||
const source =
|
||||
sessionEntry.authProfileOverrideSource ??
|
||||
(typeof sessionEntry.authProfileOverrideCompactionCount === "number"
|
||||
? "auto"
|
||||
: current
|
||||
? "user"
|
||||
: undefined);
|
||||
if (source === "user" && current && !isNewSession) {
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -327,6 +327,7 @@ const writeAuthStore = async (
|
||||
agentDir: string,
|
||||
opts?: {
|
||||
includeAnthropic?: boolean;
|
||||
order?: Record<string, string[]>;
|
||||
usageStats?: Record<
|
||||
string,
|
||||
{
|
||||
@@ -353,6 +354,7 @@ const writeAuthStore = async (
|
||||
};
|
||||
const statePayload = {
|
||||
version: 1,
|
||||
...(opts?.order ? { order: opts.order } : {}),
|
||||
usageStats:
|
||||
opts?.usageStats ??
|
||||
({
|
||||
@@ -1058,6 +1060,39 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
expect(usageStats["openai:p2"]?.lastUsed).toBe(2);
|
||||
});
|
||||
|
||||
it("honors user-pinned profiles even when stored order excludes them", async () => {
|
||||
await withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
|
||||
await writeAuthStore(agentDir, {
|
||||
order: {
|
||||
openai: ["openai:p1"],
|
||||
},
|
||||
});
|
||||
mockSingleSuccessfulAttempt();
|
||||
|
||||
await runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:user-order-excluded",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig(),
|
||||
prompt: "hello",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
authProfileId: "openai:p2",
|
||||
authProfileIdSource: "user",
|
||||
timeoutMs: 5_000,
|
||||
runId: "run:user-order-excluded",
|
||||
});
|
||||
|
||||
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
|
||||
const usageStats = await readUsageStats(agentDir);
|
||||
expect(usageStats["openai:p1"]?.lastUsed).toBe(1);
|
||||
expect(typeof usageStats["openai:p2"]?.lastUsed).toBe("number");
|
||||
expect(usageStats["openai:p2"]?.lastUsed).not.toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores user-locked profile when provider mismatches", async () => {
|
||||
await withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
|
||||
await writeAuthStore(agentDir, { includeAnthropic: true });
|
||||
|
||||
@@ -18,6 +18,7 @@ import { hasConfiguredModelFallbacks } from "../agent-scope.js";
|
||||
import {
|
||||
type AuthProfileFailureReason,
|
||||
markAuthProfileFailure,
|
||||
resolveAuthProfileEligibility,
|
||||
markAuthProfileGood,
|
||||
markAuthProfileUsed,
|
||||
} from "../auth-profiles.js";
|
||||
@@ -294,6 +295,17 @@ export async function runEmbeddedPiAgent(
|
||||
lockedProfileId = undefined;
|
||||
}
|
||||
}
|
||||
if (lockedProfileId) {
|
||||
const eligibility = resolveAuthProfileEligibility({
|
||||
cfg: params.config,
|
||||
store: authStore,
|
||||
provider,
|
||||
profileId: lockedProfileId,
|
||||
});
|
||||
if (!eligibility.eligible) {
|
||||
throw new Error(`Auth profile "${lockedProfileId}" is not configured for ${provider}.`);
|
||||
}
|
||||
}
|
||||
const profileOrder = shouldPreferExplicitConfigApiKeyAuth(params.config, provider)
|
||||
? []
|
||||
: resolveAuthProfileOrder({
|
||||
@@ -302,9 +314,6 @@ export async function runEmbeddedPiAgent(
|
||||
provider,
|
||||
preferredProfile: preferredProfileId,
|
||||
});
|
||||
if (lockedProfileId && !profileOrder.includes(lockedProfileId)) {
|
||||
throw new Error(`Auth profile "${lockedProfileId}" is not configured for ${provider}.`);
|
||||
}
|
||||
const profileCandidates = lockedProfileId
|
||||
? [lockedProfileId]
|
||||
: profileOrder.length > 0
|
||||
|
||||
@@ -17,11 +17,15 @@ import {
|
||||
runGreetingPromptForBareNewOrReset,
|
||||
withTempHome,
|
||||
} from "./reply.triggers.trigger-handling.test-harness.js";
|
||||
import { withFullRuntimeReplyConfig } from "./reply/get-reply-fast-path.js";
|
||||
import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./reply/queue.js";
|
||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
|
||||
type GetReplyFromConfig = typeof import("./reply.js").getReplyFromConfig;
|
||||
|
||||
const TEST_PRIMARY_PROFILE_ID = "openai-codex:primary@example.test";
|
||||
const TEST_SECONDARY_PROFILE_ID = "openai-codex:secondary@example.test";
|
||||
|
||||
vi.mock("./reply/agent-runner.runtime.js", () => ({
|
||||
runReplyAgent: async (params: {
|
||||
commandBody: string;
|
||||
@@ -29,6 +33,8 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({
|
||||
run: {
|
||||
provider: string;
|
||||
model: string;
|
||||
authProfileId?: string;
|
||||
authProfileIdSource?: "auto" | "user";
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
sessionFile: string;
|
||||
@@ -61,6 +67,8 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({
|
||||
prompt: params.commandBody,
|
||||
provider: params.followupRun.run.provider,
|
||||
model: params.followupRun.run.model,
|
||||
authProfileId: params.followupRun.run.authProfileId,
|
||||
authProfileIdSource: params.followupRun.run.authProfileIdSource,
|
||||
sessionId: params.followupRun.run.sessionId,
|
||||
sessionKey: params.followupRun.run.sessionKey,
|
||||
sessionFile: params.followupRun.run.sessionFile,
|
||||
@@ -528,6 +536,7 @@ describe("trigger handling", () => {
|
||||
ChatType: "direct",
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SessionKey: targetSessionKey,
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
@@ -543,6 +552,129 @@ describe("trigger handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("applies native model auth profile overrides to the target session", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = withFullRuntimeReplyConfig({
|
||||
...makeCfg(home),
|
||||
session: { store: join(home, "native-model-auth.sessions.json") },
|
||||
});
|
||||
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
|
||||
runEmbeddedPiAgentMock.mockReset();
|
||||
const storePath = cfg.session?.store;
|
||||
if (!storePath) {
|
||||
throw new Error("missing session store path");
|
||||
}
|
||||
const authDir = join(home, ".openclaw", "agents", "main", "agent");
|
||||
await fs.mkdir(authDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
join(authDir, "auth-profiles.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
[TEST_PRIMARY_PROFILE_ID]: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "oauth-access-token-josh",
|
||||
},
|
||||
[TEST_SECONDARY_PROFILE_ID]: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "oauth-access-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await fs.writeFile(
|
||||
join(authDir, "auth-state.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
order: {
|
||||
"openai-codex": [TEST_PRIMARY_PROFILE_ID],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const slashSessionKey = "telegram:slash:111";
|
||||
const targetSessionKey = MAIN_SESSION_KEY;
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
[targetSessionKey]: {
|
||||
sessionId: "session-target",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: `/model openai-codex/gpt-5.4@${TEST_SECONDARY_PROFILE_ID}`,
|
||||
From: "telegram:111",
|
||||
To: "telegram:111",
|
||||
ChatType: "direct",
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SessionKey: slashSessionKey,
|
||||
CommandSource: "native",
|
||||
CommandTargetSessionKey: targetSessionKey,
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain(`Auth profile set to ${TEST_SECONDARY_PROFILE_ID}`);
|
||||
|
||||
const store = loadSessionStore(storePath);
|
||||
expect(store[targetSessionKey]?.authProfileOverride).toBe(TEST_SECONDARY_PROFILE_ID);
|
||||
expect(store[targetSessionKey]?.authProfileOverrideSource).toBe("user");
|
||||
expect(store[slashSessionKey]).toBeUndefined();
|
||||
|
||||
runEmbeddedPiAgentMock.mockReset();
|
||||
runEmbeddedPiAgentMock.mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
await getReplyFromConfig(
|
||||
{
|
||||
Body: "hi",
|
||||
From: "telegram:111",
|
||||
To: "telegram:111",
|
||||
ChatType: "direct",
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SessionKey: targetSessionKey,
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
|
||||
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.4",
|
||||
authProfileId: TEST_SECONDARY_PROFILE_ID,
|
||||
authProfileIdSource: "user",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles bare session reset, inline commands, and unauthorized inline status", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await runGreetingPromptForBareNewOrReset({ home, body: "/new", getReplyFromConfig });
|
||||
|
||||
@@ -27,6 +27,7 @@ const piEmbeddedMocks = getSharedMocks("openclaw.trigger-handling.pi-embedded-mo
|
||||
compactEmbeddedPiSession: vi.fn(),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveActiveEmbeddedRunSessionId: vi.fn().mockReturnValue(undefined),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
@@ -55,6 +56,8 @@ const installPiEmbeddedMock = () =>
|
||||
runEmbeddedPiAgent: (...args: unknown[]) => piEmbeddedMocks.runEmbeddedPiAgent(...args),
|
||||
queueEmbeddedPiMessage: (...args: unknown[]) => piEmbeddedMocks.queueEmbeddedPiMessage(...args),
|
||||
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
|
||||
resolveActiveEmbeddedRunSessionId: (...args: unknown[]) =>
|
||||
piEmbeddedMocks.resolveActiveEmbeddedRunSessionId(...args),
|
||||
isEmbeddedPiRunActive: (...args: unknown[]) => piEmbeddedMocks.isEmbeddedPiRunActive(...args),
|
||||
isEmbeddedPiRunStreaming: (...args: unknown[]) =>
|
||||
piEmbeddedMocks.isEmbeddedPiRunStreaming(...args),
|
||||
|
||||
@@ -30,6 +30,11 @@ function isSlowReplyTestAllowed(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
}
|
||||
|
||||
function resolveFastSessionKey(ctx: MsgContext): string {
|
||||
const nativeCommandTarget =
|
||||
ctx.CommandSource === "native" ? normalizeOptionalString(ctx.CommandTargetSessionKey) : "";
|
||||
if (nativeCommandTarget) {
|
||||
return nativeCommandTarget;
|
||||
}
|
||||
const existing = normalizeOptionalString(ctx.SessionKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
|
||||
@@ -4,7 +4,11 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { markCompleteReplyConfig, withFastReplyConfig } from "./get-reply-fast-path.js";
|
||||
import {
|
||||
initFastReplySessionState,
|
||||
markCompleteReplyConfig,
|
||||
withFastReplyConfig,
|
||||
} from "./get-reply-fast-path.js";
|
||||
import { loadGetReplyModuleForTest } from "./get-reply.test-loader.js";
|
||||
import "./get-reply.test-runtime-mocks.js";
|
||||
|
||||
@@ -183,4 +187,21 @@ describe("getReplyFromConfig fast test bootstrap", () => {
|
||||
expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(runPreparedReplyMock)).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses native command target session keys during fast bootstrap", () => {
|
||||
const result = initFastReplySessionState({
|
||||
ctx: buildCtx({
|
||||
SessionKey: "telegram:slash:123",
|
||||
CommandSource: "native",
|
||||
CommandTargetSessionKey: "agent:main:main",
|
||||
}),
|
||||
cfg: { session: { store: "/tmp/sessions.json" } } as OpenClawConfig,
|
||||
agentId: "main",
|
||||
commandAuthorized: true,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(result.sessionKey).toBe("agent:main:main");
|
||||
expect(result.sessionCtx.SessionKey).toBe("agent:main:main");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -280,7 +280,6 @@ export async function getReplyFromConfig(
|
||||
triggerBodyNormalized,
|
||||
bodyStripped,
|
||||
} = sessionState;
|
||||
|
||||
if (resetTriggered && normalizeOptionalString(bodyStripped)) {
|
||||
const { applyResetModelOverride } = await loadSessionResetModelRuntime();
|
||||
await applyResetModelOverride({
|
||||
|
||||
@@ -79,6 +79,7 @@ async function makeStorePath(prefix: string): Promise<string> {
|
||||
}
|
||||
|
||||
const createStorePath = makeStorePath;
|
||||
const TEST_NATIVE_MODEL_PROFILE_ID = "openai-codex:secondary@example.test";
|
||||
|
||||
async function writeSessionStoreFast(
|
||||
storePath: string,
|
||||
@@ -963,6 +964,53 @@ describe("initSessionState RawBody", () => {
|
||||
expect(result.sessionId).not.toBe(existingSessionId);
|
||||
});
|
||||
|
||||
it("prefers native command target sessions over bound slash sessions", async () => {
|
||||
const storePath = await createStorePath("native-command-target-session-");
|
||||
const boundSlashSessionKey = "slack:slash:123";
|
||||
const targetSessionKey = "agent:main:main";
|
||||
const cfg = {
|
||||
session: { store: storePath },
|
||||
} as OpenClawConfig;
|
||||
|
||||
setMinimalCurrentConversationBindingRegistryForTests();
|
||||
registerCurrentConversationBindingAdapterForTest({
|
||||
channel: "slack",
|
||||
accountId: "default",
|
||||
});
|
||||
await getSessionBindingService().bind({
|
||||
targetSessionKey: boundSlashSessionKey,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "slack",
|
||||
accountId: "default",
|
||||
conversationId: "channel:ops",
|
||||
},
|
||||
placement: "current",
|
||||
});
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: `/model openai-codex/gpt-5.4@${TEST_NATIVE_MODEL_PROFILE_ID}`,
|
||||
CommandBody: `/model openai-codex/gpt-5.4@${TEST_NATIVE_MODEL_PROFILE_ID}`,
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
AccountId: "default",
|
||||
SenderId: "U123",
|
||||
From: "slack:U123",
|
||||
To: "channel:ops",
|
||||
OriginatingTo: "channel:ops",
|
||||
SessionKey: boundSlashSessionKey,
|
||||
CommandSource: "native",
|
||||
CommandTargetSessionKey: targetSessionKey,
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.sessionKey).toBe(targetSessionKey);
|
||||
expect(result.sessionCtx.SessionKey).toBe(targetSessionKey);
|
||||
});
|
||||
|
||||
it("uses the default per-agent sessions store when config store is unset", async () => {
|
||||
const root = await makeCaseDir("openclaw-session-store-default-");
|
||||
const stateDir = path.join(root, ".openclaw");
|
||||
|
||||
@@ -224,12 +224,16 @@ export async function initSessionState(params: {
|
||||
ctx.CommandSource === "native"
|
||||
? normalizeOptionalString(ctx.CommandTargetSessionKey)
|
||||
: undefined;
|
||||
// Native slash/menu commands can arrive on a transport-specific "slash session"
|
||||
// while explicitly targeting an existing chat session. Honor that explicit target
|
||||
// before any binding lookup so command-side mutations land on the intended session.
|
||||
const targetSessionKey =
|
||||
commandTargetSessionKey ??
|
||||
resolveBoundConversationSessionKey({
|
||||
cfg,
|
||||
ctx,
|
||||
bindingContext: conversationBindingContext,
|
||||
}) ?? commandTargetSessionKey;
|
||||
});
|
||||
const sessionCtxForState =
|
||||
targetSessionKey && targetSessionKey !== ctx.SessionKey
|
||||
? { ...ctx, SessionKey: targetSessionKey }
|
||||
@@ -695,16 +699,16 @@ export async function initSessionState(params: {
|
||||
}
|
||||
|
||||
const sessionCtx: TemplateContext = {
|
||||
...ctx,
|
||||
...sessionCtxForState,
|
||||
// Keep BodyStripped aligned with Body (best default for agent prompts).
|
||||
// RawBody is reserved for command/directive parsing and may omit context.
|
||||
BodyStripped: normalizeInboundTextNewlines(
|
||||
bodyStripped ??
|
||||
ctx.BodyForAgent ??
|
||||
ctx.Body ??
|
||||
ctx.CommandBody ??
|
||||
ctx.RawBody ??
|
||||
ctx.BodyForCommands ??
|
||||
sessionCtxForState.BodyForAgent ??
|
||||
sessionCtxForState.Body ??
|
||||
sessionCtxForState.CommandBody ??
|
||||
sessionCtxForState.RawBody ??
|
||||
sessionCtxForState.BodyForCommands ??
|
||||
"",
|
||||
),
|
||||
SessionId: sessionId,
|
||||
|
||||
Reference in New Issue
Block a user