Skip to content

fix: cursor-plugin @latest tag, http retry, and login_url scheme validation#47

Open
ashish993 wants to merge 1 commit into
CALLE-AI:mainfrom
ashish993:fix/cursor-plugin-latest-tag-and-security
Open

fix: cursor-plugin @latest tag, http retry, and login_url scheme validation#47
ashish993 wants to merge 1 commit into
CALLE-AI:mainfrom
ashish993:fix/cursor-plugin-latest-tag-and-security

Conversation

@ashish993

Copy link
Copy Markdown

Changes

Bug fix – release workflow

  • Add Initialize Cursor latest ref and Update Cursor latest ref steps to .github/workflows/release.yml. The @call-e/cursor-plugin@latest git 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)

  • Validate that the login_url returned by the broker server uses https: before it is passed to openBrowser/spawn. Loopback addresses are exempt to preserve local-dev and test compatibility.

Reliability – HTTP retry with exponential backoff (packages/core/lib/http.js)

  • Retry transient HTTP errors (429, 500, 502, 503, 504) and network-level failures up to 2 times with 500 ms / 1000 ms delays before re-throwing.

Testing

All 40 existing unit and e2e tests pass (pnpm test && pnpm check).

Copilot AI review requested due to automatic review settings May 22, 2026 17:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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@latest tag on publish.
  • Security: validate broker login_url uses https: (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.

Comment on lines +53 to +63
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}'`);
}
Comment thread packages/core/lib/http.js
Comment on lines +11 to +80
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;
}
…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.
@ashish993 ashish993 force-pushed the fix/cursor-plugin-latest-tag-and-security branch from 7746aa5 to 00cf0a1 Compare May 24, 2026 03:50

@Ray-56 Ray-56 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR. I would hold off on merging this until the retry behavior is tightened up. I found a few issues:

  1. requestJson now retries every non-HttpStatusError thrown after fetch, 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.

  2. Retries apply to every HTTP method. This helper is used by broker POST /sessions and POST /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.

  3. The IPv6 loopback exemption does not currently work as described. In Node, new URL("http://[::1]/").hostname returns [::1], not ::1, so http://[::1]/... is rejected even though the PR says loopback addresses are exempt.

Validation I ran locally on a temporary worktree:

  • pnpm test
  • pnpm run check:versions
  • pnpm -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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants