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
5 changes: 5 additions & 0 deletions .changeset/fix-http-retry-and-login-url-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@call-e/core": patch
---

Add exponential backoff retry for transient HTTP errors (429, 5xx) and network failures in `requestJson`. Validate that `login_url` returned by the broker server uses the `https:` scheme before opening it in a browser (loopback addresses are exempt to support local development).
5 changes: 5 additions & 0 deletions .changeset/fix-path-redaction-and-windows-opener.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@call-e/cli": patch
---

Fix Windows shell injection in browser opener (use rundll32 instead of cmd /c start), remove home-directory paths from public JSON outputs (cache_path, pending_cache_path), omit --cache-root from suggested commands when using the default location, and restore safe URL sanitization in all login-URL output fields.
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
89 changes: 56 additions & 33 deletions packages/cli/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
tokenCachePath,
tokenIsUsable,
} from "./cache.js";
import { DEFAULT_BASE_URL, DEFAULT_CHANNEL, DEFAULT_CLIENT_NAME, DEFAULT_SCOPE, resolveRuntimeConfig } from "./config.js";
import { ensurePendingLogin, loginWithBroker } from "./broker-client.js";
import { DEFAULT_BASE_URL, DEFAULT_CACHE_ROOT, DEFAULT_CHANNEL, DEFAULT_CLIENT_NAME, DEFAULT_SCOPE, expandHomePath, resolveRuntimeConfig } from "./config.js";
import { ensurePendingLogin, isSafeBrokerLoginUrl, loginWithBroker, sanitizeBrokerLoginUrl } from "./broker-client.js";
import {
AuthRequiredError,
McpHttpError,
Expand All @@ -27,13 +27,14 @@ class InvalidArgumentsError extends Error {
}

export function preAuthHelpMessage(loginUrl) {
const safeUrl = sanitizeBrokerLoginUrl(loginUrl) ?? "[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 @@ -342,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`);
}
}
Expand All @@ -375,26 +385,26 @@ function postAuthAssistantHint(status) {
}

function preAuthAssistantHint(loginUrl) {
if (typeof loginUrl !== "string" || !loginUrl.trim()) {
const safeLoginUrl = sanitizeBrokerLoginUrl(loginUrl);
if (!safeLoginUrl) {
return null;
}
return {
type: PRE_AUTH_HELP_HINT_TYPE,
message: preAuthHelpMessage(loginUrl.trim()),
message: preAuthHelpMessage(safeLoginUrl),
};
}

function publicPendingLoginPayload({ config, cachePath, pendingPath, pending, created }) {
const assistantHint = preAuthAssistantHint(pending.login_url);
const loginUrl = sanitizeBrokerLoginUrl(pending.login_url);
const assistantHint = preAuthAssistantHint(loginUrl);
return {
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,
...(loginUrl ? { login_url: loginUrl } : {}),
...(assistantHint ? { assistant_hint: assistantHint } : {}),
};
}
Expand All @@ -405,8 +415,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 @@ -417,16 +425,15 @@ function statusPayload(config) {
const pendingPath = pendingCachePath(config.cacheRoot, config.serverUrl);
const cacheDocument = readJson(cachePath);
const pendingDocument = readJson(pendingPath);
const pendingLoginUrl = sanitizeBrokerLoginUrl(typeof pendingDocument?.login_url === "string" ? pendingDocument.login_url : 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 @@ -454,7 +461,7 @@ function shellQuote(value) {
}

function loginCommand(config) {
return [
const parts = [
"calle",
"auth",
"login",
Expand All @@ -466,15 +473,17 @@ 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) {
return [
const parts = [
"calle",
"call",
"status",
Expand All @@ -483,11 +492,13 @@ function callStatusCommand(config, runId, timezone = null) {
...(timezone ? ["--timezone", timezone] : []),
"--server-url",
config.serverUrl,
"--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 isActivePendingLogin(pending) {
Expand Down Expand Up @@ -918,10 +929,24 @@ 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) => {
if (!isSafeBrokerLoginUrl(url)) {
throw new Error(`Refusing to open unsafe URL: expected https: or http: loopback`);
}
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];
const child = spawn(command, args, { detached: true, stdio: "ignore" });
let command;
let args;
if (process.platform === "darwin") {
command = "open";
args = [url];
} else if (process.platform === "win32") {
// Use rundll32 to avoid cmd.exe shell processing of URL special chars (& etc.)
command = "rundll32.exe";
args = ["url.dll,FileProtocolHandler", url];
} else {
command = "xdg-open";
args = [url];
}
const child = spawn(command, args, { shell: false, detached: true, stdio: "ignore" });
child.unref();
});

Expand Down Expand Up @@ -1034,8 +1059,6 @@ export async function runCli(argv, deps = {}) {
removeFile(pendingPath);
writeJson(stdout, {
server_url: config.serverUrl,
cache_path: cachePath,
pending_cache_path: pendingPath,
removed_cache: cacheDocument !== null,
removed_pending: pendingDocument !== null,
});
Expand Down
13 changes: 13 additions & 0 deletions packages/core/lib/broker-client.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
/**
* Returns true if rawUrl is a safe URL that can be opened in a browser:
* - any https: URL, or
* - an http: URL whose hostname is a loopback address (localhost, 127.0.0.1, ::1).
*/
export function isSafeBrokerLoginUrl(rawUrl: unknown): boolean;

/**
* Returns the canonicalised href of rawUrl when isSafeBrokerLoginUrl returns
* true, or null otherwise.
*/
export function sanitizeBrokerLoginUrl(rawUrl: unknown): string | null;

import type { JsonObject, PendingLoginDocument, TokenDocument } from "./cache.js";

export interface BrokerRequestConfig {
Expand Down
44 changes: 42 additions & 2 deletions packages/core/lib/broker-client.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,30 @@
import { pendingCachePath, pendingIsExpired, readPendingLogin, removeFile, tokenCachePath, tokenIsUsable, writePrivateJson, readJson } from "./cache.js";
import { migrateTokenCache, pendingCachePath, pendingIsExpired, readPendingLogin, removeFile, tokenCachePath, tokenIsUsable, writePrivateJson, readJson } from "./cache.js";
import { INTEGRATION_HEADER, SESSION_SECRET_HEADER } from "./constants.js";
import { HttpStatusError, requestJson } from "./http.js";

const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);

function normalizeHostname(hostname) {
return hostname.replace(/^\[/u, "").replace(/\]$/u, "").toLowerCase();
}

export function isSafeBrokerLoginUrl(rawUrl) {
try {
const parsed = new URL(rawUrl);
if (parsed.protocol === "https:") {
return true;
}

return parsed.protocol === "http:" && LOOPBACK_HOSTNAMES.has(normalizeHostname(parsed.hostname));
} catch {
return false;
}
}

export function sanitizeBrokerLoginUrl(rawUrl) {
return isSafeBrokerLoginUrl(rawUrl) ? new URL(rawUrl).href : null;
}

function integrationHeaders(config) {
return config?.integrationHeader ? { [INTEGRATION_HEADER]: config.integrationHeader } : {};
}
Expand Down Expand Up @@ -98,10 +121,23 @@ export async function exchangeBrokerSession(config, pending, { fetchImpl = globa
}

export function normalizePendingSession(sessionPayload) {
const loginUrl = String(sessionPayload.login_url);
let parsedLoginUrl;
try {
parsedLoginUrl = new URL(loginUrl);
} catch {
throw new Error("Broker session returned an invalid login_url");
}
const hostname = parsedLoginUrl.hostname.replace(/^\[/u, "").replace(/\]$/u, "").toLowerCase();
const isLoopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
if (parsedLoginUrl.protocol !== "https:" && !isLoopback) {
throw new Error(`Broker session login_url must use https:, got '${parsedLoginUrl.protocol}'`);
}
Comment on lines +124 to +135

return {
session_id: String(sessionPayload.session_id),
session_secret: String(sessionPayload.session_secret),
login_url: String(sessionPayload.login_url),
login_url: loginUrl,
status: String(sessionPayload.status || "PENDING").toUpperCase(),
created_at: new Date().toISOString(),
expires_at: sessionPayload.expires_at ? String(sessionPayload.expires_at) : null,
Expand Down Expand Up @@ -137,6 +173,10 @@ export async function loginWithBroker(config, {
noBrowserOpen = false,
stderr = () => {},
} = {}) {
// Migrate token files from legacy cache directory (md5) to current (sha256)
// when the hash algorithm was upgraded. No-op when the two paths are identical.
migrateTokenCache(config.cacheRoot, config.serverUrl);

const cachePath = tokenCachePath(config.cacheRoot, config.serverUrl);
const pendingPath = pendingCachePath(config.cacheRoot, config.serverUrl);
const cached = readJson(cachePath);
Expand Down
Loading