fix(sessions): reject missing explicit session targets (#122564)

* fix(sessions): reject missing explicit session targets

Punchcard-Session: brisk-willow-summit-k0

* fix(sessions): scope strict resolution to send and history

Punchcard-Session: brisk-willow-summit-k0
This commit is contained in:
Vincent Koc
2026-08-12 20:11:27 +08:00
committed by GitHub
parent c97f3207a0
commit fc14a5a587
9 changed files with 569 additions and 192 deletions
+4 -1
View File
@@ -1164,6 +1164,9 @@ describe("sessions tools", () => {
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as GatewayCall;
calls.push(request);
if (request.method === "sessions.resolve") {
return { key: targetSessionKey };
}
if (request.method === "agent") {
return { runId: "run-scoped", status: "accepted", acceptedAt: 1 };
}
@@ -1193,8 +1196,8 @@ describe("sessions tools", () => {
watched: false,
});
expect(calls.map((call) => call.method)).toEqual([
"sessions.list",
"sessions.resolve",
"sessions.list",
"agent",
]);
} finally {
+75 -1
View File
@@ -146,6 +146,76 @@ describe("sessions_history redaction", () => {
);
});
it("returns not-found for an unknown explicit key without reading history", async () => {
const requests: CallGatewayRequest[] = [];
const sessionKey = "agent:main:missing";
const tool = createSessionsHistoryTool({
config: { tools: { sessions: { visibility: "all" } } },
callGateway: async <T = Record<string, unknown>>(request: CallGatewayRequest): Promise<T> => {
requests.push(request);
if (request.method === "sessions.resolve") {
throw new Error(`No session found: ${sessionKey}`);
}
return { messages: [] } as T;
},
});
const result = await tool.execute("missing-explicit-key", { sessionKey });
expect(result.details).toEqual({
status: "error",
error: `No session found: ${sessionKey}`,
});
expect(requests.map((request) => request.method)).toEqual(["sessions.resolve"]);
});
it("conceals missing explicit keys denied by session visibility", async () => {
const requests: CallGatewayRequest[] = [];
const tool = createSessionsHistoryTool({
agentSessionKey: "agent:main:main",
config: { tools: { sessions: { visibility: "self" } } },
callGateway: async <T = Record<string, unknown>>(request: CallGatewayRequest): Promise<T> => {
requests.push(request);
throw new Error("No session found: agent:main:missing");
},
});
const result = await tool.execute("hidden-missing-key", {
sessionKey: "agent:main:missing",
});
expect(result.details).toMatchObject({ status: "forbidden" });
expect(requests.map((request) => request.method)).toEqual(["sessions.resolve"]);
});
it("returns an empty history for an existing explicit key", async () => {
const requests: CallGatewayRequest[] = [];
const sessionKey = "agent:main:empty";
const tool = createSessionsHistoryTool({
config: { tools: { sessions: { visibility: "all" } } },
callGateway: async <T = Record<string, unknown>>(request: CallGatewayRequest): Promise<T> => {
requests.push(request);
if (request.method === "sessions.resolve") {
return { key: sessionKey } as T;
}
return { messages: [] } as T;
},
});
const result = await tool.execute("existing-empty-key", { sessionKey });
expect(result.details).toMatchObject({
sessionKey,
messages: [],
bytes: 2,
});
expect(requests.map((request) => request.method)).toEqual([
"sessions.resolve",
"sessions.list",
"chat.history",
]);
});
it("redacts recalled session text even when log redaction is disabled", async () => {
// Recalled transcript content is model-visible, so it is always redacted
// even when normal logging redaction is configured off.
@@ -445,7 +515,11 @@ describe("sessions_history redaction", () => {
sessionKey: targetSessionKey,
messages: [{ role: "assistant", content: "visible" }],
});
expect(requests.map((request) => request.method)).toEqual(["sessions.list", "chat.history"]);
expect(requests.map((request) => request.method)).toEqual([
"sessions.resolve",
"sessions.list",
"chat.history",
]);
} finally {
unregister();
}
+14 -5
View File
@@ -33,6 +33,7 @@ import {
import { runWithScopedSessionAccess } from "./scoped-session-access.js";
import {
createSessionVisibilityGuard,
createSessionVisibilityRowChecker,
createAgentToAgentPolicy,
resolveEffectiveSessionToolsVisibility,
resolveSessionReference,
@@ -398,12 +399,25 @@ export function createSessionsHistoryTool(opts?: {
if (!resolvedSession.ok) {
return jsonResult({ status: resolvedSession.status, error: resolvedSession.error });
}
const a2aPolicy = createAgentToAgentPolicy(cfg);
const visibility = resolveEffectiveSessionToolsVisibility({
cfg,
sandboxed: opts?.sandboxed === true,
});
const resolutionAccess = createSessionVisibilityRowChecker({
action: "history",
defaultAgentId: resolveDefaultAgentId(cfg),
requesterSessionKey: effectiveRequesterKey,
visibility,
a2aPolicy,
}).check({ key: resolvedSession.key });
const visibleSession = await resolveVisibleSessionReference({
action: "history",
resolvedSession,
requesterSessionKey: effectiveRequesterKey,
restrictToSpawned,
visibilitySessionKey: sessionKeyParam,
concealResolutionError: resolutionAccess.allowed ? undefined : resolutionAccess.error,
callGateway: gatewayCall,
});
if (!visibleSession.ok) {
@@ -416,11 +430,6 @@ export function createSessionsHistoryTool(opts?: {
const resolvedKey = visibleSession.key;
const displayKey = visibleSession.displayKey;
const a2aPolicy = createAgentToAgentPolicy(cfg);
const visibility = resolveEffectiveSessionToolsVisibility({
cfg,
sandboxed: opts?.sandboxed === true,
});
const visibilityGuard = await createSessionVisibilityGuard({
action: "history",
defaultAgentId: resolveDefaultAgentId(cfg),
+233 -69
View File
@@ -160,7 +160,7 @@ describe("resolved session visibility checks", () => {
await expect(
resolveVisibleSessionReference({
action: "history",
action: "status",
resolvedSession: {
ok: true,
key: sessionKey,
@@ -210,7 +210,7 @@ describe("resolved session visibility checks", () => {
for (const testCase of cases) {
callGatewayMock.mockResolvedValueOnce({ key: testCase.targetSessionKey });
const result = resolveVisibleSessionReference({
action: "history",
action: "status",
resolvedSession: {
ok: true,
key: testCase.targetSessionKey,
@@ -253,7 +253,7 @@ describe("resolved session visibility checks", () => {
await expect(
resolveVisibleSessionReference({
action: "history",
action: "status",
resolvedSession: {
ok: true,
key: "agent:main:subagent:worker-999",
@@ -281,7 +281,7 @@ describe("resolved session visibility checks", () => {
await expect(
resolveVisibleSessionReference({
action: "history",
action: "status",
resolvedSession: {
ok: true,
key: "agent:main:subagent:worker",
@@ -362,71 +362,6 @@ describe("resolveSessionReference", () => {
});
});
it("retries literal current probes without allowMissing for older gateways", async () => {
const unsupportedAllowMissing = () =>
new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "invalid sessions.resolve params: at root: unexpected property 'allowMissing'",
});
callGatewayMock
.mockRejectedValueOnce(unsupportedAllowMissing())
.mockRejectedValueOnce(
new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "No session found: current",
}),
)
.mockRejectedValueOnce(unsupportedAllowMissing())
.mockResolvedValueOnce({ key: "agent:ops:main" });
const result = await resolveSessionReference({
sessionKey: "current",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:subagent:child",
restrictToSpawned: false,
});
expectResolvedSessionReference(result, {
key: "agent:ops:main",
displayKey: "agent:ops:main",
resolvedViaSessionId: true,
});
expect(callGatewayMock).toHaveBeenNthCalledWith(1, {
method: "sessions.resolve",
params: {
key: "current",
spawnedBy: undefined,
allowMissing: true,
},
});
expect(callGatewayMock).toHaveBeenNthCalledWith(2, {
method: "sessions.resolve",
params: {
key: "current",
spawnedBy: undefined,
},
});
expect(callGatewayMock).toHaveBeenNthCalledWith(3, {
method: "sessions.resolve",
params: {
sessionId: "current",
spawnedBy: undefined,
includeGlobal: true,
includeUnknown: true,
allowMissing: true,
},
});
expect(callGatewayMock).toHaveBeenNthCalledWith(4, {
method: "sessions.resolve",
params: {
sessionId: "current",
spawnedBy: undefined,
includeGlobal: true,
includeUnknown: true,
},
});
});
it("does not compatibility-retry unrelated gateway failures", async () => {
callGatewayMock.mockRejectedValueOnce(new Error("gateway timeout")).mockResolvedValueOnce({});
@@ -486,4 +421,233 @@ describe("resolveSessionReference", () => {
});
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("preserves the main alias without probing configured-main bootstrap", async () => {
const result = await resolveSessionReference({
sessionKey: "main",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:dashboard:requester",
restrictToSpawned: false,
});
expectResolvedSessionReference(result, {
key: "main",
displayKey: "main",
resolvedViaSessionId: false,
});
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("defers explicit-key lookup to action-aware visibility resolution", async () => {
const result = await resolveSessionReference({
sessionKey: "agent:main:worker",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:main",
restrictToSpawned: false,
});
expect(result).toEqual({
ok: true,
key: "agent:main:worker",
displayKey: "agent:main:worker",
resolvedViaSessionId: false,
});
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("rejects an unknown explicit session key for history", async () => {
callGatewayMock.mockRejectedValueOnce(
new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "No session found: agent:main:missing",
}),
);
const resolvedSession = await resolveSessionReference({
sessionKey: "agent:main:missing",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:main",
restrictToSpawned: false,
});
if (!resolvedSession.ok) {
throw new Error("Expected session reference");
}
const result = await resolveVisibleSessionReference({
action: "history",
resolvedSession,
requesterSessionKey: "agent:main:main",
restrictToSpawned: false,
visibilitySessionKey: "agent:main:missing",
});
expect(result).toEqual({
ok: false,
status: "error",
error: "No session found: agent:main:missing",
displayKey: "agent:main:missing",
});
expect(callGatewayMock).toHaveBeenCalledWith({
method: "sessions.resolve",
params: {
key: "agent:main:missing",
spawnedBy: undefined,
},
});
});
it("canonicalizes an existing explicit session key", async () => {
callGatewayMock.mockResolvedValueOnce({ key: "agent:ops:main" });
const resolvedSession = await resolveSessionReference({
sessionKey: "agent:OPS:main",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:main",
restrictToSpawned: false,
});
if (!resolvedSession.ok) {
throw new Error("Expected session reference");
}
const result = await resolveVisibleSessionReference({
action: "send",
resolvedSession,
requesterSessionKey: "agent:main:main",
restrictToSpawned: false,
visibilitySessionKey: "agent:OPS:main",
});
expect(result).toEqual({
ok: true,
key: "agent:ops:main",
displayKey: "agent:ops:main",
});
});
it("rejects an explicit key that canonicalizes to an incognito session", async () => {
callGatewayMock.mockResolvedValueOnce({ key: "agent:ops:dashboard:incognito-private" });
const resolvedSession = await resolveSessionReference({
sessionKey: "agent:OPS:dashboard:private",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:main",
restrictToSpawned: false,
});
if (!resolvedSession.ok) {
throw new Error("Expected session reference");
}
const result = await resolveVisibleSessionReference({
action: "history",
resolvedSession,
requesterSessionKey: "agent:main:main",
restrictToSpawned: false,
visibilitySessionKey: "agent:OPS:dashboard:private",
});
expect(result).toEqual({
ok: false,
status: "forbidden",
error: "Session not visible from session tools: agent:OPS:dashboard:private",
displayKey: "agent:ops:dashboard:incognito-private",
});
});
it("conceals a missing explicit key from sandboxed callers", async () => {
callGatewayMock.mockRejectedValueOnce(new Error("No session found: agent:main:missing"));
const resolvedSession = await resolveSessionReference({
sessionKey: "agent:main:missing",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:subagent:child",
restrictToSpawned: true,
});
if (!resolvedSession.ok) {
throw new Error("Expected session reference");
}
const result = await resolveVisibleSessionReference({
action: "history",
resolvedSession,
requesterSessionKey: "agent:main:subagent:child",
restrictToSpawned: true,
visibilitySessionKey: "agent:main:missing",
});
expect(result).toEqual({
ok: false,
status: "forbidden",
error: "Session not visible from this sandboxed agent session: agent:main:missing",
displayKey: "agent:main:missing",
});
});
it("propagates explicit-key gateway failures", async () => {
callGatewayMock.mockRejectedValueOnce(new Error("gateway unavailable"));
const resolvedSession = await resolveSessionReference({
sessionKey: "agent:main:worker",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:main",
restrictToSpawned: false,
});
if (!resolvedSession.ok) {
throw new Error("Expected session reference");
}
const result = await resolveVisibleSessionReference({
action: "send",
resolvedSession,
requesterSessionKey: "agent:main:main",
restrictToSpawned: false,
visibilitySessionKey: "agent:main:worker",
});
expect(result).toEqual({
ok: false,
status: "error",
error: "gateway unavailable",
displayKey: "agent:main:worker",
});
});
it("reports an allowed missing explicit key for deliberate bootstrap", async () => {
callGatewayMock.mockResolvedValueOnce({});
const resolvedSession = await resolveSessionReference({
sessionKey: "agent:main:main",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:dashboard:requester",
restrictToSpawned: false,
});
if (!resolvedSession.ok) {
throw new Error("Expected session reference");
}
const result = await resolveVisibleSessionReference({
action: "send",
resolvedSession,
requesterSessionKey: "agent:main:dashboard:requester",
restrictToSpawned: false,
visibilitySessionKey: "agent:main:main",
allowMissingKey: true,
});
expect(result).toEqual({
ok: true,
key: "agent:main:main",
displayKey: "agent:main:main",
missing: true,
});
expect(callGatewayMock).toHaveBeenCalledWith({
method: "sessions.resolve",
params: {
key: "agent:main:main",
spawnedBy: undefined,
allowMissing: true,
},
});
});
});
+86 -51
View File
@@ -9,7 +9,6 @@ import {
normalizeGatewayClientId,
} from "../../../packages/gateway-protocol/src/client-info.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { GatewayClientRequestError } from "../../gateway/client.js";
import { formatErrorMessage } from "../../infra/errors.js";
import {
createSessionVisibilityChecker,
@@ -164,10 +163,11 @@ type VisibleSessionReferenceResolution =
ok: true;
key: string;
displayKey: string;
missing?: true;
}
| {
ok: false;
status: "forbidden";
status: "error" | "forbidden";
error: string;
displayKey: string;
};
@@ -190,36 +190,35 @@ function buildResolvedSessionReference(params: {
};
}
function buildFailedSessionReference(
error: unknown,
raw: string,
restrictToSpawned: boolean,
): Extract<SessionReferenceResolution, { ok: false }> {
return restrictToSpawned
? {
ok: false,
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${raw}`,
}
: {
ok: false,
status: "error",
error:
formatErrorMessage(error) ||
`Session not found: ${raw} (use the full sessionKey from sessions_list)`,
};
}
async function requestResolvedSessionKey(
params: Record<string, unknown> & { allowMissing?: boolean },
callGateway: GatewayCaller,
): Promise<string | undefined> {
try {
const result = await callGateway<{ key?: unknown }>({
method: "sessions.resolve",
params,
});
return normalizeOptionalString(result?.key);
} catch (error) {
const olderGatewayRejectedProbe =
params.allowMissing === true &&
error instanceof GatewayClientRequestError &&
error.gatewayCode === "INVALID_REQUEST" &&
error.message.includes("invalid sessions.resolve params") &&
error.message.includes("unexpected property 'allowMissing'");
if (!olderGatewayRejectedProbe) {
throw error;
}
// Protocol v4 gateways predating allowMissing reject the additive field.
// Retry without it for mixed-version correctness; remove at the next protocol break.
const legacyParams: Record<string, unknown> = { ...params };
delete legacyParams.allowMissing;
const result = await callGateway<{ key?: unknown }>({
method: "sessions.resolve",
params: legacyParams,
});
return normalizeOptionalString(result?.key);
}
const result = await callGateway<{ key?: unknown }>({
method: "sessions.resolve",
params,
});
return normalizeOptionalString(result?.key);
}
function buildSessionResolveQuery(params: {
@@ -310,20 +309,7 @@ export async function resolveSessionReference(params: {
}
return buildReference(key, true);
} catch (error) {
if (params.restrictToSpawned) {
return {
ok: false,
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${raw}`,
};
}
return {
ok: false,
status: "error",
error:
formatErrorMessage(error) ||
`Session not found: ${raw} (use the full sessionKey from sessions_list)`,
};
return buildFailedSessionReference(error, raw, params.restrictToSpawned);
}
}
@@ -333,12 +319,7 @@ export async function resolveSessionReference(params: {
mainKey: params.mainKey,
requesterInternalKey: params.requesterInternalKey,
});
const displayKey = resolveDisplaySessionKey({
key: resolvedKey,
alias: params.alias,
mainKey: params.mainKey,
});
return { ok: true, key: resolvedKey, displayKey, resolvedViaSessionId: false };
return buildReference(resolvedKey, false);
}
export async function resolveVisibleSessionReference(params: {
@@ -347,10 +328,13 @@ export async function resolveVisibleSessionReference(params: {
requesterSessionKey: string;
restrictToSpawned: boolean;
visibilitySessionKey: string;
allowMissingKey?: boolean;
concealResolutionError?: string;
callGateway?: GatewayCaller;
}): Promise<VisibleSessionReferenceResolution> {
const resolvedKey = params.resolvedSession.key;
const displayKey = params.resolvedSession.displayKey;
let resolvedKey = params.resolvedSession.key;
let displayKey = params.resolvedSession.displayKey;
let missing = false;
// Cross-session tools persist their results into the caller transcript; an
// incognito target must remain unreachable even from an incognito requester.
if (isIncognitoSessionKey(resolvedKey)) {
@@ -361,6 +345,57 @@ export async function resolveVisibleSessionReference(params: {
displayKey,
};
}
const input = params.visibilitySessionKey.trim();
const isExplicitKey =
!params.resolvedSession.resolvedViaSessionId &&
input !== "current" &&
input !== "main" &&
input !== "global" &&
input !== "unknown" &&
!shouldResolveSessionIdInput(input);
if (isExplicitKey && (params.action === "history" || params.action === "send")) {
try {
const key = await requestResolvedSessionKey(
buildSessionResolveQuery({
input: resolvedKey,
kind: "key",
requesterInternalKey: params.requesterSessionKey,
restrictToSpawned: params.restrictToSpawned,
allowMissing: params.allowMissingKey,
}),
params.callGateway ?? callAgentToolGatewayRequest,
);
if (key) {
resolvedKey = key;
displayKey = key;
} else if (params.allowMissingKey) {
missing = true;
}
} catch (error) {
if (params.concealResolutionError && !params.restrictToSpawned) {
return {
ok: false,
status: "forbidden",
error: params.concealResolutionError,
displayKey,
};
}
const failed = buildFailedSessionReference(
error,
params.visibilitySessionKey,
params.restrictToSpawned,
);
return { ...failed, displayKey };
}
}
if (isIncognitoSessionKey(resolvedKey)) {
return {
ok: false,
status: "forbidden",
error: `Session not visible from session tools: ${params.visibilitySessionKey}`,
displayKey,
};
}
const shouldVerifySpawnedVisibility =
params.restrictToSpawned &&
!params.resolvedSession.resolvedViaSessionId &&
@@ -389,5 +424,5 @@ export async function resolveVisibleSessionReference(params: {
displayKey,
};
}
return { ok: true, key: resolvedKey, displayKey };
return { ok: true, key: resolvedKey, displayKey, ...(missing ? { missing: true } : {}) };
}
+55 -57
View File
@@ -69,6 +69,7 @@ import {
import { runWithScopedSessionAccess } from "./scoped-session-access.js";
import {
createSessionVisibilityGuard,
createSessionVisibilityRowChecker,
createAgentToAgentPolicy,
resolveEffectiveSessionToolsVisibility,
resolveSessionReference,
@@ -205,57 +206,39 @@ function isConfiguredAgentMainSessionKey(params: {
);
}
async function ensureConfiguredAgentMainSession(params: {
async function createConfiguredAgentMainSession(params: {
cfg: OpenClawConfig;
callGateway: GatewayCaller;
sessionKey: string;
mainKey: string;
requesterSessionKey?: string;
useTrustedInProcessCreation: boolean;
}): Promise<{ ok: true } | { ok: false; error: string }> {
if (
!isConfiguredAgentMainSessionKey({
cfg: params.cfg,
sessionKey: params.sessionKey,
mainKey: params.mainKey,
})
) {
return { ok: true };
}
try {
await params.callGateway({
method: "sessions.resolve",
params: { key: params.sessionKey },
timeoutMs: 10_000,
});
return { ok: true };
} catch {
try {
const createParams = {
key: params.sessionKey,
agentId: resolveAgentIdFromSessionKey(params.sessionKey, resolveDefaultAgentId(params.cfg)),
};
if (
params.useTrustedInProcessCreation &&
params.requesterSessionKey &&
hasInProcessGatewayToolContext()
) {
await callInProcessGatewayToolWithCreation("sessions.create", createParams, {
via: "internal",
actor: { type: "agent", id: params.requesterSessionKey },
});
} else {
await params.callGateway({
method: "sessions.create",
params: createParams,
timeoutMs: 10_000,
});
}
return { ok: true };
} catch (err) {
return { ok: false, error: formatErrorMessage(err) };
const createParams = {
key: params.sessionKey,
agentId: resolveAgentIdFromSessionKey(params.sessionKey, resolveDefaultAgentId(params.cfg)),
};
if (
params.useTrustedInProcessCreation &&
params.requesterSessionKey &&
hasInProcessGatewayToolContext()
) {
// sessions.create serializes keyed creation and adopts an existing row,
// so concurrent first sends can safely race after the missing resolution.
await callInProcessGatewayToolWithCreation("sessions.create", createParams, {
via: "internal",
actor: { type: "agent", id: params.requesterSessionKey },
});
} else {
await params.callGateway({
method: "sessions.create",
params: createParams,
timeoutMs: 10_000,
});
}
return { ok: true };
} catch (err) {
return { ok: false, error: formatErrorMessage(err) };
}
}
@@ -591,6 +574,11 @@ export function createSessionsSendTool(opts?: {
error: "Either sessionKey or label is required",
});
}
const allowMissingKey = isConfiguredAgentMainSessionKey({
cfg,
sessionKey,
mainKey,
});
const resolvedSession = await resolveSessionReference({
sessionKey,
alias,
@@ -606,12 +594,21 @@ export function createSessionsSendTool(opts?: {
error: resolvedSession.error,
});
}
const resolutionAccess = createSessionVisibilityRowChecker({
action: "send",
defaultAgentId: resolveDefaultAgentId(cfg),
requesterSessionKey: effectiveRequesterKey,
visibility: sessionVisibility,
a2aPolicy,
}).check({ key: resolvedSession.key });
const visibleSession = await resolveVisibleSessionReference({
action: "send",
resolvedSession,
requesterSessionKey: effectiveRequesterKey,
restrictToSpawned,
visibilitySessionKey: sessionKey,
allowMissingKey,
concealResolutionError: resolutionAccess.allowed ? undefined : resolutionAccess.error,
callGateway: gatewayCall,
});
const unresolvedDisplayKey = sessionKey;
@@ -772,21 +769,22 @@ export function createSessionsSendTool(opts?: {
...(opts?.signal ? { signal: opts.signal } : {}),
targetSessionKey: resolvedKey,
run: async () => {
const ensuredSession = await ensureConfiguredAgentMainSession({
cfg,
callGateway: gatewayCall,
sessionKey: resolvedKey,
mainKey,
requesterSessionKey,
useTrustedInProcessCreation: opts?.callGateway === undefined,
});
if (!ensuredSession.ok) {
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: ensuredSession.error,
sessionKey: displayKey,
if (visibleSession.missing) {
const createdSession = await createConfiguredAgentMainSession({
cfg,
callGateway: gatewayCall,
sessionKey: resolvedKey,
requesterSessionKey,
useTrustedInProcessCreation: opts?.callGateway === undefined,
});
if (!createdSession.ok) {
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: createdSession.error,
sessionKey: displayKey,
});
}
}
const requesterChannel = opts?.agentChannel;
+35 -7
View File
@@ -838,6 +838,31 @@ describe("sessions_send gating", () => {
expect(requireGatewayRequest().method).toBe("sessions.resolve");
});
it("conceals missing explicit keys denied by session visibility", async () => {
callGatewayMock.mockRejectedValueOnce(new Error("No session found: agent:main:missing"));
const tool = createSessionsSendTool({
agentSessionKey: MAIN_AGENT_SESSION_KEY,
callGateway: callGatewayMock,
config: {
session: { scope: "per-sender", mainKey: "main" },
tools: {
agentToAgent: { enabled: false },
sessions: { visibility: "self" },
},
} as never,
});
const result = await tool.execute("call-hidden-missing-key", {
sessionKey: "agent:main:missing",
message: "hi",
timeoutSeconds: 0,
});
expect(requireDetails(result).status).toBe("forbidden");
expect(callGatewayMock).toHaveBeenCalledTimes(1);
expect(requireGatewayRequest().method).toBe("sessions.resolve");
});
it("prefers sessionKey over a redundant label", async () => {
const tool = createMainSessionsSendTool();
@@ -989,8 +1014,9 @@ describe("sessions_send gating", () => {
timeoutSeconds: 0,
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
expect(requireGatewayRequest().method).toBe("sessions.list");
expect(callGatewayMock).toHaveBeenCalledTimes(2);
expect(requireGatewayRequest().method).toBe("sessions.resolve");
expect(requireGatewayRequest(1).method).toBe("sessions.list");
expect(requireDetails(result).status).toBe("forbidden");
});
@@ -1017,7 +1043,8 @@ describe("sessions_send gating", () => {
expect((result.details as { error?: string } | undefined)?.error ?? "").toContain(
"cannot target a thread session",
);
expect(callGatewayMock).not.toHaveBeenCalled();
expect(callGatewayMock).toHaveBeenCalledTimes(1);
expect(requireGatewayRequest().method).toBe("sessions.resolve");
});
it("rejects Telegram topic session targets before dispatching an agent run", async () => {
@@ -1056,7 +1083,8 @@ describe("sessions_send gating", () => {
expect((result.details as { error?: string } | undefined)?.error ?? "").toContain(
"cannot target a thread session",
);
expect(callGatewayMock).not.toHaveBeenCalled();
expect(callGatewayMock).toHaveBeenCalledTimes(1);
expect(requireGatewayRequest().method).toBe("sessions.resolve");
});
it("rejects label targets that resolve to canonical thread sessions", async () => {
@@ -1083,8 +1111,9 @@ describe("sessions_send gating", () => {
expect((result.details as { error?: string } | undefined)?.error ?? "").toContain(
"cannot target a thread session",
);
expect(callGatewayMock).toHaveBeenCalledTimes(1);
expect(callGatewayMock).toHaveBeenCalledTimes(2);
expect(requireGatewayRequest().method).toBe("sessions.resolve");
expect(requireGatewayRequest(1).method).toBe("sessions.resolve");
});
it("does not disclose a resolved thread session key from a sessionId target", async () => {
@@ -1653,8 +1682,7 @@ describe("sessions_send agent-main materialization provenance", () => {
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
if (request.method === "sessions.resolve") {
// Unmaterialized agent main: the probe fails, forcing creation.
throw new Error("unknown session: agent:main:main");
return {};
}
if (request.method === "sessions.create") {
throw new Error("plain sessions.create must not be used for trusted materialization");
+55 -1
View File
@@ -3,7 +3,18 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type Mock,
} from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { testing as agentStepTesting } from "../agents/tools/agent-step.test-support.js";
import { runSessionsSendA2AFlow } from "../agents/tools/sessions-send-tool.a2a.js";
import {
@@ -33,6 +44,7 @@ let server: Awaited<ReturnType<typeof startTestGatewayServer>>;
let gatewayPort: number;
const gatewayToken = "test-gateway-token-1234567890";
let envSnapshot: ReturnType<typeof captureEnv>;
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
type SessionSendTool = ReturnType<typeof createOpenClawTools>[number];
const SESSION_SEND_E2E_TIMEOUT_MS = 10_000;
@@ -153,6 +165,48 @@ afterAll(async () => {
});
describe("sessions_send gateway loopback", () => {
it("rejects a missing explicit key without creating or running a session", async () => {
const dir = tempDirs.make("openclaw-sessions-send-missing-");
const missingKey = "agent:main:missing";
const spy = agentCommandMock as unknown as Mock<(opts: unknown) => Promise<void>>;
testState.sessionStorePath = path.join(dir, "sessions.json");
try {
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
spy.mockClear();
const tool = createOpenClawTools({
agentSessionKey: "agent:main:main",
config: { tools: { sessions: { visibility: "all" } } },
}).find((candidate) => candidate.name === "sessions_send");
if (!tool) {
throw new Error("missing sessions_send tool");
}
const result = await tool.execute("call-missing-key", {
sessionKey: missingKey,
message: "ping",
timeoutSeconds: 0,
});
expect(result.details).toMatchObject({
status: "error",
error: `No session found: ${missingKey}`,
});
expect(spy).not.toHaveBeenCalled();
expect(
loadSessionEntry({ sessionKey: missingKey, storePath: testState.sessionStorePath }),
).toBe(undefined);
} finally {
testState.sessionStorePath = undefined;
}
});
it("returns reply when lifecycle ends before agent.wait", async () => {
const spy = agentCommandMock as unknown as Mock<(opts: unknown) => Promise<void>>;
spy.mockImplementation(async (opts: unknown) =>
@@ -114,6 +114,18 @@ test("sessions.resolve can probe a missing selector without returning an RPC err
expect(resolved.payload).toEqual({ ok: false });
});
test("sessions.resolve rejects a missing key by default", async () => {
await createSessionStoreDir();
const { ws } = await openClient();
const resolved = await rpcReq(ws, "sessions.resolve", {
key: "agent:main:missing",
});
expect(resolved.ok).toBe(false);
expect(resolved.error?.message).toBe("No session found: agent:main:missing");
});
test("sessions.resolve returns short-id ambiguity as a protocol-success result", async () => {
await createSessionStoreDir();
await writeSessionStore({