fix: cursor-plugin @latest tag, http retry, and login_url scheme validation - #47
fix: cursor-plugin @latest tag, http retry, and login_url scheme validation#47ashish993 wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR addresses three production-facing concerns in the CALL‑E integrations repo: keeping the Cursor plugin’s @latest git tag updated during releases, hardening broker-driven login URL handling, and improving resilience of core HTTP calls via retries.
Changes:
- Release workflow: initialize and update
@call-e/cursor-plugin@latesttag on publish. - Security: validate broker
login_urluseshttps:(except loopback) before opening in a browser. - Reliability: add exponential-backoff retries for transient HTTP status codes and network failures in
requestJson.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
.github/workflows/release.yml |
Adds Cursor plugin @latest tag initialization and update steps to match existing plugin workflows. |
packages/core/lib/http.js |
Implements retry loop with exponential backoff for transient HTTP and network failures. |
packages/core/lib/broker-client.js |
Adds login_url parsing + scheme validation with loopback exemption before returning the pending session. |
.changeset/fix-http-retry-and-login-url-validation.md |
Documents the patch-level release notes for the core changes. |
Comments suppressed due to low confidence (2)
packages/core/lib/http.js:80
- The retry catch-all will also retry non-network exceptions (e.g., JSON.parse SyntaxError or the "Expected JSON object" error thrown above). That can cause unexpected extra requests and makes invalid/malformed responses look like transient failures. Limit retries to actual fetch/network errors (e.g., TypeError from fetch / known errno codes), and let parse/validation errors fail fast.
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== "object") {
throw new Error(`Expected JSON object response for ${method} ${url}`);
}
return parsed;
} catch (error) {
if (error?.name === "AbortError") {
throw new Error(`Request timed out for ${method} ${url}`);
}
if (error instanceof HttpStatusError) {
throw error;
}
// Retry on network-level errors (ECONNRESET, ECONNREFUSED, etc.)
if (attempt < MAX_RETRY_ATTEMPTS) {
attempt++;
const delayMs = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
await sleep(delayMs);
continue;
}
packages/core/lib/http.js:55
- Retries are currently applied regardless of HTTP method. Since this helper is used with POST in broker-client, retrying after network failures / 5xx can duplicate non-idempotent operations (e.g., creating multiple sessions or exchanging twice). Consider restricting retries to idempotent methods (GET/HEAD) by default, or adding an explicit opt-in flag for retrying POST/other unsafe methods.
export async function requestJson(method, url, { headers = {}, json = undefined, timeoutSeconds = 15, fetchImpl = globalThis.fetch } = {}) {
if (typeof fetchImpl !== "function") {
throw new Error("global fetch is not available in this Node.js runtime");
}
let attempt = 0;
while (true) {
const controller = new AbortController();
const timeoutMs = Math.max(Math.ceil(Number(timeoutSeconds || 15) * 1000), 1000);
const timeout = setTimeout(() => controller.abort(), timeoutMs);
if (typeof timeout.unref === "function") {
timeout.unref();
}
try {
const response = await fetchImpl(url, {
method,
headers: {
Accept: "application/json",
...(json !== undefined ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: json !== undefined ? JSON.stringify(json) : undefined,
signal: controller.signal,
});
const text = await response.text();
if (!response.ok) {
const error = new HttpStatusError(`Client error '${response.status} ${response.statusText}' for url '${url}'`, {
statusCode: response.status,
responseText: text,
headers: Object.fromEntries(response.headers.entries()),
});
if (attempt < MAX_RETRY_ATTEMPTS && RETRYABLE_STATUS_CODES.has(response.status)) {
attempt++;
const delayMs = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
await sleep(delayMs);
continue;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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 isLoopback = parsedLoginUrl.hostname === "localhost" || parsedLoginUrl.hostname === "127.0.0.1" || parsedLoginUrl.hostname === "::1"; | ||
| if (parsedLoginUrl.protocol !== "https:" && !isLoopback) { | ||
| throw new Error(`Broker session login_url must use https:, got '${parsedLoginUrl.protocol}'`); | ||
| } |
| const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]); | ||
| const MAX_RETRY_ATTEMPTS = 2; | ||
| const RETRY_BASE_DELAY_MS = 500; | ||
|
|
||
| async function sleep(ms) { | ||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| } | ||
|
|
||
| export async function requestJson(method, url, { headers = {}, json = undefined, timeoutSeconds = 15, fetchImpl = globalThis.fetch } = {}) { | ||
| if (typeof fetchImpl !== "function") { | ||
| throw new Error("global fetch is not available in this Node.js runtime"); | ||
| } | ||
|
|
||
| const controller = new AbortController(); | ||
| const timeoutMs = Math.max(Math.ceil(Number(timeoutSeconds || 15) * 1000), 1000); | ||
| const timeout = setTimeout(() => controller.abort(), timeoutMs); | ||
| if (typeof timeout.unref === "function") { | ||
| timeout.unref(); | ||
| } | ||
| let attempt = 0; | ||
| while (true) { | ||
| const controller = new AbortController(); | ||
| const timeoutMs = Math.max(Math.ceil(Number(timeoutSeconds || 15) * 1000), 1000); | ||
| const timeout = setTimeout(() => controller.abort(), timeoutMs); | ||
| if (typeof timeout.unref === "function") { | ||
| timeout.unref(); | ||
| } | ||
|
|
||
| try { | ||
| const response = await fetchImpl(url, { | ||
| method, | ||
| headers: { | ||
| Accept: "application/json", | ||
| ...(json !== undefined ? { "Content-Type": "application/json" } : {}), | ||
| ...headers, | ||
| }, | ||
| body: json !== undefined ? JSON.stringify(json) : undefined, | ||
| signal: controller.signal, | ||
| }); | ||
| const text = await response.text(); | ||
| if (!response.ok) { | ||
| throw new HttpStatusError(`Client error '${response.status} ${response.statusText}' for url '${url}'`, { | ||
| statusCode: response.status, | ||
| responseText: text, | ||
| headers: Object.fromEntries(response.headers.entries()), | ||
| try { | ||
| const response = await fetchImpl(url, { | ||
| method, | ||
| headers: { | ||
| Accept: "application/json", | ||
| ...(json !== undefined ? { "Content-Type": "application/json" } : {}), | ||
| ...headers, | ||
| }, | ||
| body: json !== undefined ? JSON.stringify(json) : undefined, | ||
| signal: controller.signal, | ||
| }); | ||
| const text = await response.text(); | ||
| if (!response.ok) { | ||
| const error = new HttpStatusError(`Client error '${response.status} ${response.statusText}' for url '${url}'`, { | ||
| statusCode: response.status, | ||
| responseText: text, | ||
| headers: Object.fromEntries(response.headers.entries()), | ||
| }); | ||
| if (attempt < MAX_RETRY_ATTEMPTS && RETRYABLE_STATUS_CODES.has(response.status)) { | ||
| attempt++; | ||
| const delayMs = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1); | ||
| await sleep(delayMs); | ||
| continue; | ||
| } | ||
| throw error; | ||
| } | ||
| if (!text.trim()) { | ||
| return {}; | ||
| } | ||
| const parsed = JSON.parse(text); | ||
| if (!parsed || typeof parsed !== "object") { | ||
| throw new Error(`Expected JSON object response for ${method} ${url}`); | ||
| } | ||
| return parsed; | ||
| } catch (error) { | ||
| if (error?.name === "AbortError") { | ||
| throw new Error(`Request timed out for ${method} ${url}`); | ||
| } | ||
| if (error instanceof HttpStatusError) { | ||
| throw error; | ||
| } | ||
| // Retry on network-level errors (ECONNRESET, ECONNREFUSED, etc.) | ||
| if (attempt < MAX_RETRY_ATTEMPTS) { | ||
| attempt++; | ||
| const delayMs = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1); | ||
| await sleep(delayMs); | ||
| continue; | ||
| } |
7746aa5 to
00cf0a1
Compare
There was a problem hiding this comment.
Thanks for the PR. I would hold off on merging this until the retry behavior is tightened up. I found a few issues:
-
requestJsonnow retries every non-HttpStatusErrorthrown afterfetch, including local response parsing/validation failures. For example, a 200 response with malformed JSON is retried twice before throwing; I reproduced this with an invalid JSON response and saw 3 total requests. Those are not transient network failures, so malformed/invalid responses should fail fast. -
Retries apply to every HTTP method. This helper is used by broker
POST /sessionsandPOST /exchange, so a transient 5xx or network failure can duplicate non-idempotent operations. I would restrict retries to idempotent methods by default, or make POST retries an explicit opt-in where the caller knows it is safe. -
The IPv6 loopback exemption does not currently work as described. In Node,
new URL("http://[::1]/").hostnamereturns[::1], not::1, sohttp://[::1]/...is rejected even though the PR says loopback addresses are exempt.
Validation I ran locally on a temporary worktree:
pnpm testpnpm run check:versionspnpm -r --filter "./packages/*" run check
Those passed. I also checked the PR branch with gh pr checks 47, and GitHub currently reports no checks for this PR branch.
Ray-56
left a comment
There was a problem hiding this comment.
The latest commit addresses the previously reported retry issues: malformed JSON now fails fast, unsafe POST requests are not retried, and bracketed IPv6 loopback handling is covered. Two current-branch issues still prevent merge.
[P1] Rebase onto the current main and resolve the merge conflict without rolling package metadata or declarations backward. GitHub currently reports this PR as conflicting, and the branch still carries older Core package state than current main. After rebasing, rerun Core check/test/typecheck, pnpm run check:versions, and the pack dry-run so the reviewed result matches what would actually merge.
[P2] Split or remove the Cursor @latest release-workflow change unless maintainers explicitly decide to introduce that public alias. This repository currently documents automatic @latest maintenance for Codex and Claude only, and no Cursor install path consumes @call-e/cursor-plugin@latest. Combining a token-bearing release workflow change with Core retry/security behavior also makes this PR unnecessarily broad. Prefer a separate PR that updates docs/agent-integration-layout.md, explains the supported Cursor install path, and validates the release behavior; otherwise keep this PR limited to Core.
The existing @call-e/core patch changeset is appropriate for the runtime portion.
…dation - Add Initialize Cursor latest ref and Update Cursor latest ref steps to the release workflow, matching the existing Codex and Claude plugin steps. The cursor-plugin @latest tag was never initialized or updated on publish. - Add exponential backoff retry in requestJson for transient HTTP errors (429, 500, 502, 503, 504) and network-level failures. Retries up to 2 times with 500ms / 1000ms delays before re-throwing. - Validate that the broker server's login_url uses https: before passing it to openBrowser / spawn. Loopback addresses (localhost, 127.0.0.1, ::1) are exempt to preserve local development and test compatibility.
P1: replace Windows cmd /c start with shell-free rundll32 opener to prevent command injection via OAuth URL special characters (&) P1: add cache migration path (legacyServerHash + migrateTokenCache) so the md5→sha256 serverHash upgrade does not strand existing tokens on disk; wire migrateTokenCache into loginWithBroker and cover with 3 tests P1: restore isSafeBrokerLoginUrl and sanitizeBrokerLoginUrl to @call-e/core/broker-client; add broker-client.d.ts type declarations; wire types into package.json exports so the runtime and published type surface agree P2: share isSafeBrokerLoginUrl predicate between openBrowser and broker validator so http: loopback URLs are accepted end-to-end P2: remove cache_path / pending_cache_path from all public JSON outputs (publicPendingLoginPayload, publicLoginPayload, statusPayload, auth logout); omit --cache-root from next_command / login_command when using the default location to prevent home directory path leaks in agent-visible JSON P2: add @call-e/cli patch changeset P2: revert Cursor @latest release-workflow steps and restore pinned action SHAs in ci.yml and release.yml (Cursor @latest belongs in a separate PR)
6670cf8 to
4aa0908
Compare
Ray-56
left a comment
There was a problem hiding this comment.
Thanks for the update. The current head is still blocked.
[P1] The Windows browser opener still spawns the unqualified executable name rundll32.exe. On Windows this can resolve through the current working directory before System32, so a planted executable in an untrusted checkout can run during calle auth login. Use the fully qualified %SystemRoot%\System32\rundll32.exe path and test the production command-selection path, or remove this overlapping change and rely on PR #71.
[P1] The branch no longer contains the Cursor @latest initialization/update behavior described by its title and body. Instead it now mixes CLI path redaction and browser opening, cache migration, Core retry/type changes, CI runtime changes, and action pinning. Please rebuild this branch from current main and keep it to one objective. If the Cursor alias is still intended, use a dedicated PR with the required integration-layout documentation and release-policy decision.
[P2] The newly added cache migration is currently MD5-to-MD5: both serverHash and legacyServerHash use MD5, so the migration is a no-op. Remove it unless this PR intentionally changes the current hash and supplies complete migration coverage.
GitHub CI did not execute for this head: the run ended as action_required with zero jobs. Please rerun CI after the branch is narrowed. The Core and CLI patch changesets are present, but their final scope should match the rewritten PR.
Changes
Bug fix – release workflow
Initialize Cursor latest refandUpdate Cursor latest refsteps to.github/workflows/release.yml. The@call-e/cursor-plugin@latestgit tag was never initialized or updated on publish, unlike the Codex and Claude plugin equivalents.Security – broker login_url validation (
packages/core/lib/broker-client.js)login_urlreturned by the broker server useshttps:before it is passed toopenBrowser/spawn. Loopback addresses are exempt to preserve local-dev and test compatibility.Reliability – HTTP retry with exponential backoff (
packages/core/lib/http.js)Testing
All 40 existing unit and e2e tests pass (
pnpm test && pnpm check).