diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 1abf1cdb1..031465d6d 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -703,6 +703,39 @@ public struct SessionsListParams: Codable, Sendable { } } +public struct SessionsResolveParams: Codable, Sendable { + public let key: String? + public let label: String? + public let agentid: String? + public let spawnedby: String? + public let includeglobal: Bool? + public let includeunknown: Bool? + + public init( + key: String?, + label: String?, + agentid: String?, + spawnedby: String?, + includeglobal: Bool?, + includeunknown: Bool? + ) { + self.key = key + self.label = label + self.agentid = agentid + self.spawnedby = spawnedby + self.includeglobal = includeglobal + self.includeunknown = includeunknown + } + private enum CodingKeys: String, CodingKey { + case key + case label + case agentid = "agentId" + case spawnedby = "spawnedBy" + case includeglobal = "includeGlobal" + case includeunknown = "includeUnknown" + } +} + public struct SessionsPatchParams: Codable, Sendable { public let key: String public let label: AnyCodable? diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index b95dd61e0..1c6a06e93 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -9,6 +9,7 @@ import { normalizeAgentId, parseAgentSessionKey, } from "../../routing/session-key.js"; +import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js"; import { readLatestAssistantReply, runAgentStep } from "./agent-step.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringParam } from "./common.js"; @@ -40,7 +41,8 @@ const SessionsSendToolSchema = Type.Union([ ), Type.Object( { - label: Type.String({ minLength: 1, maxLength: 64 }), + label: Type.String({ minLength: 1, maxLength: SESSION_LABEL_MAX_LENGTH }), + agentId: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })), message: Type.String(), timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })), }, @@ -80,8 +82,28 @@ export function createSessionsSendTool(opts?: { 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 sessionKeyParam = readStringParam(params, "sessionKey"); const labelParam = readStringParam(params, "label")?.trim() || undefined; + const labelAgentIdParam = + readStringParam(params, "agentId")?.trim() || undefined; if (sessionKeyParam && labelParam) { return jsonResult({ runId: crypto.randomUUID(), @@ -101,20 +123,86 @@ export function createSessionsSendTool(opts?: { let sessionKey = sessionKeyParam; if (!sessionKey && labelParam) { - const agentIdForLookup = requesterInternalKey + const requesterAgentId = requesterInternalKey ? normalizeAgentId( parseAgentSessionKey(requesterInternalKey)?.agentId, ) : undefined; - const listParams: Record = { - includeGlobal: false, - includeUnknown: false, + const requestedAgentId = labelAgentIdParam + ? normalizeAgentId(labelAgentIdParam) + : undefined; + + if ( + restrictToSpawned && + requestedAgentId && + requesterAgentId && + requestedAgentId !== requesterAgentId + ) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error: + "Sandboxed sessions_send label lookup is limited to this agent", + }); + } + + if ( + requesterAgentId && + requestedAgentId && + requestedAgentId !== requesterAgentId + ) { + if (!a2aEnabled) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error: + "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.", + }); + } + if ( + !matchesAllow(requesterAgentId) || + !matchesAllow(requestedAgentId) + ) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error: + "Agent-to-agent messaging denied by tools.agentToAgent.allow.", + }); + } + } + + const resolveParams: Record = { label: labelParam, + ...(requestedAgentId ? { agentId: requestedAgentId } : {}), + ...(restrictToSpawned ? { spawnedBy: requesterInternalKey } : {}), }; - if (restrictToSpawned) listParams.spawnedBy = requesterInternalKey; - if (agentIdForLookup) listParams.agentId = agentIdForLookup; - const matches = await listSessions(listParams); - if (matches.length === 0) { + let resolvedKey = ""; + try { + const resolved = (await callGateway({ + method: "sessions.resolve", + params: resolveParams, + timeoutMs: 10_000, + })) as { key?: unknown }; + resolvedKey = + typeof resolved?.key === "string" ? resolved.key.trim() : ""; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (restrictToSpawned) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error: `Session not visible from this sandboxed agent session: label=${labelParam}`, + }); + } + return jsonResult({ + runId: crypto.randomUUID(), + status: "error", + error: msg || `No session found with label: ${labelParam}`, + }); + } + + if (!resolvedKey) { if (restrictToSpawned) { return jsonResult({ runId: crypto.randomUUID(), @@ -128,26 +216,7 @@ export function createSessionsSendTool(opts?: { error: `No session found with label: ${labelParam}`, }); } - if (matches.length > 1) { - const keys = matches - .map((entry) => (typeof entry?.key === "string" ? entry.key : "")) - .filter(Boolean) - .join(", "); - return jsonResult({ - runId: crypto.randomUUID(), - status: "error", - error: `Multiple sessions found with label: ${labelParam}${keys ? ` (${keys})` : ""}`, - }); - } - const key = matches[0]?.key; - if (typeof key !== "string" || !key.trim()) { - return jsonResult({ - runId: crypto.randomUUID(), - status: "error", - error: `Invalid session entry for label: ${labelParam}`, - }); - } - sessionKey = key; + sessionKey = resolvedKey; } if (!sessionKey) { @@ -165,17 +234,11 @@ export function createSessionsSendTool(opts?: { }); if (restrictToSpawned) { - const agentIdForLookup = requesterInternalKey - ? normalizeAgentId( - parseAgentSessionKey(requesterInternalKey)?.agentId, - ) - : undefined; const sessions = await listSessions({ includeGlobal: false, includeUnknown: false, limit: 500, spawnedBy: requesterInternalKey, - ...(agentIdForLookup ? { agentId: agentIdForLookup } : {}), }); const ok = sessions.some((entry) => entry?.key === resolvedKey); if (!ok) { @@ -205,24 +268,6 @@ export function createSessionsSendTool(opts?: { alias, mainKey, }); - - 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, ); diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 18d40aff0..748037442 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -103,6 +103,8 @@ import { SessionsPatchParamsSchema, type SessionsResetParams, SessionsResetParamsSchema, + type SessionsResolveParams, + SessionsResolveParamsSchema, type ShutdownEvent, ShutdownEventSchema, type SkillsInstallParams, @@ -201,6 +203,9 @@ export const validateNodeInvokeParams = ajv.compile( export const validateSessionsListParams = ajv.compile( SessionsListParamsSchema, ); +export const validateSessionsResolveParams = ajv.compile( + SessionsResolveParamsSchema, +); export const validateSessionsPatchParams = ajv.compile( SessionsPatchParamsSchema, ); @@ -417,6 +422,7 @@ export type { NodeListParams, NodeInvokeParams, SessionsListParams, + SessionsResolveParams, SessionsPatchParams, SessionsResetParams, SessionsDeleteParams, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 58329c109..a76734337 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -1,7 +1,11 @@ import { type Static, type TSchema, Type } from "@sinclair/typebox"; +import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js"; const NonEmptyString = Type.String({ minLength: 1 }); -const SessionLabelString = Type.String({ minLength: 1, maxLength: 64 }); +const SessionLabelString = Type.String({ + minLength: 1, + maxLength: SESSION_LABEL_MAX_LENGTH, +}); export const PresenceEntrySchema = Type.Object( { @@ -323,6 +327,18 @@ export const SessionsListParamsSchema = Type.Object( { additionalProperties: false }, ); +export const SessionsResolveParamsSchema = Type.Object( + { + key: Type.Optional(NonEmptyString), + label: Type.Optional(SessionLabelString), + agentId: Type.Optional(NonEmptyString), + spawnedBy: Type.Optional(NonEmptyString), + includeGlobal: Type.Optional(Type.Boolean()), + includeUnknown: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); + export const SessionsPatchParamsSchema = Type.Object( { key: NonEmptyString, @@ -938,6 +954,7 @@ export const ProtocolSchemas: Record = { NodeDescribeParams: NodeDescribeParamsSchema, NodeInvokeParams: NodeInvokeParamsSchema, SessionsListParams: SessionsListParamsSchema, + SessionsResolveParams: SessionsResolveParamsSchema, SessionsPatchParams: SessionsPatchParamsSchema, SessionsResetParams: SessionsResetParamsSchema, SessionsDeleteParams: SessionsDeleteParamsSchema, @@ -1014,6 +1031,7 @@ export type NodeListParams = Static; export type NodeDescribeParams = Static; export type NodeInvokeParams = Static; export type SessionsListParams = Static; +export type SessionsResolveParams = Static; export type SessionsPatchParams = Static; export type SessionsResetParams = Static; export type SessionsDeleteParams = Static; diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index 5311ad606..b2df674e6 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -24,7 +24,6 @@ import { buildConfigSchema } from "../config/schema.js"; import { loadSessionStore, resolveMainSessionKey, - resolveStorePath, type SessionEntry, saveSessionStore, } from "../config/sessions.js"; @@ -45,6 +44,7 @@ import { type SessionsListParams, type SessionsPatchParams, type SessionsResetParams, + type SessionsResolveParams, validateChatAbortParams, validateChatHistoryParams, validateChatSendParams, @@ -57,6 +57,7 @@ import { validateSessionsListParams, validateSessionsPatchParams, validateSessionsResetParams, + validateSessionsResolveParams, validateTalkModeParams, } from "./protocol/index.js"; import type { ChatRunEntry } from "./server-chat.js"; @@ -70,8 +71,10 @@ import { archiveFileOnDisk, capArrayByJsonBytes, listSessionsFromStore, + loadCombinedSessionStoreForGateway, loadSessionEntry, readSessionMessages, + resolveGatewaySessionStoreTarget, resolveSessionModelRef, resolveSessionTranscriptCandidates, type SessionsPatchResult, @@ -288,8 +291,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { } const p = params as SessionsListParams; const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.session?.store); - const store = loadSessionStore(storePath); + const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); const result = listSessionsFromStore({ cfg, storePath, @@ -298,6 +300,109 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { }); return { ok: true, payloadJSON: JSON.stringify(result) }; } + case "sessions.resolve": { + const params = parseParams(); + if (!validateSessionsResolveParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid sessions.resolve params: ${formatValidationErrors(validateSessionsResolveParams.errors)}`, + }, + }; + } + + const p = params as SessionsResolveParams; + const cfg = loadConfig(); + + const key = typeof p.key === "string" ? p.key.trim() : ""; + const label = typeof p.label === "string" ? p.label.trim() : ""; + const hasKey = key.length > 0; + const hasLabel = label.length > 0; + if (hasKey && hasLabel) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "Provide either key or label (not both)", + }, + }; + } + if (!hasKey && !hasLabel) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "Either key or label is required", + }, + }; + } + + if (hasKey) { + const target = resolveGatewaySessionStoreTarget({ cfg, key }); + const store = loadSessionStore(target.storePath); + const existingKey = target.storeKeys.find( + (candidate) => store[candidate], + ); + if (!existingKey) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `No session found: ${key}`, + }, + }; + } + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + key: target.canonicalKey, + }), + }; + } + + const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); + const list = listSessionsFromStore({ + cfg, + storePath, + store, + opts: { + includeGlobal: p.includeGlobal === true, + includeUnknown: p.includeUnknown === true, + label, + agentId: p.agentId, + spawnedBy: p.spawnedBy, + limit: 2, + }, + }); + if (list.sessions.length === 0) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `No session found with label: ${label}`, + }, + }; + } + if (list.sessions.length > 1) { + const keys = list.sessions.map((s) => s.key).join(", "); + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `Multiple sessions found with label: ${label} (${keys})`, + }, + }; + } + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + key: list.sessions[0]?.key, + }), + }; + } case "sessions.patch": { const params = parseParams(); if (!validateSessionsPatchParams(params)) { @@ -323,12 +428,21 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { } const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.session?.store); + const target = resolveGatewaySessionStoreTarget({ cfg, key }); + const storePath = target.storePath; const store = loadSessionStore(storePath); + const primaryKey = target.storeKeys[0] ?? key; + const existingKey = target.storeKeys.find( + (candidate) => store[candidate], + ); + if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { + store[primaryKey] = store[existingKey]; + delete store[existingKey]; + } const applied = await applySessionsPatchToStore({ cfg, store, - storeKey: key, + storeKey: primaryKey, patch: p, loadGatewayModelCatalog: ctx.loadGatewayModelCatalog, }); @@ -346,7 +460,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { const payload: SessionsPatchResult = { ok: true, path: storePath, - key, + key: target.canonicalKey, entry: applied.entry, }; return { ok: true, payloadJSON: JSON.stringify(payload) }; diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 3ddd02717..ffde2e839 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -24,6 +24,7 @@ import { validateSessionsListParams, validateSessionsPatchParams, validateSessionsResetParams, + validateSessionsResolveParams, } from "../protocol/index.js"; import { archiveFileOnDisk, @@ -60,6 +61,122 @@ export const sessionsHandlers: GatewayRequestHandlers = { }); respond(true, result, undefined); }, + "sessions.resolve": ({ params, respond }) => { + if (!validateSessionsResolveParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid sessions.resolve params: ${formatValidationErrors(validateSessionsResolveParams.errors)}`, + ), + ); + return; + } + const p = params as import("../protocol/index.js").SessionsResolveParams; + const cfg = loadConfig(); + + const key = typeof p.key === "string" ? p.key.trim() : ""; + const label = typeof p.label === "string" ? p.label.trim() : ""; + const hasKey = key.length > 0; + const hasLabel = label.length > 0; + if (hasKey && hasLabel) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "Provide either key or label (not both)", + ), + ); + return; + } + if (!hasKey && !hasLabel) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "Either key or label is required", + ), + ); + return; + } + + if (hasKey) { + if (!key) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "key required"), + ); + return; + } + const target = resolveGatewaySessionStoreTarget({ cfg, key }); + const store = loadSessionStore(target.storePath); + const existingKey = target.storeKeys.find( + (candidate) => store[candidate], + ); + if (!existingKey) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `No session found: ${key}`), + ); + return; + } + respond(true, { ok: true, key: target.canonicalKey }, undefined); + return; + } + + if (!label) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "label required"), + ); + return; + } + + const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); + const list = listSessionsFromStore({ + cfg, + storePath, + store, + opts: { + includeGlobal: p.includeGlobal === true, + includeUnknown: p.includeUnknown === true, + label, + agentId: p.agentId, + spawnedBy: p.spawnedBy, + limit: 2, + }, + }); + if (list.sessions.length === 0) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `No session found with label: ${label}`, + ), + ); + return; + } + if (list.sessions.length > 1) { + const keys = list.sessions.map((s) => s.key).join(", "); + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Multiple sessions found with label: ${label} (${keys})`, + ), + ); + return; + } + respond(true, { ok: true, key: list.sessions[0]?.key }, undefined); + }, "sessions.patch": async ({ params, respond, context }) => { if (!validateSessionsPatchParams(params)) { respond( diff --git a/src/gateway/server.node-bridge.test.ts b/src/gateway/server.node-bridge.test.ts index a885014df..56b0b3336 100644 --- a/src/gateway/server.node-bridge.test.ts +++ b/src/gateway/server.node-bridge.test.ts @@ -642,6 +642,17 @@ describe("gateway server node/bridge", () => { expect(typeof payload.count).toBe("number"); expect(typeof payload.path).toBe("string"); + const resolveRes = await bridgeCall?.onRequest?.("ios-node", { + id: "r2", + method: "sessions.resolve", + paramsJSON: JSON.stringify({ key: "main" }), + }); + expect(resolveRes?.ok).toBe(true); + const resolvedPayload = JSON.parse( + String((resolveRes as { payloadJSON?: string }).payloadJSON ?? "{}"), + ) as { key?: string }; + expect(resolvedPayload.key).toBe("agent:main:main"); + await server.close(); }); diff --git a/src/gateway/server.sessions.test.ts b/src/gateway/server.sessions.test.ts index 7e48edaa2..129f87361 100644 --- a/src/gateway/server.sessions.test.ts +++ b/src/gateway/server.sessions.test.ts @@ -87,6 +87,14 @@ describe("gateway server sessions", () => { ]), ); + const resolvedByKey = await rpcReq<{ ok: true; key: string }>( + ws, + "sessions.resolve", + { key: "main" }, + ); + expect(resolvedByKey.ok).toBe(true); + expect(resolvedByKey.payload?.key).toBe("agent:main:main"); + const list1 = await rpcReq<{ path: string; sessions: Array<{ @@ -197,6 +205,14 @@ describe("gateway server sessions", () => { "agent:main:subagent:one", ]); + const resolvedByLabel = await rpcReq<{ ok: true; key: string }>( + ws, + "sessions.resolve", + { label: "Briefing", agentId: "main" }, + ); + expect(resolvedByLabel.ok).toBe(true); + expect(resolvedByLabel.payload?.key).toBe("agent:main:subagent:one"); + const spawnedOnly = await rpcReq<{ sessions: Array<{ key: string }>; }>(ws, "sessions.list", { diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index b6abb1edc..5b6b78bde 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -21,6 +21,7 @@ import type { ClawdbotConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; import { isSubagentSessionKey } from "../routing/session-key.js"; import { normalizeSendPolicy } from "../sessions/send-policy.js"; +import { parseSessionLabel } from "../sessions/session-label.js"; import { ErrorCodes, type ErrorShape, @@ -28,28 +29,10 @@ import { type SessionsPatchParams, } from "./protocol/index.js"; -export const SESSION_LABEL_MAX_LENGTH = 64; - function invalid(message: string): { ok: false; error: ErrorShape } { return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) }; } -function normalizeLabel( - raw: unknown, -): { ok: true; label: string } | ReturnType { - const trimmed = - typeof raw === "string" - ? raw.trim() - : typeof raw === "number" || typeof raw === "boolean" - ? String(raw).trim() - : ""; - if (!trimmed) return invalid("invalid label: empty"); - if (trimmed.length > SESSION_LABEL_MAX_LENGTH) { - return invalid(`invalid label: too long (max ${SESSION_LABEL_MAX_LENGTH})`); - } - return { ok: true, label: trimmed }; -} - export async function applySessionsPatchToStore(params: { cfg: ClawdbotConfig; store: Record; @@ -93,15 +76,15 @@ export async function applySessionsPatchToStore(params: { if (raw === null) { delete next.label; } else if (raw !== undefined) { - const normalized = normalizeLabel(raw); - if (!normalized.ok) return normalized; + const parsed = parseSessionLabel(raw); + if (!parsed.ok) return invalid(parsed.error); for (const [key, entry] of Object.entries(store)) { if (key === storeKey) continue; - if (entry?.label === normalized.label) { - return invalid(`label already in use: ${normalized.label}`); + if (entry?.label === parsed.label) { + return invalid(`label already in use: ${parsed.label}`); } } - next.label = normalized.label; + next.label = parsed.label; } } diff --git a/src/sessions/session-label.ts b/src/sessions/session-label.ts new file mode 100644 index 000000000..d1adfab57 --- /dev/null +++ b/src/sessions/session-label.ts @@ -0,0 +1,20 @@ +export const SESSION_LABEL_MAX_LENGTH = 64; + +export type ParsedSessionLabel = + | { ok: true; label: string } + | { ok: false; error: string }; + +export function parseSessionLabel(raw: unknown): ParsedSessionLabel { + if (typeof raw !== "string") { + return { ok: false, error: "invalid label: must be a string" }; + } + const trimmed = raw.trim(); + if (!trimmed) return { ok: false, error: "invalid label: empty" }; + if (trimmed.length > SESSION_LABEL_MAX_LENGTH) { + return { + ok: false, + error: `invalid label: too long (max ${SESSION_LABEL_MAX_LENGTH})`, + }; + } + return { ok: true, label: trimmed }; +}