Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ logs/
.vscode/
.cursorignore
.cursorindexingignore

# Local notes
run.md
81 changes: 63 additions & 18 deletions packages/cli/lib/cli.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 ✨
Expand Down Expand Up @@ -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`);
}
Comment on lines +353 to +356
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`);
}
}
Expand Down Expand Up @@ -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,
Expand All @@ -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 } : {}),
};
Expand All @@ -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 } : {}),
};
}

Expand Down Expand Up @@ -445,7 +468,7 @@ function shellQuote(value) {
}

function loginCommand(config) {
return [
const parts = [
"calle",
"auth",
"login",
Expand All @@ -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) {
Expand Down Expand Up @@ -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");
}
Comment on lines +651 to +653

const goal = requireStringOption(options, "goal", "--goal");
if (goal.length > 2000) {
throw new InvalidArgumentsError("--goal: maximum 2000 characters");
}
Comment on lines +655 to +658

const args = {
to_phones: toPhones,
goal: requireStringOption(options, "goal", "--goal"),
goal,
};
const language = optionalStringOption(options, "language");
const region = optionalStringOption(options, "region");
Expand Down Expand Up @@ -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];
Expand All @@ -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");
Expand Down
50 changes: 48 additions & 2 deletions packages/cli/lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}
Expand Down Expand Up @@ -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 });
Comment on lines 152 to 167
Expand All @@ -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 ||
Expand Down
21 changes: 17 additions & 4 deletions packages/core/lib/broker-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/lib/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Comment on lines 5 to 7

export function tokenCachePath(cacheRoot, serverUrl) {
Expand Down
Loading
Loading