UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

290 lines (289 loc) 12.4 kB
import { c as isRecord } from "./record-coerce-DItp3I4t.js"; import { r as defaultRuntime } from "./runtime-CF2WjnNZ.js"; import { O as parseAgentSessionKey } from "./session-key-BnWWjqNc.js"; import { n as sanitizeTerminalText, t as hasTerminalControl } from "./safe-text-BGBqp1a4.js"; import { r as isTerminalInteractive } from "./terminal-interactivity-DXUXAq5U.js"; import { t as selectStyled } from "./prompt-select-styled-w98xOWqw.js"; import { n as loadRecentSessions, r as resolveResumeSession, t as buildSessionChoices } from "./tui-session-picker-Cgewa8ax.js"; import { Guard } from "typebox/guard"; import { cancel, isCancel } from "@clack/prompts"; //#region src/shared/resume-handoff.ts const RESUME_HANDOFF_MAX_ENCODED_LENGTH = 4096; const RESUME_HANDOFF_MAX_GATEWAY_URL_LENGTH = 2048; const RESUME_HANDOFF_KEYS = [ "version", "sessionKey", "gatewayUrl" ]; const RESUME_HANDOFF_ERROR = "Invalid --handoff payload. Copy a fresh command from the Control UI."; function invalidResumeHandoff() { throw new Error(RESUME_HANDOFF_ERROR); } function validateGatewayUrl(gatewayUrl) { if (gatewayUrl.length === 0 || gatewayUrl.length > RESUME_HANDOFF_MAX_GATEWAY_URL_LENGTH || hasTerminalControl(gatewayUrl)) invalidResumeHandoff(); let parsed; try { parsed = new URL(gatewayUrl); } catch { invalidResumeHandoff(); } const authority = gatewayUrl.slice(gatewayUrl.indexOf("://") + 3).split("/", 1)[0] ?? ""; if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:" || gatewayUrl.includes("?") || gatewayUrl.includes("#") || authority.includes("@") || parsed.username.length > 0 || parsed.password.length > 0) invalidResumeHandoff(); } function validateResumeHandoffFields(sessionKey, gatewayUrl) { if (sessionKey.length === 0 || !Guard.IsMaxLength(sessionKey, 512) || hasTerminalControl(sessionKey) || parseAgentSessionKey(sessionKey) === null) invalidResumeHandoff(); validateGatewayUrl(gatewayUrl); } function encodeBase64Url(bytes) { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); } function decodeBase64Url(encoded) { const standard = encoded.replaceAll("-", "+").replaceAll("_", "/"); const paddingLength = (4 - standard.length % 4) % 4; const binary = atob(`${standard}${"=".repeat(paddingLength)}`); return Uint8Array.from(binary, (character) => character.charCodeAt(0)); } function decodeResumeHandoff(encoded) { try { if (encoded.length === 0 || encoded.length > RESUME_HANDOFF_MAX_ENCODED_LENGTH || !/^[A-Za-z0-9_-]+$/u.test(encoded)) invalidResumeHandoff(); const bytes = decodeBase64Url(encoded); if (encodeBase64Url(bytes) !== encoded) invalidResumeHandoff(); const json = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); const payload = JSON.parse(json); if (!isRecord(payload)) invalidResumeHandoff(); if (Object.keys(payload).length !== RESUME_HANDOFF_KEYS.length || !RESUME_HANDOFF_KEYS.every((key) => Object.hasOwn(payload, key)) || payload.version !== 1 || typeof payload.sessionKey !== "string" || typeof payload.gatewayUrl !== "string") invalidResumeHandoff(); validateResumeHandoffFields(payload.sessionKey, payload.gatewayUrl); return { version: 1, sessionKey: payload.sessionKey, gatewayUrl: payload.gatewayUrl }; } catch { return invalidResumeHandoff(); } } //#endregion //#region src/cli/resume-cli.runtime.ts const RESUME_INTERACTIVE_TERMINAL_GUIDANCE = "Attaching to a session requires an interactive terminal. Re-run `openclaw resume [query]` from an interactive terminal."; const RESUME_HANDOFF_MISSING = "This session is no longer available. Copy a fresh command from the Control UI."; const RESUME_HANDOFF_UNRESOLVED = "Could not resolve the session handoff. Copy a fresh command from the Control UI."; function hasExactKeys(value, requiredKeys, optionalKeys = []) { if (!isRecord(value)) return false; const keys = Object.keys(value); return requiredKeys.every((key) => Object.hasOwn(value, key)) && keys.every((key) => requiredKeys.includes(key) || optionalKeys.includes(key)); } function isHandoffSessionCandidate(value) { return hasExactKeys(value, ["key", "agentId"], ["displayName"]) && typeof value.key === "string" && value.key.length > 0 && typeof value.agentId === "string" && value.agentId.length > 0 && (!Object.hasOwn(value, "displayName") || typeof value.displayName === "string"); } function isHandoffErrorShape(value) { if (!hasExactKeys(value, ["code", "message"], [ "details", "retryable", "retryAfterMs" ]) || typeof value.code !== "string" || value.code.length === 0 || typeof value.message !== "string" || value.message.length === 0 || Object.hasOwn(value, "retryable") && typeof value.retryable !== "boolean") return false; return !Object.hasOwn(value, "retryAfterMs") || typeof value.retryAfterMs === "number" && Number.isInteger(value.retryAfterMs) && value.retryAfterMs >= 0; } function parseHandoffSessionResolveResult(value) { if (hasExactKeys(value, [ "ok", "key", "agentId" ]) && value.ok === true && typeof value.key === "string" && value.key.length > 0 && typeof value.agentId === "string" && value.agentId.length > 0) return { kind: "success", key: value.key, agentId: value.agentId }; if (hasExactKeys(value, ["ok", "missing"]) && value.ok === true && value.missing === true) return { kind: "missing" }; if (hasExactKeys(value, [ "ok", "ambiguous", "candidates" ]) && value.ok === true && value.ambiguous === true && Array.isArray(value.candidates) && value.candidates.every(isHandoffSessionCandidate)) return { kind: "ambiguous", candidates: value.candidates }; if (hasExactKeys(value, ["ok", "error"]) && value.ok === false && isHandoffErrorShape(value.error)) return { kind: "error", error: value.error }; return { kind: "malformed" }; } function requireInteractiveResumeTerminal() { if (!isTerminalInteractive()) throw new Error(RESUME_INTERACTIVE_TERMINAL_GUIDANCE); } async function formatResumeConnectionError(error) { const [{ formatTuiErrorMessage }, { resolveGatewayDisconnectState }] = await Promise.all([import("./tui-formatters-BiKgYaqD.js"), import("./tui-Cjf5Sw9V.js")]); const details = error && typeof error === "object" && "details" in error ? error.details : void 0; const state = resolveGatewayDisconnectState({ reason: formatTuiErrorMessage(error), details }); return new Error([state.connectionStatus, state.remediation ?? "Ensure the Gateway is running and your --url/--token/--password are correct."].join("\n"), { cause: error }); } async function connectResumeGateway(opts, handoffTarget) { const { GatewayChatClient } = await import("./gateway-chat-DJ7Ob6nF.js"); const client = await GatewayChatClient.connect({ ...opts, ...handoffTarget ? { allowConfiguredAuthForExactTarget: true, suppressEnvAuthFallback: true } : {} }); try { await new Promise((resolve, reject) => { let settled = false; const finish = (complete) => { if (settled) return; settled = true; complete(); }; client.onConnected = () => finish(resolve); client.onConnectError = (error) => finish(() => reject(error)); client.onDisconnected = (reason) => finish(() => reject(new Error(reason || "Gateway connection closed"))); client.start(); }); return client; } catch (error) { await client.stop(); throw await formatResumeConnectionError(error); } } async function resolveHandoffConnection(opts, handoff) { const client = await connectResumeGateway(opts, true); try { let result; try { result = await client.resolveSession({ key: handoff.sessionKey, agentId: handoff.agentId, includeGlobal: true, allowMissing: true }); } catch { throw new Error(RESUME_HANDOFF_UNRESOLVED); } const parsed = parseHandoffSessionResolveResult(result); if (parsed.kind === "success") { const canonicalKeyOwner = parseAgentSessionKey(parsed.key)?.agentId; if (parsed.agentId !== handoff.agentId || canonicalKeyOwner !== parsed.agentId) throw new Error(RESUME_HANDOFF_UNRESOLVED); return { connection: client.connection, sessionKey: parsed.key }; } if (parsed.kind === "missing") throw new Error(RESUME_HANDOFF_MISSING); throw new Error(RESUME_HANDOFF_UNRESOLVED); } finally { await client.stop(); } } async function fetchResumeSessions(opts, options = {}) { const client = await connectResumeGateway(opts, false); try { return { connection: client.connection, sessions: await loadRecentSessions(client, options) }; } catch (error) { throw await formatResumeConnectionError(error); } finally { await client.stop(); } } async function promptResumeSession(sessions) { const choices = buildSessionChoices(sessions); if (choices.length === 0) throw new Error("No recent sessions found. Run `openclaw sessions` to inspect sessions or `openclaw tui` to start one."); const selected = await selectStyled({ message: "Resume a session", options: choices.map((choice) => ({ value: choice.value, label: formatResumeCandidate(choice), hint: choice.description ? sanitizeTerminalText(choice.description) : void 0 })) }); if (isCancel(selected)) { cancel("Cancelled."); return null; } return selected; } function reportResumeFailure(query, resolution) { if (resolution.kind === "ambiguous") { defaultRuntime.error(`Session query ${JSON.stringify(query)} is ambiguous. Candidates:`); for (const candidate of resolution.candidates) defaultRuntime.error(` ${formatResumeCandidate(candidate)}`); defaultRuntime.error("Use a longer name or the exact session key."); return; } defaultRuntime.error(`No recent session matched ${JSON.stringify(query)}.`); defaultRuntime.error("Run `openclaw resume` to choose from recent sessions or `openclaw sessions` to inspect all sessions."); } function formatResumeCandidate(candidate) { const label = sanitizeTerminalText(candidate.label); const key = sanitizeTerminalText(candidate.value); return label === key ? key : `${label} [${key}]`; } function resolveExplicitGlobalSessionKey(query) { const parsed = parseAgentSessionKey(query); return parsed?.rest === "global" ? { agentId: parsed.agentId, key: `agent:${parsed.agentId}:global` } : void 0; } /** Resolve or select one session and run the existing Gateway-backed TUI. */ async function runResumeCommand(query, opts) { const { handoff: encodedHandoff, ...connectionOptions } = opts; if (encodedHandoff !== void 0 && (query !== void 0 || opts.url !== void 0)) throw new Error("--handoff cannot be combined with a positional query or --url."); const handoff = encodedHandoff === void 0 ? void 0 : decodeResumeHandoff(encodedHandoff); requireInteractiveResumeTerminal(); const resolvedQuery = query?.trim(); const explicitGlobalSession = resolveExplicitGlobalSessionKey(resolvedQuery); let connection; let sessionKey; if (handoff) { const parsed = parseAgentSessionKey(handoff.sessionKey); const resolved = await resolveHandoffConnection({ ...connectionOptions, url: handoff.gatewayUrl }, { sessionKey: handoff.sessionKey, agentId: parsed.agentId }); connection = resolved.connection; sessionKey = resolved.sessionKey; } else { const discovery = await fetchResumeSessions(connectionOptions, explicitGlobalSession ? { agentId: explicitGlobalSession.agentId, includeGlobal: true } : void 0); connection = discovery.connection; if (explicitGlobalSession) sessionKey = explicitGlobalSession.key; else if (resolvedQuery) { const resolution = resolveResumeSession(discovery.sessions, resolvedQuery); if (resolution.kind !== "match") { reportResumeFailure(resolvedQuery, resolution); defaultRuntime.exit(1); return; } sessionKey = resolution.session.value; } else sessionKey = await promptResumeSession(discovery.sessions); } if (!sessionKey) return; const { runTui } = await import("./tui-Cjf5Sw9V.js"); await runTui({ boundGateway: { url: handoff?.gatewayUrl ?? connection.url, ...connection.token ? { token: connection.token } : {}, ...connection.password ? { password: connection.password } : {}, ...connection.tlsFingerprint ? { tlsFingerprint: connection.tlsFingerprint } : {} }, session: sessionKey, forceProcessExitOnReturn: true }); } //#endregion export { runResumeCommand };