diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cefad4..7605a3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,17 +12,17 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Set up pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 with: version: 10.18.3 - name: Set up Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: - node-version: 22 + node-version: 24 cache: pnpm - name: Install dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b6c0318..930f05d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,18 +19,18 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 0 token: ${{ env.RELEASE_GITHUB_TOKEN }} - name: Set up pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 with: version: 10.18.3 - name: Set up Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 24 cache: pnpm @@ -97,7 +97,7 @@ jobs: - name: Create release PR or publish id: changesets - uses: changesets/action@v1 + uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b # v1 with: version: pnpm run version-packages publish: pnpm run publish-packages diff --git a/.gitignore b/.gitignore index a753e77..0e8afc8 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ logs/ .vscode/ .cursorignore .cursorindexingignore + +# Local notes +run.md diff --git a/packages/cli/lib/cli.js b/packages/cli/lib/cli.js index 4d828b6..07eaa02 100644 --- a/packages/cli/lib/cli.js +++ b/packages/cli/lib/cli.js @@ -1,5 +1,5 @@ import { pendingCachePath, readJson, removeFile, tokenCachePath, tokenIsUsable } from "./cache.js"; -import { DEFAULT_BASE_URL, DEFAULT_CHANNEL, DEFAULT_CLIENT_NAME, DEFAULT_SCOPE, resolveRuntimeConfig } from "./config.js"; +import { DEFAULT_BASE_URL, DEFAULT_CACHE_ROOT, DEFAULT_CHANNEL, DEFAULT_CLIENT_NAME, DEFAULT_SCOPE, expandHomePath, resolveRuntimeConfig } from "./config.js"; import { ensurePendingLogin, loginWithBroker } from "./broker-client.js"; import { AuthRequiredError, @@ -18,13 +18,23 @@ class InvalidArgumentsError extends Error { } export function preAuthHelpMessage(loginUrl) { + let safeUrl; + try { + const parsed = new URL(loginUrl); + if (parsed.protocol !== "https:") { + throw new Error("non-https"); + } + safeUrl = parsed.href; + } catch { + safeUrl = "[authorization URL unavailable]"; + } return `Hi, I'm CALL-E 👋 I can help you make phone calls, ask for information, and handle phone-related tasks. I'll also keep you updated on the call status, what was discussed, and the key points. Before we officially begin, I'll send you the call goal for confirmation. Before we start, please complete authorization here: -${loginUrl}`; +${safeUrl}`; } export const POST_AUTH_HELP_MESSAGE = `Great, authorization is complete ✨ @@ -333,18 +343,27 @@ function parsePositiveInteger(value, optionName) { return parsed; } +const ARGS_JSON_MAX_BYTES = 64 * 1024; // 64 KB + function parseJsonObject(value, optionName) { const raw = firstOptionValue(value); if (raw === undefined) { return {}; } + const rawStr = String(raw); + if (Buffer.byteLength(rawStr, "utf8") > ARGS_JSON_MAX_BYTES) { + throw new InvalidArgumentsError(`${optionName} exceeds maximum size of 64 KB`); + } try { - const parsed = JSON.parse(String(raw)); + const parsed = JSON.parse(rawStr); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("not object"); } return parsed; - } catch { + } catch (err) { + if (err instanceof InvalidArgumentsError) { + throw err; + } throw new InvalidArgumentsError(`${optionName} must be a JSON object`); } } @@ -381,8 +400,6 @@ function publicPendingLoginPayload({ config, cachePath, pendingPath, pending, cr status: "login_required", broker_base_url: config.brokerBaseUrl, server_url: config.serverUrl, - cache_path: cachePath, - pending_cache_path: pendingPath, pending_status: pending.status, pending_created: created, login_url: pending.login_url, @@ -396,8 +413,6 @@ function publicLoginPayload({ config, cachePath, pendingPath, tokenDocument, sta status, broker_base_url: config.brokerBaseUrl, server_url: config.serverUrl, - cache_path: cachePath, - pending_cache_path: pendingPath, expires_at: tokenDocument?.expires_at ?? null, ...(assistantHint ? { assistant_hint: assistantHint } : {}), }; @@ -408,16 +423,24 @@ function statusPayload(config) { const pendingPath = pendingCachePath(config.cacheRoot, config.serverUrl); const cacheDocument = readJson(cachePath); const pendingDocument = readJson(pendingPath); + const rawPendingLoginUrl = typeof pendingDocument?.login_url === "string" ? pendingDocument.login_url : null; + let pendingLoginUrl = null; + if (rawPendingLoginUrl) { + try { + const parsed = new URL(rawPendingLoginUrl); + pendingLoginUrl = parsed.protocol === "https:" ? parsed.href : null; + } catch { + pendingLoginUrl = null; + } + } return { server_url: config.serverUrl, - cache_path: cachePath, - pending_cache_path: pendingPath, cache_exists: cacheDocument !== null, pending_exists: pendingDocument !== null, usable: tokenIsUsable(cacheDocument, config.minTtlSeconds), expires_at: cacheDocument?.expires_at ?? null, pending_status: pendingDocument?.status ?? null, - pending_login_url: pendingDocument?.login_url ?? null, + ...(pendingLoginUrl ? { pending_login_url: pendingLoginUrl } : {}), }; } @@ -445,7 +468,7 @@ function shellQuote(value) { } function loginCommand(config) { - return [ + const parts = [ "calle", "auth", "login", @@ -457,11 +480,13 @@ function loginCommand(config) { config.authBaseUrl, "--channel", config.channel, - "--cache-root", - config.cacheRoot, - ] - .map(shellQuote) - .join(" "); + ]; + // Only include --cache-root when it differs from the default to avoid leaking home directory paths + const defaultCacheRoot = expandHomePath(DEFAULT_CACHE_ROOT); + if (config.cacheRoot !== defaultCacheRoot) { + parts.push("--cache-root", config.cacheRoot); + } + return parts.map(shellQuote).join(" "); } function callStatusCommand(config, runId, timezone = null) { @@ -623,10 +648,18 @@ function buildPlanArguments(options) { if (toPhones.length === 0) { throw new InvalidArgumentsError("Missing required --to-phone"); } + if (toPhones.length > 10) { + throw new InvalidArgumentsError("--to-phone: maximum 10 numbers per request"); + } + + const goal = requireStringOption(options, "goal", "--goal"); + if (goal.length > 2000) { + throw new InvalidArgumentsError("--goal: maximum 2000 characters"); + } const args = { to_phones: toPhones, - goal: requireStringOption(options, "goal", "--goal"), + goal, }; const language = optionalStringOption(options, "language"); const region = optionalStringOption(options, "region"); @@ -878,6 +911,15 @@ export async function runCli(argv, deps = {}) { const stdout = deps.stdout || ((text) => process.stdout.write(text)); const stderr = deps.stderr || ((text) => process.stderr.write(`${text}\n`)); const openBrowser = deps.openBrowser || (async (url) => { + let parsedUrl; + try { + parsedUrl = new URL(url); + } catch { + throw new Error(`Refusing to open browser: invalid login URL`); + } + if (parsedUrl.protocol !== "https:") { + throw new Error(`Refusing to open browser: login URL must use HTTPS (got '${parsedUrl.protocol}')`); + } const { spawn } = await import("node:child_process"); const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open"; const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; @@ -894,6 +936,9 @@ export async function runCli(argv, deps = {}) { const { options, positional } = parseOptions(rest); const config = resolveRuntimeConfig(options, deps.env || process.env); + config._onProtocolVersionMismatch = (serverVersion, clientVersion) => { + process.stderr.write(`[calle] Warning: MCP protocol version mismatch — server reports ${serverVersion}, client expects ${clientVersion}.\n`); + }; const captureTelemetry = createCommandTelemetry({ config, group, command, deps }); if (prePlanInvokedCommand(group, command)) { await captureTelemetry("cli_invoked"); diff --git a/packages/cli/lib/config.js b/packages/cli/lib/config.js index 66f1029..469ef92 100644 --- a/packages/cli/lib/config.js +++ b/packages/cli/lib/config.js @@ -78,7 +78,16 @@ function resolveTelemetryEnabled(options = {}, env = {}) { function resolveTelemetryUrl({ telemetryUrl, baseUrl }, env = {}) { const configured = firstOptionValue(telemetryUrl) || env.CALLE_TELEMETRY_URL; if (configured) { - return String(configured); + const url = String(configured); + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:") { + throw new Error(`Telemetry URL must use HTTPS, got: ${parsed.protocol}`); + } + } catch (err) { + throw new Error(`Invalid CALLE_TELEMETRY_URL: ${err.message}`); + } + return url; } return `${normalizeBaseUrl(baseUrl)}/api/ui-telemetry/track`; } @@ -122,7 +131,37 @@ export function formatIntegrationHeader(integrationContext) { return `${source}/${integration}/${version}`; } +const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + +function requireHttpsUrl(value, name) { + if (!value) { + return value; + } + let parsed; + try { + parsed = new URL(value); + } catch { + throw new Error(`${name} must be a valid URL`); + } + if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && LOOPBACK_HOSTNAMES.has(parsed.hostname))) { + throw new Error(`${name} must use HTTPS (got '${parsed.protocol}')`); + } + return value; +} + export function resolveRuntimeConfig(options = {}, env = process.env) { + if (options.baseUrl) { + requireHttpsUrl(options.baseUrl, "--base-url"); + } + if (options.serverUrl) { + requireHttpsUrl(options.serverUrl, "--server-url"); + } + if (options.brokerBaseUrl) { + requireHttpsUrl(options.brokerBaseUrl, "--broker-base-url"); + } + if (options.authBaseUrl) { + requireHttpsUrl(options.authBaseUrl, "--auth-base-url"); + } const baseUrl = normalizeBaseUrl(options.baseUrl || DEFAULT_BASE_URL); const channel = options.channel || DEFAULT_CHANNEL; const serverUrl = resolveServerUrl({ serverUrl: options.serverUrl, baseUrl, channel }); @@ -144,7 +183,14 @@ export function resolveRuntimeConfig(options = {}, env = process.env) { minTtlSeconds: Number(options.minTtlSeconds || DEFAULT_MIN_TTL_SECONDS), serverName: options.serverName || DEFAULT_SERVER_NAME, telemetryEnabled: resolveTelemetryEnabled(options, env), - telemetryUrl: resolveTelemetryUrl({ telemetryUrl: options.telemetryUrl, baseUrl }, env), + telemetryUrl: (() => { + try { + return resolveTelemetryUrl({ telemetryUrl: options.telemetryUrl, baseUrl }, env); + } catch (err) { + process.stderr.write(`[calle] Warning: ${err.message} — telemetry disabled.\n`); + return null; + } + })(), telemetryTimeoutSeconds: Number( firstOptionValue(options.telemetryTimeoutSeconds) || env.CALLE_TELEMETRY_TIMEOUT_SECONDS || diff --git a/packages/core/lib/broker-client.js b/packages/core/lib/broker-client.js index 412aa34..bac4934 100644 --- a/packages/core/lib/broker-client.js +++ b/packages/core/lib/broker-client.js @@ -95,16 +95,29 @@ export async function loginWithBroker(config, { const { pending, created } = await ensurePendingLogin(config, { fetchImpl, forceLogin }); if (created) { + let safeLoginUrl; + try { + const parsed = new URL(pending.login_url); + if (parsed.protocol !== "https:") { + throw new Error("non-https"); + } + safeLoginUrl = parsed.href; + } catch { + safeLoginUrl = null; + } stderr("Open the brokered login URL in your browser to continue:"); - stderr(pending.login_url); - if (!noBrowserOpen) { - await openBrowser(pending.login_url); + stderr(safeLoginUrl ?? "[authorization URL unavailable]"); + if (!noBrowserOpen && safeLoginUrl) { + await openBrowser(safeLoginUrl); } } const deadline = Date.now() + Number(config.pollTimeoutSeconds || 300) * 1000; + const maxAttempts = Number(config.pollMaxAttempts || 0) || 600; + let attempt = 0; let current = pending; - while (Date.now() < deadline) { + while (Date.now() < deadline && attempt < maxAttempts) { + attempt += 1; const statusPayload = await getBrokerSessionStatus(config, current, { fetchImpl }); const status = String(statusPayload.status || current.status || "PENDING").toUpperCase(); current = { diff --git a/packages/core/lib/cache.js b/packages/core/lib/cache.js index f484d62..c4ef0a2 100644 --- a/packages/core/lib/cache.js +++ b/packages/core/lib/cache.js @@ -3,7 +3,7 @@ import path from "node:path"; import crypto from "node:crypto"; export function serverHash(serverUrl) { - return crypto.createHash("md5").update(serverUrl, "utf8").digest("hex"); + return crypto.createHash("sha256").update(serverUrl, "utf8").digest("hex"); } export function tokenCachePath(cacheRoot, serverUrl) { diff --git a/packages/core/lib/mcp-client.js b/packages/core/lib/mcp-client.js index 8c45fc6..28fa113 100644 --- a/packages/core/lib/mcp-client.js +++ b/packages/core/lib/mcp-client.js @@ -50,56 +50,90 @@ function parseResponseBody(text) { return JSON.parse(text); } -async function requestJsonRpc(fetchImpl, url, { headers, payload, timeoutMs }) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - if (typeof timeout.unref === "function") { - timeout.unref(); +const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]); +const MAX_RETRY_ATTEMPTS = 3; +const RETRY_BASE_DELAY_MS = 500; + +function retryDelayMs(attempt, retryAfterHeader) { + const retryAfter = Number(retryAfterHeader); + if (retryAfterHeader && !Number.isNaN(retryAfter) && retryAfter > 0) { + return Math.min(retryAfter * 1000, 30000); } + return Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), 10000); +} - try { - const response = await fetchImpl(url, { - method: "POST", - headers, - body: JSON.stringify(payload), - signal: controller.signal, - }); - const text = await response.text(); - let body = null; - try { - body = parseResponseBody(text); - } catch { - body = null; - } - const responseHeaders = Object.fromEntries(response.headers.entries()); - - if (!response.ok) { - throw new McpHttpError(`MCP HTTP ${response.status} for ${payload.method}`, { - statusCode: response.status, - responseText: text, - payload: body, - headers: responseHeaders, - }); +async function requestJsonRpc(fetchImpl, url, { headers, payload, timeoutMs, sleepImpl = (ms) => new Promise((r) => setTimeout(r, ms)) }) { + let lastError; + for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + if (typeof timeout.unref === "function") { + timeout.unref(); } - if (body?.error) { - const error = body.error; - throw new McpHttpError(error.message || `Remote MCP error for ${payload.method}`, { - payload: error, - headers: responseHeaders, - code: "mcp_error", + try { + const response = await fetchImpl(url, { + method: "POST", + headers, + body: JSON.stringify(payload), + signal: controller.signal, }); - } + const text = await response.text(); + let body = null; + try { + body = parseResponseBody(text); + } catch { + body = null; + } + const responseHeaders = Object.fromEntries(response.headers.entries()); + + if (!response.ok) { + const err = new McpHttpError(`MCP HTTP ${response.status} for ${payload.method}`, { + statusCode: response.status, + responseText: text, + payload: body, + headers: responseHeaders, + }); + if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < MAX_RETRY_ATTEMPTS) { + lastError = err; + await sleepImpl(retryDelayMs(attempt, responseHeaders["retry-after"])); + continue; + } + throw err; + } + + if (body?.error) { + const error = body.error; + const rawMessage = typeof error.message === "string" ? error.message : null; + const safeMessage = rawMessage + ? rawMessage.slice(0, 200).replace(/[\r\n]+/g, " ").trim() + : `Remote MCP error for ${payload.method}`; + throw new McpHttpError(safeMessage, { + payload: error, + headers: responseHeaders, + code: "mcp_error", + }); + } - return { body, headers: responseHeaders }; - } catch (error) { - if (error?.name === "AbortError") { - throw new McpHttpError(`MCP request timed out for ${payload.method}`, { code: "http_error" }); + return { body, headers: responseHeaders }; + } catch (error) { + if (error?.name === "AbortError") { + throw new McpHttpError(`MCP request timed out for ${payload.method}`, { code: "http_error" }); + } + if (error instanceof McpHttpError) { + throw error; + } + lastError = error; + if (attempt < MAX_RETRY_ATTEMPTS) { + await sleepImpl(retryDelayMs(attempt, null)); + continue; + } + throw error; + } finally { + clearTimeout(timeout); } - throw error; - } finally { - clearTimeout(timeout); } + throw lastError; } function requireFetch(fetchImpl) { @@ -168,6 +202,11 @@ async function openMcpSession({ config, fetchImpl }) { const sessionId = initialize.headers["mcp-session-id"] || initialize.headers["Mcp-Session-Id"] || ""; const rpcHeaders = sessionId ? { ...commonHeaders, "mcp-session-id": sessionId } : commonHeaders; + const serverProtocolVersion = initialize.body?.result?.protocolVersion; + if (serverProtocolVersion && serverProtocolVersion !== MCP_PROTOCOL_VERSION) { + config._onProtocolVersionMismatch?.(serverProtocolVersion, MCP_PROTOCOL_VERSION); + } + await requestJsonRpc(fetchImpl, config.serverUrl, { headers: rpcHeaders, payload: buildJsonRpcPayload({