From ab000398be08d31c625836b00d5f409a1324c62f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 11:09:06 +0000 Subject: [PATCH] fix: resolve session ids in session tools --- CHANGELOG.md | 1 + docs/concepts/session-tool.md | 5 +- docs/tools/index.md | 6 +- .../clawdbot-tools.session-status.test.ts | 112 +++++++++- src/agents/clawdbot-tools.sessions.test.ts | 100 +++++++++ src/agents/tools/session-status-tool.ts | 86 +++++++- src/agents/tools/sessions-helpers.ts | 192 +++++++++++++++++- src/agents/tools/sessions-history-tool.ts | 70 +++---- src/agents/tools/sessions-list-tool.ts | 34 +--- src/agents/tools/sessions-send-tool.ts | 70 +++---- src/gateway/protocol/schema/sessions.ts | 1 + ...ions.gateway-server-sessions-a.e2e.test.ts | 6 + src/gateway/sessions-resolve.ts | 51 ++++- 13 files changed, 604 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f495a1f17..4048290ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.clawd.bot - TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg. ### Fixes +- Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518) - Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete. - Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest. - Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu. diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index 50281357b..730f827fc 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -56,19 +56,20 @@ Row shape (JSON): Fetch transcript for one session. Parameters: -- `sessionKey` (required) +- `sessionKey` (required; accepts session key or `sessionId` from `sessions_list`) - `limit?: number` max messages (server clamps) - `includeTools?: boolean` (default false) Behavior: - `includeTools=false` filters `role: "toolResult"` messages. - Returns messages array in the raw transcript format. +- When given a `sessionId`, Clawdbot resolves it to the corresponding session key (missing ids error). ## sessions_send Send a message into another session. Parameters: -- `sessionKey` (required) +- `sessionKey` (required; accepts session key or `sessionId` from `sessions_list`) - `message` (required) - `timeoutSeconds?: number` (default >0; 0 = fire-and-forget) diff --git a/docs/tools/index.md b/docs/tools/index.md index 88e552afa..effa3c4ce 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -379,10 +379,10 @@ List sessions, inspect transcript history, or send to another session. Core parameters: - `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none) -- `sessions_history`: `sessionKey`, `limit?`, `includeTools?` -- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget) +- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?` +- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget) - `sessions_spawn`: `task`, `label?`, `agentId?`, `model?`, `runTimeoutSeconds?`, `cleanup?` -- `session_status`: `sessionKey?` (default current), `model?` (`default` clears override) +- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override) Notes: - `main` is the canonical direct-chat key; global/unknown are hidden. diff --git a/src/agents/clawdbot-tools.session-status.test.ts b/src/agents/clawdbot-tools.session-status.test.ts index 94ee3e8b4..fd3b75914 100644 --- a/src/agents/clawdbot-tools.session-status.test.ts +++ b/src/agents/clawdbot-tools.session-status.test.ts @@ -17,7 +17,8 @@ vi.mock("../config/sessions.js", async (importOriginal) => { updateSessionStoreMock(storePath, store); return store; }, - resolveStorePath: () => "/tmp/sessions.json", + resolveStorePath: (_store: string | undefined, opts?: { agentId?: string }) => + opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json", }; }); @@ -117,11 +118,118 @@ describe("session_status tool", () => { if (!tool) throw new Error("missing session_status tool"); await expect(tool.execute("call2", { sessionKey: "nope" })).rejects.toThrow( - "Unknown sessionKey", + "Unknown sessionId", ); expect(updateSessionStoreMock).not.toHaveBeenCalled(); }); + it("resolves sessionId inputs", async () => { + loadSessionStoreMock.mockReset(); + updateSessionStoreMock.mockReset(); + const sessionId = "sess-main"; + loadSessionStoreMock.mockReturnValue({ + "agent:main:main": { + sessionId, + updatedAt: 10, + }, + }); + + const tool = createClawdbotTools({ agentSessionKey: "main" }).find( + (candidate) => candidate.name === "session_status", + ); + expect(tool).toBeDefined(); + if (!tool) throw new Error("missing session_status tool"); + + const result = await tool.execute("call3", { sessionKey: sessionId }); + const details = result.details as { ok?: boolean; sessionKey?: string }; + expect(details.ok).toBe(true); + expect(details.sessionKey).toBe("agent:main:main"); + }); + + it("uses non-standard session keys without sessionId resolution", async () => { + loadSessionStoreMock.mockReset(); + updateSessionStoreMock.mockReset(); + loadSessionStoreMock.mockReturnValue({ + "temp:slug-generator": { + sessionId: "sess-temp", + updatedAt: 10, + }, + }); + + const tool = createClawdbotTools({ agentSessionKey: "main" }).find( + (candidate) => candidate.name === "session_status", + ); + expect(tool).toBeDefined(); + if (!tool) throw new Error("missing session_status tool"); + + const result = await tool.execute("call4", { sessionKey: "temp:slug-generator" }); + const details = result.details as { ok?: boolean; sessionKey?: string }; + expect(details.ok).toBe(true); + expect(details.sessionKey).toBe("temp:slug-generator"); + }); + + it("blocks cross-agent session_status without agent-to-agent access", async () => { + loadSessionStoreMock.mockReset(); + updateSessionStoreMock.mockReset(); + loadSessionStoreMock.mockReturnValue({ + "agent:other:main": { + sessionId: "s2", + updatedAt: 10, + }, + }); + + const tool = createClawdbotTools({ agentSessionKey: "agent:main:main" }).find( + (candidate) => candidate.name === "session_status", + ); + expect(tool).toBeDefined(); + if (!tool) throw new Error("missing session_status tool"); + + await expect(tool.execute("call5", { sessionKey: "agent:other:main" })).rejects.toThrow( + "Agent-to-agent status is disabled", + ); + }); + + it("scopes bare session keys to the requester agent", async () => { + loadSessionStoreMock.mockReset(); + updateSessionStoreMock.mockReset(); + const stores = new Map>([ + [ + "/tmp/main/sessions.json", + { + "agent:main:main": { sessionId: "s-main", updatedAt: 10 }, + }, + ], + [ + "/tmp/support/sessions.json", + { + main: { sessionId: "s-support", updatedAt: 20 }, + }, + ], + ]); + loadSessionStoreMock.mockImplementation((storePath: string) => { + return stores.get(storePath) ?? {}; + }); + updateSessionStoreMock.mockImplementation( + (_storePath: string, store: Record) => { + // Keep map in sync for resolveSessionEntry fallbacks if needed. + if (_storePath) { + stores.set(_storePath, store); + } + }, + ); + + const tool = createClawdbotTools({ agentSessionKey: "agent:support:main" }).find( + (candidate) => candidate.name === "session_status", + ); + expect(tool).toBeDefined(); + if (!tool) throw new Error("missing session_status tool"); + + const result = await tool.execute("call6", { sessionKey: "main" }); + const details = result.details as { ok?: boolean; sessionKey?: string }; + expect(details.ok).toBe(true); + expect(details.sessionKey).toBe("main"); + }); + it("resets per-session model override via model=default", async () => { loadSessionStoreMock.mockReset(); updateSessionStoreMock.mockReset(); diff --git a/src/agents/clawdbot-tools.sessions.test.ts b/src/agents/clawdbot-tools.sessions.test.ts index c7964b75b..434c6d09f 100644 --- a/src/agents/clawdbot-tools.sessions.test.ts +++ b/src/agents/clawdbot-tools.sessions.test.ts @@ -172,6 +172,62 @@ describe("sessions tools", () => { expect(withToolsDetails.messages).toHaveLength(2); }); + it("sessions_history resolves sessionId inputs", async () => { + callGatewayMock.mockReset(); + const sessionId = "sess-group"; + const targetKey = "agent:main:discord:channel:1457165743010611293"; + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: Record }; + if (request.method === "sessions.resolve") { + return { + key: targetKey, + }; + } + if (request.method === "chat.history") { + return { + messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }], + }; + } + return {}; + }); + + const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_history"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("missing sessions_history tool"); + + const result = await tool.execute("call5", { sessionKey: sessionId }); + const details = result.details as { messages?: unknown[] }; + expect(details.messages).toHaveLength(1); + const historyCall = callGatewayMock.mock.calls.find( + (call) => (call[0] as { method?: string }).method === "chat.history", + ); + expect(historyCall?.[0]).toMatchObject({ + method: "chat.history", + params: { sessionKey: targetKey }, + }); + }); + + it("sessions_history errors on missing sessionId", async () => { + callGatewayMock.mockReset(); + const sessionId = "aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa"; + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string }; + if (request.method === "sessions.resolve") { + throw new Error("No session found"); + } + return {}; + }); + + const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_history"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("missing sessions_history tool"); + + const result = await tool.execute("call6", { sessionKey: sessionId }); + const details = result.details as { status?: string; error?: string }; + expect(details.status).toBe("error"); + expect(details.error).toMatch(/Session not found|No session found/); + }); + it("sessions_send supports fire-and-forget and wait", async () => { callGatewayMock.mockReset(); const calls: Array<{ method?: string; params?: unknown }> = []; @@ -313,6 +369,50 @@ describe("sessions tools", () => { expect(sendCallCount).toBe(0); }); + it("sessions_send resolves sessionId inputs", async () => { + callGatewayMock.mockReset(); + const sessionId = "sess-send"; + const targetKey = "agent:main:discord:channel:123"; + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: Record }; + if (request.method === "sessions.resolve") { + return { key: targetKey }; + } + if (request.method === "agent") { + return { runId: "run-1", acceptedAt: 123 }; + } + if (request.method === "agent.wait") { + return { status: "ok" }; + } + if (request.method === "chat.history") { + return { messages: [] }; + } + return {}; + }); + + const tool = createClawdbotTools({ + agentSessionKey: "main", + agentChannel: "discord", + }).find((candidate) => candidate.name === "sessions_send"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("missing sessions_send tool"); + + const result = await tool.execute("call7", { + sessionKey: sessionId, + message: "ping", + timeoutSeconds: 0, + }); + const details = result.details as { status?: string }; + expect(details.status).toBe("accepted"); + const agentCall = callGatewayMock.mock.calls.find( + (call) => (call[0] as { method?: string }).method === "agent", + ); + expect(agentCall?.[0]).toMatchObject({ + method: "agent", + params: { sessionKey: targetKey }, + }); + }); + it("sessions_send runs ping-pong then announces", async () => { callGatewayMock.mockReset(); const calls: Array<{ method?: string; params?: unknown }> = []; diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 18b4444c8..80f743961 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -40,7 +40,13 @@ import { import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; import type { AnyAgentTool } from "./common.js"; import { readStringParam } from "./common.js"; -import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js"; +import { + shouldResolveSessionIdInput, + resolveInternalSessionKey, + resolveMainSessionAlias, + createAgentToAgentPolicy, +} from "./sessions-helpers.js"; +import { loadCombinedSessionStoreForGateway } from "../../gateway/session-utils.js"; const SessionStatusToolSchema = Type.Object({ sessionKey: Type.Optional(Type.String()), @@ -149,6 +155,22 @@ function resolveSessionEntry(params: { return null; } +function resolveSessionKeyFromSessionId(params: { + cfg: ClawdbotConfig; + sessionId: string; + agentId?: string; +}): string | null { + const trimmed = params.sessionId.trim(); + if (!trimmed) return null; + const { store } = loadCombinedSessionStoreForGateway(params.cfg); + const match = Object.entries(store).find(([key, entry]) => { + if (entry?.sessionId !== trimmed) return false; + if (!params.agentId) return true; + return resolveAgentIdFromSessionKey(key) === params.agentId; + }); + return match?.[0] ?? null; +} + async function resolveModelOverride(params: { cfg: ClawdbotConfig; raw: string; @@ -222,24 +244,74 @@ export function createSessionStatusTool(opts?: { const params = args as Record; const cfg = opts?.config ?? loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); + const a2aPolicy = createAgentToAgentPolicy(cfg); - const requestedKeyRaw = readStringParam(params, "sessionKey") ?? opts?.agentSessionKey; + const requestedKeyParam = readStringParam(params, "sessionKey"); + let requestedKeyRaw = requestedKeyParam ?? opts?.agentSessionKey; if (!requestedKeyRaw?.trim()) { throw new Error("sessionKey required"); } - const agentId = resolveAgentIdFromSessionKey(opts?.agentSessionKey ?? requestedKeyRaw); - const storePath = resolveStorePath(cfg.session?.store, { agentId }); - const store = loadSessionStore(storePath); + const requesterAgentId = resolveAgentIdFromSessionKey( + opts?.agentSessionKey ?? requestedKeyRaw, + ); + const ensureAgentAccess = (targetAgentId: string) => { + if (targetAgentId === requesterAgentId) return; + // Gate cross-agent access behind tools.agentToAgent settings. + if (!a2aPolicy.enabled) { + throw new Error( + "Agent-to-agent status is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.", + ); + } + if (!a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { + throw new Error("Agent-to-agent session status denied by tools.agentToAgent.allow."); + } + }; - const resolved = resolveSessionEntry({ + if (requestedKeyRaw.startsWith("agent:")) { + ensureAgentAccess(resolveAgentIdFromSessionKey(requestedKeyRaw)); + } + + const isExplicitAgentKey = requestedKeyRaw.startsWith("agent:"); + let agentId = isExplicitAgentKey + ? resolveAgentIdFromSessionKey(requestedKeyRaw) + : requesterAgentId; + let storePath = resolveStorePath(cfg.session?.store, { agentId }); + let store = loadSessionStore(storePath); + + // Resolve against the requester-scoped store first to avoid leaking default agent data. + let resolved = resolveSessionEntry({ store, keyRaw: requestedKeyRaw, alias, mainKey, }); + + if (!resolved && shouldResolveSessionIdInput(requestedKeyRaw)) { + const resolvedKey = resolveSessionKeyFromSessionId({ + cfg, + sessionId: requestedKeyRaw, + agentId: a2aPolicy.enabled ? undefined : requesterAgentId, + }); + if (resolvedKey) { + // If resolution points at another agent, enforce A2A policy before switching stores. + ensureAgentAccess(resolveAgentIdFromSessionKey(resolvedKey)); + requestedKeyRaw = resolvedKey; + agentId = resolveAgentIdFromSessionKey(resolvedKey); + storePath = resolveStorePath(cfg.session?.store, { agentId }); + store = loadSessionStore(storePath); + resolved = resolveSessionEntry({ + store, + keyRaw: requestedKeyRaw, + alias, + mainKey, + }); + } + } + if (!resolved) { - throw new Error(`Unknown sessionKey: ${requestedKeyRaw}`); + const kind = shouldResolveSessionIdInput(requestedKeyRaw) ? "sessionId" : "sessionKey"; + throw new Error(`Unknown ${kind}: ${requestedKeyRaw}`); } const configured = resolveDefaultModelForAgent({ cfg, agentId }); diff --git a/src/agents/tools/sessions-helpers.ts b/src/agents/tools/sessions-helpers.ts index c4ec120ed..6ddd2281e 100644 --- a/src/agents/tools/sessions-helpers.ts +++ b/src/agents/tools/sessions-helpers.ts @@ -1,11 +1,12 @@ import type { ClawdbotConfig } from "../../config/config.js"; +import { callGateway } from "../../gateway/call.js"; import { sanitizeUserFacingText } from "../pi-embedded-helpers.js"; import { stripDowngradedToolCallText, stripMinimaxToolCallXml, stripThinkingTagsFromText, } from "../pi-embedded-utils.js"; -import { normalizeMainKey } from "../../routing/session-key.js"; +import { isAcpSessionKey, normalizeMainKey } from "../../routing/session-key.js"; export type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other"; @@ -62,6 +63,195 @@ export function resolveInternalSessionKey(params: { key: string; alias: string; return params.key; } +export type AgentToAgentPolicy = { + enabled: boolean; + matchesAllow: (agentId: string) => boolean; + isAllowed: (requesterAgentId: string, targetAgentId: string) => boolean; +}; + +export function createAgentToAgentPolicy(cfg: ClawdbotConfig): AgentToAgentPolicy { + const routingA2A = cfg.tools?.agentToAgent; + const enabled = routingA2A?.enabled === true; + const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : []; + const matchesAllow = (agentId: string) => { + if (allowPatterns.length === 0) return true; + return allowPatterns.some((pattern) => { + const raw = String(pattern ?? "").trim(); + if (!raw) return false; + if (raw === "*") return true; + if (!raw.includes("*")) return raw === agentId; + const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i"); + return re.test(agentId); + }); + }; + const isAllowed = (requesterAgentId: string, targetAgentId: string) => { + if (requesterAgentId === targetAgentId) return true; + if (!enabled) return false; + return matchesAllow(requesterAgentId) && matchesAllow(targetAgentId); + }; + return { enabled, matchesAllow, isAllowed }; +} + +const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function looksLikeSessionId(value: string): boolean { + return SESSION_ID_RE.test(value.trim()); +} + +export function looksLikeSessionKey(value: string): boolean { + const raw = value.trim(); + if (!raw) return false; + // These are canonical key shapes that should never be treated as sessionIds. + if (raw === "main" || raw === "global" || raw === "unknown") return true; + if (isAcpSessionKey(raw)) return true; + if (raw.startsWith("agent:")) return true; + if (raw.startsWith("cron:") || raw.startsWith("hook:")) return true; + if (raw.startsWith("node-") || raw.startsWith("node:")) return true; + if (raw.includes(":group:") || raw.includes(":channel:")) return true; + return false; +} + +export function shouldResolveSessionIdInput(value: string): boolean { + // Treat anything that doesn't look like a well-formed key as a sessionId candidate. + return looksLikeSessionId(value) || !looksLikeSessionKey(value); +} + +export type SessionReferenceResolution = + | { + ok: true; + key: string; + displayKey: string; + resolvedViaSessionId: boolean; + } + | { ok: false; status: "error" | "forbidden"; error: string }; + +async function resolveSessionKeyFromSessionId(params: { + sessionId: string; + alias: string; + mainKey: string; + requesterInternalKey?: string; + restrictToSpawned: boolean; +}): Promise { + try { + // Resolve via gateway so we respect store routing and visibility rules. + const result = (await callGateway({ + method: "sessions.resolve", + params: { + sessionId: params.sessionId, + spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined, + includeGlobal: !params.restrictToSpawned, + includeUnknown: !params.restrictToSpawned, + }, + })) as { key?: unknown }; + const key = typeof result?.key === "string" ? result.key.trim() : ""; + if (!key) { + throw new Error( + `Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`, + ); + } + return { + ok: true, + key, + displayKey: resolveDisplaySessionKey({ + key, + alias: params.alias, + mainKey: params.mainKey, + }), + resolvedViaSessionId: true, + }; + } catch (err) { + if (params.restrictToSpawned) { + return { + ok: false, + status: "forbidden", + error: `Session not visible from this sandboxed agent session: ${params.sessionId}`, + }; + } + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + status: "error", + error: + message || + `Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`, + }; + } +} + +async function resolveSessionKeyFromKey(params: { + key: string; + alias: string; + mainKey: string; + requesterInternalKey?: string; + restrictToSpawned: boolean; +}): Promise { + try { + // Try key-based resolution first so non-standard keys keep working. + const result = (await callGateway({ + method: "sessions.resolve", + params: { + key: params.key, + spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined, + }, + })) as { key?: unknown }; + const key = typeof result?.key === "string" ? result.key.trim() : ""; + if (!key) return null; + return { + ok: true, + key, + displayKey: resolveDisplaySessionKey({ + key, + alias: params.alias, + mainKey: params.mainKey, + }), + resolvedViaSessionId: false, + }; + } catch { + return null; + } +} + +export async function resolveSessionReference(params: { + sessionKey: string; + alias: string; + mainKey: string; + requesterInternalKey?: string; + restrictToSpawned: boolean; +}): Promise { + const raw = params.sessionKey.trim(); + if (shouldResolveSessionIdInput(raw)) { + // Prefer key resolution to avoid misclassifying custom keys as sessionIds. + const resolvedByKey = await resolveSessionKeyFromKey({ + key: raw, + alias: params.alias, + mainKey: params.mainKey, + requesterInternalKey: params.requesterInternalKey, + restrictToSpawned: params.restrictToSpawned, + }); + if (resolvedByKey) return resolvedByKey; + return await resolveSessionKeyFromSessionId({ + sessionId: raw, + alias: params.alias, + mainKey: params.mainKey, + requesterInternalKey: params.requesterInternalKey, + restrictToSpawned: params.restrictToSpawned, + }); + } + + const resolvedKey = resolveInternalSessionKey({ + key: raw, + alias: params.alias, + mainKey: params.mainKey, + }); + const displayKey = resolveDisplaySessionKey({ + key: resolvedKey, + alias: params.alias, + mainKey: params.mainKey, + }); + return { ok: true, key: resolvedKey, displayKey, resolvedViaSessionId: false }; +} + export function classifySessionKind(params: { key: string; gatewayKind?: string | null; diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index 76dad4963..5351b70c5 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -2,17 +2,14 @@ import { Type } from "@sinclair/typebox"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; -import { - isSubagentSessionKey, - normalizeAgentId, - parseAgentSessionKey, -} from "../../routing/session-key.js"; +import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringParam } from "./common.js"; import { - resolveDisplaySessionKey, - resolveInternalSessionKey, + createAgentToAgentPolicy, + resolveSessionReference, resolveMainSessionAlias, + resolveInternalSessionKey, stripToolMessages, } from "./sessions-helpers.js"; @@ -58,7 +55,7 @@ export function createSessionsHistoryTool(opts?: { parameters: SessionsHistoryToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; - const sessionKey = readStringParam(params, "sessionKey", { + const sessionKeyParam = readStringParam(params, "sessionKey", { required: true, }); const cfg = loadConfig(); @@ -72,17 +69,26 @@ export function createSessionsHistoryTool(opts?: { mainKey, }) : undefined; - const resolvedKey = resolveInternalSessionKey({ - key: sessionKey, - alias, - mainKey, - }); const restrictToSpawned = opts?.sandboxed === true && visibility === "spawned" && - requesterInternalKey && + !!requesterInternalKey && !isSubagentSessionKey(requesterInternalKey); - if (restrictToSpawned) { + const resolvedSession = await resolveSessionReference({ + sessionKey: sessionKeyParam, + alias, + mainKey, + requesterInternalKey, + restrictToSpawned, + }); + if (!resolvedSession.ok) { + return jsonResult({ status: resolvedSession.status, error: resolvedSession.error }); + } + // From here on, use the canonical key (sessionId inputs already resolved). + const resolvedKey = resolvedSession.key; + const displayKey = resolvedSession.displayKey; + const resolvedViaSessionId = resolvedSession.resolvedViaSessionId; + if (restrictToSpawned && !resolvedViaSessionId) { const ok = await isSpawnedSessionAllowed({ requesterSessionKey: requesterInternalKey, targetSessionKey: resolvedKey, @@ -90,40 +96,24 @@ export function createSessionsHistoryTool(opts?: { if (!ok) { return jsonResult({ status: "forbidden", - error: `Session not visible from this sandboxed agent session: ${sessionKey}`, + error: `Session not visible from this sandboxed agent session: ${sessionKeyParam}`, }); } } - const routingA2A = cfg.tools?.agentToAgent; - const a2aEnabled = routingA2A?.enabled === true; - const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : []; - const matchesAllow = (agentId: string) => { - if (allowPatterns.length === 0) return true; - return allowPatterns.some((pattern) => { - const raw = String(pattern ?? "").trim(); - if (!raw) return false; - if (raw === "*") return true; - if (!raw.includes("*")) return raw === agentId; - const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i"); - return re.test(agentId); - }); - }; - const requesterAgentId = normalizeAgentId( - parseAgentSessionKey(requesterInternalKey)?.agentId, - ); - const targetAgentId = normalizeAgentId(parseAgentSessionKey(resolvedKey)?.agentId); + const a2aPolicy = createAgentToAgentPolicy(cfg); + const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey); + const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey); const isCrossAgent = requesterAgentId !== targetAgentId; if (isCrossAgent) { - if (!a2aEnabled) { + if (!a2aPolicy.enabled) { return jsonResult({ status: "forbidden", error: "Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.", }); } - if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) { + if (!a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { return jsonResult({ status: "forbidden", error: "Agent-to-agent history denied by tools.agentToAgent.allow.", @@ -143,11 +133,7 @@ export function createSessionsHistoryTool(opts?: { const rawMessages = Array.isArray(result?.messages) ? result.messages : []; const messages = includeTools ? rawMessages : stripToolMessages(rawMessages); return jsonResult({ - sessionKey: resolveDisplaySessionKey({ - key: sessionKey, - alias, - mainKey, - }), + sessionKey: displayKey, messages, }); }, diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 47566eefb..148a33d30 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -4,14 +4,11 @@ import { Type } from "@sinclair/typebox"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; -import { - isSubagentSessionKey, - normalizeAgentId, - parseAgentSessionKey, -} from "../../routing/session-key.js"; +import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringArrayParam } from "./common.js"; import { + createAgentToAgentPolicy, classifySessionKind, deriveChannel, resolveDisplaySessionKey, @@ -98,24 +95,8 @@ export function createSessionsListTool(opts?: { const sessions = Array.isArray(list?.sessions) ? list.sessions : []; const storePath = typeof list?.path === "string" ? list.path : undefined; - const routingA2A = cfg.tools?.agentToAgent; - const a2aEnabled = routingA2A?.enabled === true; - const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : []; - const matchesAllow = (agentId: string) => { - if (allowPatterns.length === 0) return true; - return allowPatterns.some((pattern) => { - const raw = String(pattern ?? "").trim(); - if (!raw) return false; - if (raw === "*") return true; - if (!raw.includes("*")) return raw === agentId; - const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i"); - return re.test(agentId); - }); - }; - const requesterAgentId = normalizeAgentId( - parseAgentSessionKey(requesterInternalKey)?.agentId, - ); + const a2aPolicy = createAgentToAgentPolicy(cfg); + const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey); const rows: SessionListRow[] = []; for (const entry of sessions) { @@ -123,12 +104,9 @@ export function createSessionsListTool(opts?: { const key = typeof entry.key === "string" ? entry.key : ""; if (!key) continue; - const entryAgentId = normalizeAgentId(parseAgentSessionKey(key)?.agentId); + const entryAgentId = resolveAgentIdFromSessionKey(key); const crossAgent = entryAgentId !== requesterAgentId; - if (crossAgent) { - if (!a2aEnabled) continue; - if (!matchesAllow(requesterAgentId) || !matchesAllow(entryAgentId)) continue; - } + if (crossAgent && !a2aPolicy.isAllowed(requesterAgentId, entryAgentId)) continue; if (key === "unknown") continue; if (key === "global" && alias !== "global") continue; diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index 5366edc16..ff2d7e1f4 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -7,7 +7,7 @@ import { callGateway } from "../../gateway/call.js"; import { isSubagentSessionKey, normalizeAgentId, - parseAgentSessionKey, + resolveAgentIdFromSessionKey, } from "../../routing/session-key.js"; import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js"; import { @@ -18,10 +18,11 @@ import { AGENT_LANE_NESTED } from "../lanes.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringParam } from "./common.js"; import { + createAgentToAgentPolicy, extractAssistantText, - resolveDisplaySessionKey, resolveInternalSessionKey, resolveMainSessionAlias, + resolveSessionReference, stripToolMessages, } from "./sessions-helpers.js"; import { buildAgentToAgentMessageContext, resolvePingPongTurns } from "./sessions-send-helpers.js"; @@ -63,24 +64,10 @@ export function createSessionsSendTool(opts?: { const restrictToSpawned = opts?.sandboxed === true && visibility === "spawned" && - requesterInternalKey && + !!requesterInternalKey && !isSubagentSessionKey(requesterInternalKey); - const routingA2A = cfg.tools?.agentToAgent; - const a2aEnabled = routingA2A?.enabled === true; - const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : []; - const matchesAllow = (agentId: string) => { - if (allowPatterns.length === 0) return true; - return allowPatterns.some((pattern) => { - const raw = String(pattern ?? "").trim(); - if (!raw) return false; - if (raw === "*") return true; - if (!raw.includes("*")) return raw === agentId; - const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i"); - return re.test(agentId); - }); - }; + const a2aPolicy = createAgentToAgentPolicy(cfg); const sessionKeyParam = readStringParam(params, "sessionKey"); const labelParam = readStringParam(params, "label")?.trim() || undefined; @@ -105,7 +92,7 @@ export function createSessionsSendTool(opts?: { let sessionKey = sessionKeyParam; if (!sessionKey && labelParam) { const requesterAgentId = requesterInternalKey - ? normalizeAgentId(parseAgentSessionKey(requesterInternalKey)?.agentId) + ? resolveAgentIdFromSessionKey(requesterInternalKey) : undefined; const requestedAgentId = labelAgentIdParam ? normalizeAgentId(labelAgentIdParam) @@ -125,7 +112,7 @@ export function createSessionsSendTool(opts?: { } if (requesterAgentId && requestedAgentId && requestedAgentId !== requesterAgentId) { - if (!a2aEnabled) { + if (!a2aPolicy.enabled) { return jsonResult({ runId: crypto.randomUUID(), status: "forbidden", @@ -133,7 +120,7 @@ export function createSessionsSendTool(opts?: { "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.", }); } - if (!matchesAllow(requesterAgentId) || !matchesAllow(requestedAgentId)) { + if (!a2aPolicy.isAllowed(requesterAgentId, requestedAgentId)) { return jsonResult({ runId: crypto.randomUUID(), status: "forbidden", @@ -195,14 +182,26 @@ export function createSessionsSendTool(opts?: { error: "Either sessionKey or label is required", }); } - - const resolvedKey = resolveInternalSessionKey({ - key: sessionKey, + const resolvedSession = await resolveSessionReference({ + sessionKey, alias, mainKey, + requesterInternalKey, + restrictToSpawned, }); + if (!resolvedSession.ok) { + return jsonResult({ + runId: crypto.randomUUID(), + status: resolvedSession.status, + error: resolvedSession.error, + }); + } + // Normalize sessionKey/sessionId input into a canonical session key. + const resolvedKey = resolvedSession.key; + const displayKey = resolvedSession.displayKey; + const resolvedViaSessionId = resolvedSession.resolvedViaSessionId; - if (restrictToSpawned) { + if (restrictToSpawned && !resolvedViaSessionId) { const sessions = await listSessions({ includeGlobal: false, includeUnknown: false, @@ -215,11 +214,7 @@ export function createSessionsSendTool(opts?: { runId: crypto.randomUUID(), status: "forbidden", error: `Session not visible from this sandboxed agent session: ${sessionKey}`, - sessionKey: resolveDisplaySessionKey({ - key: sessionKey, - alias, - mainKey, - }), + sessionKey: displayKey, }); } } @@ -231,18 +226,11 @@ export function createSessionsSendTool(opts?: { const announceTimeoutMs = timeoutSeconds === 0 ? 30_000 : timeoutMs; const idempotencyKey = crypto.randomUUID(); let runId: string = idempotencyKey; - const displayKey = resolveDisplaySessionKey({ - key: sessionKey, - alias, - mainKey, - }); - const requesterAgentId = normalizeAgentId( - parseAgentSessionKey(requesterInternalKey)?.agentId, - ); - const targetAgentId = normalizeAgentId(parseAgentSessionKey(resolvedKey)?.agentId); + const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey); + const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey); const isCrossAgent = requesterAgentId !== targetAgentId; if (isCrossAgent) { - if (!a2aEnabled) { + if (!a2aPolicy.enabled) { return jsonResult({ runId: crypto.randomUUID(), status: "forbidden", @@ -251,7 +239,7 @@ export function createSessionsSendTool(opts?: { sessionKey: displayKey, }); } - if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) { + if (!a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { return jsonResult({ runId: crypto.randomUUID(), status: "forbidden", diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 4b7e895c7..67156a5de 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -38,6 +38,7 @@ export const SessionsPreviewParamsSchema = Type.Object( export const SessionsResolveParamsSchema = Type.Object( { key: Type.Optional(NonEmptyString), + sessionId: Type.Optional(NonEmptyString), label: Type.Optional(SessionLabelString), agentId: Type.Optional(NonEmptyString), spawnedBy: Type.Optional(NonEmptyString), diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts index 26a46ac30..5b9ae1c6d 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts @@ -142,6 +142,12 @@ describe("gateway server sessions", () => { expect(resolvedByKey.ok).toBe(true); expect(resolvedByKey.payload?.key).toBe("agent:main:main"); + const resolvedBySessionId = await rpcReq<{ ok: true; key: string }>(ws, "sessions.resolve", { + sessionId: "sess-group", + }); + expect(resolvedBySessionId.ok).toBe(true); + expect(resolvedBySessionId.payload?.key).toBe("agent:main:discord:group:dev"); + const list1 = await rpcReq<{ path: string; defaults?: { model?: string | null; modelProvider?: string | null }; diff --git a/src/gateway/sessions-resolve.ts b/src/gateway/sessions-resolve.ts index 28b0fb5ca..d5a00e156 100644 --- a/src/gateway/sessions-resolve.ts +++ b/src/gateway/sessions-resolve.ts @@ -23,17 +23,23 @@ export function resolveSessionKeyFromResolveParams(params: { const key = typeof p.key === "string" ? p.key.trim() : ""; const hasKey = key.length > 0; + const sessionId = typeof p.sessionId === "string" ? p.sessionId.trim() : ""; + const hasSessionId = sessionId.length > 0; const hasLabel = typeof p.label === "string" && p.label.trim().length > 0; - if (hasKey && hasLabel) { + const selectionCount = [hasKey, hasSessionId, hasLabel].filter(Boolean).length; + if (selectionCount > 1) { return { ok: false, - error: errorShape(ErrorCodes.INVALID_REQUEST, "Provide either key or label (not both)"), + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "Provide either key, sessionId, or label (not multiple)", + ), }; } - if (!hasKey && !hasLabel) { + if (selectionCount === 0) { return { ok: false, - error: errorShape(ErrorCodes.INVALID_REQUEST, "Either key or label is required"), + error: errorShape(ErrorCodes.INVALID_REQUEST, "Either key, sessionId, or label is required"), }; } @@ -50,6 +56,43 @@ export function resolveSessionKeyFromResolveParams(params: { return { ok: true, key: target.canonicalKey }; } + if (hasSessionId) { + const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); + const list = listSessionsFromStore({ + cfg, + storePath, + store, + opts: { + includeGlobal: p.includeGlobal === true, + includeUnknown: p.includeUnknown === true, + spawnedBy: p.spawnedBy, + agentId: p.agentId, + search: sessionId, + limit: 8, + }, + }); + const matches = list.sessions.filter( + (session) => session.sessionId === sessionId || session.key === sessionId, + ); + if (matches.length === 0) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, `No session found: ${sessionId}`), + }; + } + if (matches.length > 1) { + const keys = matches.map((session) => session.key).join(", "); + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + `Multiple sessions found for sessionId: ${sessionId} (${keys})`, + ), + }; + } + return { ok: true, key: String(matches[0]?.key ?? "") }; + } + const parsedLabel = parseSessionLabel(p.label); if (!parsedLabel.ok) { return {