diff --git a/CHANGELOG.md b/CHANGELOG.md index c75386b..b0de9a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Add strictly opt-in Cursor Cloud pull-request controls: `--cursor-cloud-auto-create-pr` / `PI_CURSOR_CLOUD_AUTO_CREATE_PR` / `cloud.autoCreatePR` and `--cursor-cloud-skip-reviewer-request` / `PI_CURSOR_CLOUD_SKIP_REVIEWER_REQUEST` / `cloud.skipReviewerRequest`. Unset controls remain omitted from SDK options, project config is excluded, and local runtime behavior is unchanged. - Add strictly opt-in local-agent HTTP/1.1/SSE compatibility through `PI_CURSOR_HTTP_1_1`, `/cursor-http [on|off|toggle]`, and user `cursor-sdk.json` `local.useHttp1ForAgent`, resolved as session > environment > user > unset. Explicit values configure the Cursor SDK before local agent creation, session shutdown clears extension-owned SDK transport state before module reload, transport choices split the pooled agent key, and enabled local status shows `http1`; cloud and unset/default behavior remain unchanged. The pool-key shape change makes pre-upgrade local resume handles rebootstrap once; superseded handles remain eligible for explicit `/cursor-local-resume-cleanup`. +### Fixed + +- Initialize `CURSOR_RIPGREP_PATH` from the installed Cursor SDK platform package before local agent creation, including nested npm dependency layouts, so Cursor-native Grep/Glob can use the bundled executable. + ### Changed - Expand the maintainer-only `npm run smoke:cloud` release gate to create, seed, and delete a private UUID-named GitHub repository while proving cancel, starting-ref branch, direct-push, missing-branch, lifecycle-delete, exact agent cleanup, and authenticated repository-deletion contracts. Add fail-closed `SIGINT`/`SIGTERM` handling, including a real event-loop checkpoint before the atomic evidence commit and handlers retained through process teardown, plus account-conditional artifact/raw-usage observations. The gate now requires `gh` authorization to create/push/delete private repositories; product runtime behavior and defaults are unchanged. diff --git a/src/cursor-ripgrep-path.ts b/src/cursor-ripgrep-path.ts index a04f863..0de68dc 100644 --- a/src/cursor-ripgrep-path.ts +++ b/src/cursor-ripgrep-path.ts @@ -1,14 +1,19 @@ import { accessSync, constants } from "node:fs"; import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; -const require = createRequire(import.meta.url); const RIPGREP_ENV = "CURSOR_RIPGREP_PATH"; -export function resolveBundledCursorRipgrepPath(): string | undefined { +export function resolveBundledCursorRipgrepPath( + fromModuleUrl: string | URL = import.meta.url, +): string | undefined { try { + const require = createRequire(fromModuleUrl); const platformPackage = `@cursor/sdk-${process.platform}-${process.arch}`; - const packageDirectory = dirname(require.resolve(`${platformPackage}/package.json`)); + const sdkEntry = require.resolve("@cursor/sdk"); + const packageDirectory = dirname( + require.resolve(`${platformPackage}/package.json`, { paths: [dirname(sdkEntry)] }), + ); const ripgrepPath = join(packageDirectory, "bin", process.platform === "win32" ? "rg.exe" : "rg"); accessSync(ripgrepPath, constants.X_OK); return ripgrepPath; @@ -19,7 +24,7 @@ export function resolveBundledCursorRipgrepPath(): string | undefined { export function ensureCursorRipgrepPath(): string | undefined { const configuredPath = process.env[RIPGREP_ENV]; - if (configuredPath) return configuredPath; + if (configuredPath && isAbsolute(configuredPath)) return configuredPath; const bundledPath = resolveBundledCursorRipgrepPath(); if (bundledPath) process.env[RIPGREP_ENV] = bundledPath; diff --git a/test/cursor-provider-stream-config.test.ts b/test/cursor-provider-stream-config.test.ts index 73b5f04..7d2012e 100644 --- a/test/cursor-provider-stream-config.test.ts +++ b/test/cursor-provider-stream-config.test.ts @@ -29,7 +29,7 @@ import { __testUtils as cursorSessionScopeTestUtils } from "../src/cursor-sessio import type { Context } from "@earendil-works/pi-ai/compat"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; async function setCursorModeForProviderTest(mode: "agent" | "plan"): Promise { const pi = createPiHarness({ flagValues: { "cursor-mode": mode } }); @@ -58,6 +58,32 @@ describe("streamCursor prompt and model config", () => { expect(mockedCreate.mock.calls[0][0].local).toEqual({ cwd: process.cwd(), settingSources: ["all"] }); }); + it("sets absolute CURSOR_RIPGREP_PATH before local Agent.create", async () => { + delete process.env.CURSOR_RIPGREP_PATH; + let pathAtCreate: string | undefined; + mockedCreate.mockImplementation(async () => { + pathAtCreate = process.env.CURSOR_RIPGREP_PATH; + return asMockSdkAgent({ + send: vi.fn().mockResolvedValue({ + id: "run-1", + agentId: "agent-1", + status: "finished", + wait: vi.fn().mockResolvedValue({ id: "run-1", status: "finished" }), + cancel: vi.fn(), + supports: () => true, + unsupportedReason: () => undefined, + }), + }); + }); + + await collectEvents(streamCursor(makeModel("gpt-5.5@1m"), makeContext(), { apiKey: "test-key" })); + + expect(mockedCreate).toHaveBeenCalledTimes(1); + expect(pathAtCreate).toBeTruthy(); + expect(isAbsolute(pathAtCreate!)).toBe(true); + expect(pathAtCreate!.replaceAll("\\", "/")).toContain(`@cursor/sdk-${process.platform}-${process.arch}`); + }); + it("passes enabled local safety controls from env into Agent.create", async () => { process.env.PI_CURSOR_AUTO_REVIEW = "1"; process.env.PI_CURSOR_SANDBOX = "true"; diff --git a/test/cursor-ripgrep-path.test.ts b/test/cursor-ripgrep-path.test.ts index f4cbd05..4cc3adb 100644 --- a/test/cursor-ripgrep-path.test.ts +++ b/test/cursor-ripgrep-path.test.ts @@ -1,4 +1,18 @@ -import { accessSync, constants } from "node:fs"; +import { + accessSync, + chmodSync, + constants, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { ensureCursorRipgrepPath, @@ -6,6 +20,8 @@ import { } from "../src/cursor-ripgrep-path.js"; const originalRipgrepPath = process.env.CURSOR_RIPGREP_PATH; +const platformPackage = `@cursor/sdk-${process.platform}-${process.arch}`; +const rgBinaryName = process.platform === "win32" ? "rg.exe" : "rg"; afterEach(() => { if (originalRipgrepPath === undefined) delete process.env.CURSOR_RIPGREP_PATH; @@ -17,11 +33,65 @@ describe("Cursor ripgrep path", () => { const ripgrepPath = resolveBundledCursorRipgrepPath(); if (!ripgrepPath) throw new Error("Expected the installed Cursor SDK platform package to include ripgrep"); - expect(ripgrepPath.replaceAll("\\", "/")).toContain(`@cursor/sdk-${process.platform}-${process.arch}`); + expect(ripgrepPath.replaceAll("\\", "/")).toContain(platformPackage); expect(() => accessSync(ripgrepPath, constants.X_OK)).not.toThrow(); }); - it("configures an empty path without overriding an existing value", () => { + it("resolves a platform package nested under @cursor/sdk/node_modules", () => { + const root = mkdtempSync(join(tmpdir(), "pi-cursor-ripgrep-nested-")); + try { + const consumerDir = join(root, "consumer"); + const consumerModule = join(consumerDir, "index.js"); + const sdkDir = join(consumerDir, "node_modules", "@cursor", "sdk"); + const nestedPlatformDir = join(sdkDir, "node_modules", "@cursor", `sdk-${process.platform}-${process.arch}`); + const nestedBinDir = join(nestedPlatformDir, "bin"); + const nestedRg = join(nestedBinDir, rgBinaryName); + + mkdirSync(nestedBinDir, { recursive: true }); + writeFileSync(join(sdkDir, "package.json"), JSON.stringify({ name: "@cursor/sdk", version: "1.0.23", main: "index.js" })); + writeFileSync(join(sdkDir, "index.js"), "module.exports = {};\n"); + writeFileSync( + join(nestedPlatformDir, "package.json"), + JSON.stringify({ name: platformPackage, version: "1.0.23", bin: { rg: `bin/${rgBinaryName}` } }), + ); + writeFileSync(nestedRg, "#!/bin/sh\nexit 0\n"); + chmodSync(nestedRg, 0o755); + writeFileSync(consumerModule, "export {};\n"); + + // Nested only — no hoisted platform package beside @cursor/sdk. + const consumerRequire = createRequire(consumerModule); + expect(() => consumerRequire.resolve(`${platformPackage}/package.json`)).toThrow(); + expect(consumerRequire.resolve("@cursor/sdk")).toBe(realpathSync(join(sdkDir, "index.js"))); + + const resolved = resolveBundledCursorRipgrepPath(pathToFileURL(consumerModule)); + expect(resolved).toBe(realpathSync(nestedRg)); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("locks installed @cursor/sdk 1.0.23 Agent.create ripgrep contract", () => { + const require = createRequire(import.meta.url); + const sdkEntry = require.resolve("@cursor/sdk"); + const sdkRoot = join(dirname(sdkEntry), "..", ".."); + const sdkPackage = JSON.parse(readFileSync(join(sdkRoot, "package.json"), "utf8")) as { version: string }; + expect(sdkPackage.version).toBe("1.0.23"); + + // Agent.create lives in the local-runtime chunk (esm/357.js beside cjs entry's sibling esm). + const bundle = readFileSync(join(sdkRoot, "dist", "esm", "357.js"), "utf8"); + + // Absolute CURSOR_RIPGREP_PATH wins; otherwise search from process.argv[1]; then configure. + expect(bundle).toMatch( + /process\.env\.CURSOR_RIPGREP_PATH;w=\w+&&\(0,\w+\.isAbsolute\)\(\w+\)\?\w+:W\(\w+\),\w+\|\|\(\w+=\(0,\w+\.Qd\)\(\)\),\w+&&\(0,\w+\.J\)\(\w+\)/, + ); + expect(bundle).toContain("if(!process.argv[1])return;"); + expect(bundle).toContain("node_modules"); + expect(bundle).toContain("`@cursor/sdk-${t}`"); + expect(bundle).toContain('throw new Error("configureRipgrepPath: path must not be empty")'); + expect(bundle).toContain("Ripgrep path not configured. Call configureRipgrepPath() at startup."); + }); + + it("configures an empty path without overriding an existing absolute value", () => { process.env.CURSOR_RIPGREP_PATH = ""; const bundledPath = ensureCursorRipgrepPath(); expect(process.env.CURSOR_RIPGREP_PATH).toBe(bundledPath);