Skip to content

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

Open
ashish993 wants to merge 3 commits 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 3 commits 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;
}
@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.

@ashish993 ashish993 left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Review this. @Ray-56

@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.

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)
@ashish993
ashish993 force-pushed the fix/cursor-plugin-latest-tag-and-security branch from 6670cf8 to 4aa0908 Compare August 3, 2026 13:52
@ashish993
ashish993 requested a review from Ray-56 August 3, 2026 13:56

@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 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.

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