From c23ce123f5e2f84919a60fbaa558dfcc7fea4c36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D0=B8=D1=8F?= Date: Sun, 25 Oct 2026 23:18:15 +0200 Subject: [PATCH 1/5] feat: add cursor pinned project research collector Integrates independently reviewed client leaf 41eb69c9a6a4d2a61f739c473335698fb0dd20f3. Native runtime qualification remains separate. --- scripts/project_native_collectors/cursor.py | 27 ++++++++ scripts/test_project_native_cursor.py | 73 +++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 scripts/project_native_collectors/cursor.py create mode 100644 scripts/test_project_native_cursor.py diff --git a/scripts/project_native_collectors/cursor.py b/scripts/project_native_collectors/cursor.py new file mode 100644 index 000000000..fdd297cb4 --- /dev/null +++ b/scripts/project_native_collectors/cursor.py @@ -0,0 +1,27 @@ +"""Immutable Cursor distribution candidate; acquisition does not run its payload. + +The shared hosted runner may download and inventory this archive. Its launcher, +bundled Node runtime, support files and digests are deliberately not inferred +from the installer script. A reviewed pin amendment must freeze those measured +identities before a native observation collector can replace this refusal. +""" + +CLIENT = "cursor" +VERSION = "2026.10.22-27dcf5f" +ACQUISITION_ONLY = True +ACQUISITION = { + "url": "https://downloads.cursor.com/lab/2026.10.22-27dcf5f/linux/x64/agent-cli-package.tar.gz", + "integrity": None, + "max_bytes": 256 * 1024 * 1024, + "format": "tar.gz", + "entrypoint": None, + "runtime": "node", +} + + +def collect(ctx): + """Refuse native work, including context access, before measured pin review.""" + raise RuntimeError( + "Cursor is acquisition-only: archive, launcher, Node and support-file " + "identities require a reviewed pin amendment before native observation" + ) diff --git a/scripts/test_project_native_cursor.py b/scripts/test_project_native_cursor.py new file mode 100644 index 000000000..b86780904 --- /dev/null +++ b/scripts/test_project_native_cursor.py @@ -0,0 +1,73 @@ +"""Inert contracts for the Cursor acquisition boundary; no vendor execution.""" + +import importlib.util +from pathlib import Path +import subprocess +import unittest +from unittest.mock import patch +from urllib.parse import urlsplit +import urllib.request + + +LEAF = Path(__file__).with_name("project_native_collectors") / "cursor.py" + + +def load_leaf(): + spec = importlib.util.spec_from_file_location("cursor_acquisition_contract", LEAF) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class ForbiddenContext: + def __getattribute__(self, name): + raise AssertionError("acquisition-only collector accessed context: " + name) + + +class CursorAcquisitionContract(unittest.TestCase): + def test_import_and_collect_never_start_a_process_or_download(self): + with ( + patch.object(subprocess, "Popen", side_effect=AssertionError("process execution")) as process, + patch.object(urllib.request, "urlopen", side_effect=AssertionError("network acquisition")) as download, + ): + leaf = load_leaf() + for context in (None, ForbiddenContext(), {"native_opt_in": True, "hosted": True}): + with self.subTest(context_type=type(context).__name__): + with self.assertRaisesRegex(RuntimeError, "acquisition-only.*reviewed pin amendment"): + leaf.collect(context) + process.assert_not_called() + download.assert_not_called() + + def test_candidate_has_no_execution_identity_before_inventory(self): + leaf = load_leaf() + self.assertIs(leaf.ACQUISITION_ONLY, True) + self.assertIsNone(leaf.ACQUISITION["integrity"]) + self.assertIsNone(leaf.ACQUISITION["entrypoint"]) + self.assertNotIn("binary_sha256", leaf.ACQUISITION) + self.assertNotIn("expected_bytes", leaf.ACQUISITION) + # A flag change alone must not accidentally expose native execution. + leaf.ACQUISITION_ONLY = False + with self.assertRaisesRegex(RuntimeError, "acquisition-only"): + leaf.collect(ForbiddenContext()) + + def test_acquisition_is_bounded_and_uses_an_immutable_official_candidate(self): + leaf = load_leaf() + candidate = leaf.ACQUISITION + url = urlsplit(candidate["url"]) + self.assertEqual(url.scheme, "https") + self.assertEqual(url.netloc, "downloads.cursor.com") + self.assertFalse(url.query or url.fragment or url.username or url.password) + self.assertRegex(leaf.VERSION, r"\A\d{4}\.\d{2}\.\d{2}-[0-9a-f]{7}\Z") + self.assertEqual( + url.path.split("/"), + ["", "lab", leaf.VERSION, "linux", "x64", "agent-cli-package.tar.gz"], + ) + self.assertIs(type(candidate["max_bytes"]), int) + self.assertGreater(candidate["max_bytes"], 0) + self.assertLessEqual(candidate["max_bytes"], 256 * 1024 * 1024) + self.assertEqual(candidate["format"], "tar.gz") + self.assertEqual(candidate["runtime"], "node") + + +if __name__ == "__main__": + unittest.main() From 1eb5be734a106c91c921cec8b41c23011cc83c67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D0=B8=D1=8F?= Date: Thu, 29 Oct 2026 15:45:23 +0200 Subject: [PATCH 2/5] test(cursor): capture bounded native help and empty MCP lists Run seven fixed read-only probes against the reviewed official wrapper on actual disposable hosted runners. Preserve the complete pinned runtime closure and ordinary environment, and keep project lifecycle, MCP execution, authentication and model claims unqualified. --- .../remaining-native-qualification.md | 27 +++++++ scripts/project_native_collectors/cursor.py | 47 +++++++++++-- scripts/test_project_native_cursor.py | 70 ++++++++++++++++--- 3 files changed, 127 insertions(+), 17 deletions(-) diff --git a/docs/plans/project-install-e2e/remaining-native-qualification.md b/docs/plans/project-install-e2e/remaining-native-qualification.md index 5b44307f3..79304be6b 100644 --- a/docs/plans/project-install-e2e/remaining-native-qualification.md +++ b/docs/plans/project-install-e2e/remaining-native-qualification.md @@ -1998,3 +1998,30 @@ runtime-file guards. Other clients retain their original urllib transport. The collector remains acquisition-only and refuses native context access even after its pin is frozen. All project/MCP/authentication/model flags remain false; actual hosted acquisition and a separately reviewed read-only collector remain required. + +### Cursor first read-only help and empty-component capture (2026-10-29) + +After reviewed acquisition publicationfd1df496, enable only acquire/help research +phases with the same exact archive, native shell launcher, bundled Node and full +445-file support closure. The pinned index version callback writes exactly +`2026.10.22-27dcf5f` plus LF. Its command table defines top-level help, MCP help, +plugin help and MCP list; it does not define a skills-list command. The exact +2297-byte2601.index.js MCP list module SHA256 is +`6635570334069b0ff14fc4b4e9d629f16478f1a9e8b13c626182874437eaf850`. + +Run the fixed version/top-help/MCP-help/plugin-help table in A, then read-only +empty MCP lists in A, B and the outside directory. Reuse existing30-second +supervised probes, the original shared360-second budget, closed environment, +full runtime identity guards and bounded original stdout/stderr capture. Seed +no native configuration, disabled store, approval, credential or provider. Add +no environment override, updater assumption, login, model, installation, plugin +marketplace mutation, skills command or production detection change. + +Only the exact version, ordinary successful processes and nonempty strict-UTF8 +help can support cursor.help. Retain all three component streams as opaque first +captures; do not infer an empty-catalog grammar, configuration preservation, +server initialization or scope/lifecycle from them. Private disposable-HOME +startup/cache effects remain unqualified and are not adopted as trusted config. +All five project/MCP flags stay false, with scope, authentication and real-model +work explicitly unobserved. Independent actual hosted raw review must establish +the next supported component contract before additional qualification work. diff --git a/scripts/project_native_collectors/cursor.py b/scripts/project_native_collectors/cursor.py index 6fb2f226e..586e3b0d3 100644 --- a/scripts/project_native_collectors/cursor.py +++ b/scripts/project_native_collectors/cursor.py @@ -1,8 +1,8 @@ -"""Measured immutable Cursor distribution; acquisition never runs its payload.""" +"""Pinned read-only Cursor help and opaque empty MCP captures; no runtime proof.""" CLIENT = "cursor" VERSION = "2026.10.22-27dcf5f" -ACQUISITION_ONLY = True +AVAILABLE_PHASES = ("acquire", "help") ACQUISITION = { "url": "https://downloads.cursor.com/lab/2026.10.22-27dcf5f/linux/x64/agent-cli-package.tar.gz", "integrity": "sha256:85296944e4600a83c4b764f578d1502bf9c416db200d33a593e8f3ecabfdc294", @@ -17,10 +17,43 @@ "transport": "cursor-publisher-curl", } +PROBES = ( + ("cursor-version", ("--version",), "a", "version"), + ("cursor-help", ("--help",), "a", "help"), + ("cursor-mcp-help", ("mcp", "--help"), "a", "help"), + ("cursor-plugin-help", ("plugin", "--help"), "a", "help"), + ("cursor-empty-a-mcp", ("mcp", "list"), "a", "opaque-component-capture"), + ("cursor-empty-b-mcp", ("mcp", "list"), "b", "opaque-component-capture"), + ("cursor-empty-outside-mcp", ("mcp", "list"), "user", "opaque-component-capture"), +) + def collect(ctx): - """Refuse native work, including context access, before collector review.""" - raise RuntimeError( - "Cursor is acquisition-only: the measured archive pin does not authorize " - "native observation without a separately reviewed collector" - ) + """Use the frozen bundled launcher without seeding settings or approving MCP.""" + if getattr(ctx, "phase", None) != "help": + raise ValueError("Cursor collector accepts only its reviewed help phase") + observations = {} + for label, arguments, project, surface in PROBES: + record, stdout, stderr = ctx.probe(label, list(arguments), project=project, timeout=30) + if (not isinstance(record, dict) or type(record.get("exit")) is not int + or record["exit"] != 0 or "error" not in record or record["error"] is not None + or not isinstance(stdout, bytes) or not isinstance(stderr, bytes)): + raise ValueError("Cursor read-only probe failed; no supported boundary is inferred") + try: + text = stdout.decode("utf-8", errors="strict") + stderr.decode("utf-8", errors="strict") + except UnicodeError as error: + raise ValueError("Cursor read-only capture is not UTF-8") from error + if surface == "version" and (stdout != (VERSION + "\n").encode() or stderr): + raise ValueError("Cursor executable version differs from frozen publisher version") + if surface in ("version", "help") and not text.strip(): + raise ValueError("Cursor version/help capture is empty") + observations[label] = {"arguments": list(arguments), "project": project, "surface": surface} + return { + "observations": observations, + "cases": [{"id": "cursor.help", "status": "passed", "reason": + "Exact pinned version and read-only help observed; three empty-context MCP outputs remain opaque, without scope or runtime qualification"}], + "unobserved": ["cursor.scope-observation", "cursor.project-lifecycle", "cursor.native-restart-removal", + "cursor.mcp-initialize", "cursor.mcp-tools-list", "cursor.mcp-tools-call", + "cursor.vendor-authentication", "cursor.real-model"], + } diff --git a/scripts/test_project_native_cursor.py b/scripts/test_project_native_cursor.py index c1a398b5e..e1b2a0fd4 100644 --- a/scripts/test_project_native_cursor.py +++ b/scripts/test_project_native_cursor.py @@ -20,37 +20,40 @@ def load_leaf(): class ForbiddenContext: + def __init__(self, phase): + object.__setattr__(self, "phase", phase) + def __getattribute__(self, name): - raise AssertionError("acquisition-only collector accessed context: " + name) + if name == "phase": + return object.__getattribute__(self, name) + raise AssertionError("unreviewed phase accessed native context: " + name) class CursorAcquisitionContract(unittest.TestCase): - def test_import_and_collect_never_start_a_process_or_download(self): + def test_import_and_unreviewed_phases_never_start_a_process_or_download(self): with ( patch.object(subprocess, "Popen", side_effect=AssertionError("process execution")) as process, patch.object(urllib.request, "urlopen", side_effect=AssertionError("network acquisition")) as download, ): leaf = load_leaf() - for context in (None, ForbiddenContext(), {"native_opt_in": True, "hosted": True}): + for context in (None, ForbiddenContext("acquire"), ForbiddenContext("observe"), ForbiddenContext("unknown")): with self.subTest(context_type=type(context).__name__): - with self.assertRaisesRegex(RuntimeError, "acquisition-only.*separately reviewed collector"): + with self.assertRaisesRegex(ValueError, "only its reviewed help phase"): leaf.collect(context) process.assert_not_called() download.assert_not_called() - def test_measured_pin_keeps_native_execution_disabled(self): + def test_measured_pin_limits_native_execution_to_reviewed_help(self): leaf = load_leaf() - self.assertIs(leaf.ACQUISITION_ONLY, True) + self.assertEqual(leaf.AVAILABLE_PHASES, ("acquire", "help")) self.assertEqual(leaf.ACQUISITION["integrity"], "sha256:85296944e4600a83c4b764f578d1502bf9c416db200d33a593e8f3ecabfdc294") self.assertEqual(leaf.ACQUISITION["entrypoint"], "dist-package/cursor-agent") self.assertEqual(leaf.ACQUISITION["binary_sha256"], "2ccc9a8e167797641448b5e5c936f006ba137a2555f117f38c5eb76a5238a233") self.assertEqual(leaf.ACQUISITION["expected_bytes"], 184859176) self.assertEqual(leaf.ACQUISITION["max_expanded_bytes"], 640 << 20) self.assertEqual(leaf.ACQUISITION["transport"], "cursor-publisher-curl") - # A flag change alone must not accidentally expose native execution. - leaf.ACQUISITION_ONLY = False - with self.assertRaisesRegex(RuntimeError, "acquisition-only"): - leaf.collect(ForbiddenContext()) + with self.assertRaisesRegex(ValueError, "only its reviewed help phase"): + leaf.collect(ForbiddenContext("observe")) def test_acquisition_is_bounded_and_uses_an_immutable_official_candidate(self): leaf = load_leaf() @@ -70,6 +73,53 @@ def test_acquisition_is_bounded_and_uses_an_immutable_official_candidate(self): self.assertEqual(candidate["format"], "tar.gz") self.assertEqual(candidate["runtime"], "native") + def test_closed_probe_table_preserves_raw_component_capture_without_scope_claim(self): + leaf = load_leaf() + calls = [] + class Context: + phase = "help" + def probe(self, label, arguments, **options): + calls.append((label, arguments, options)) + body = (leaf.VERSION + "\n").encode() if label == "cursor-version" else b"opaque native text\n" + return {"exit": 0, "error": None}, body, b"" + result = leaf.collect(Context()) + expected = [ + ("cursor-version", ["--version"], {"project": "a", "timeout": 30}), + ("cursor-help", ["--help"], {"project": "a", "timeout": 30}), + ("cursor-mcp-help", ["mcp", "--help"], {"project": "a", "timeout": 30}), + ("cursor-plugin-help", ["plugin", "--help"], {"project": "a", "timeout": 30}), + ("cursor-empty-a-mcp", ["mcp", "list"], {"project": "a", "timeout": 30}), + ("cursor-empty-b-mcp", ["mcp", "list"], {"project": "b", "timeout": 30}), + ("cursor-empty-outside-mcp", ["mcp", "list"], {"project": "user", "timeout": 30}), + ] + self.assertEqual(calls, expected) + self.assertEqual([row["id"] for row in result["cases"]], ["cursor.help"]) + self.assertEqual(len(result["observations"]), 7) + for case in ("scope-observation", "project-lifecycle", "native-restart-removal", + "mcp-initialize", "mcp-tools-list", "mcp-tools-call", "vendor-authentication", "real-model"): + self.assertIn("cursor." + case, result["unobserved"]) + + def test_unknown_version_process_failure_empty_help_and_non_utf8_fail_closed(self): + leaf = load_leaf() + variants = (("cursor-version", {"exit": 0, "error": None}, b"other\n", b""), + ("cursor-version", {"exit": 0, "error": None}, (leaf.VERSION + "\n").encode(), b"unexpected"), + ("cursor-help", {"exit": 1, "error": None}, b"help", b""), + ("cursor-help", {"exit": 0, "error": "deadline"}, b"help", b""), + ("cursor-help", {"exit": False, "error": None}, b"help", b""), + ("cursor-help", {"exit": 0, "error": None}, b" \n", b""), + ("cursor-help", {"exit": 0, "error": None}, b"\xff", b""), + ("cursor-empty-a-mcp", {"exit": 0, "error": None}, b"raw", b"\xff")) + for label, record, stdout, stderr in variants: + class Context: + phase = "help" + def probe(self, current, arguments, **options): + if current == label: + return record, stdout, stderr + body = (leaf.VERSION + "\n").encode() if current == "cursor-version" else b"opaque\n" + return {"exit": 0, "error": None}, body, b"" + with self.subTest(label=label, stdout=stdout), self.assertRaises(ValueError): + leaf.collect(Context()) + if __name__ == "__main__": unittest.main() From e231c9e5af4e7160d95ea49d0557271e7863a056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D0=B8=D1=8F?= Date: Thu, 29 Oct 2026 16:19:55 +0200 Subject: [PATCH 3/5] feat(cursor): select the actual Agent for project components Specialize the existing project registry to accept the genuine Cursor Agent surface for project component operations. Preserve user-scope editor selection, original profile authority and errors, editor executable/version identity, and Prepared activation semantics. --- .../cursor_project_registry_test.go | 172 ++++++++++++++++++ .../agentplugins/clients/all/registry.go | 4 +- .../agentplugins/clients/cursor/detect.go | 14 +- 3 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 install/integrationctl/agentplugins/adapters/clientdetect/cursor_project_registry_test.go diff --git a/install/integrationctl/agentplugins/adapters/clientdetect/cursor_project_registry_test.go b/install/integrationctl/agentplugins/adapters/clientdetect/cursor_project_registry_test.go new file mode 100644 index 000000000..1bf23b9aa --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/clientdetect/cursor_project_registry_test.go @@ -0,0 +1,172 @@ +package clientdetect + +import ( + "context" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/all" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +// These are inert host probes through the actual registries and detector, not +// execution of an editor, Agent, package payload or native-registry command. +func TestCursorProjectRegistrySelectionPreservesUserAuthority(t *testing.T) { + for _, row := range []struct { + name string + editor, agent bool + config, invalid bool + }{ + {name: "agent", agent: true}, + {name: "editor", editor: true}, + {name: "both", editor: true, agent: true}, + {name: "config", config: true}, + {name: "absent"}, + {name: "invalid-profile", editor: true, agent: true, invalid: true}, + } { + t.Run(row.name, func(t *testing.T) { + home := cursorSelectionHome(t, row.config, row.invalid) + binaries := map[string]string{} + if row.editor { + binaries["cursor"] = filepath.Join(home, "editor") + } + if row.agent { + binaries["cursor-agent"] = filepath.Join(home, "agent") + } + user := cursorSelectionClient(t, cursorSelectionDetector(home, all.Default(), binaries), false) + project := cursorSelectionClient(t, cursorSelectionDetector(home, all.Project(), binaries), false) + assertCursorSelection(t, user, row.editor && !row.invalid, row.invalid, home, binaries["cursor"]) + assertCursorSelection(t, project, (row.editor || row.agent) && !row.invalid, row.invalid, home, binaries["cursor"]) + if !reflect.DeepEqual(user.Surfaces, project.Surfaces) { + t.Fatal("project selection changed raw editor/Agent/profile evidence") + } + }) + } +} + +func TestCursorProjectAgentSelectionNeverBorrowsExecutableOrVersion(t *testing.T) { + home := cursorSelectionHome(t, false, false) + detector := cursorSelectionDetector(home, all.Project(), map[string]string{"cursor-agent": filepath.Join(home, "agent")}) + detector.ProbeVersion = func(context.Context, string) (string, error) { + t.Fatal("Agent-only project selection invoked an editor version probe") + return "", nil + } + client := cursorSelectionClient(t, detector, true) + assertCursorSelection(t, client, true, false, home, "") + inspector, ok := clients.As[clients.RegistryInspector](all.Project(), domain.ClientCursor) + if !ok || inspector.UsesNativeRegistryExecutable() { + t.Fatal("project components gained a native executable registry probe") + } + adapter, ok := clients.As[clients.NativeRegistryLayout](all.Project(), domain.ClientCursor) + if !ok { + t.Fatal("project Cursor registry layout disappeared") + } + project := filepath.Join(home, "project") + if err := os.Mkdir(project, 0o700); err != nil { + t.Fatal(err) + } + root, executable := adapter.NativeRegistry(clients.PlanInput{Client: client, TargetContext: domain.TargetContext{Scope: domain.ScopeProject, ProjectRoot: project, UserConfigRoot: client.ConfigRoot}}) + if root != filepath.Join(project, ".cursor") || executable != "" { + t.Fatalf("project native registry borrowed Agent identity: root=%q executable=%q", root, executable) + } +} + +func TestCursorProjectRegistryKeepsPreparedAndAttestationBoundary(t *testing.T) { + home := cursorSelectionHome(t, false, false) + registry := all.Project() + client := cursorSelectionClient(t, cursorSelectionDetector(home, registry, map[string]string{"cursor-agent": filepath.Join(home, "agent")}), false) + project := filepath.Join(home, "project") + target := domain.TargetContext{Scope: domain.ScopeProject, ProjectRoot: project, UserConfigRoot: client.ConfigRoot} + plan := domain.DeliveryPlan{Scope: domain.ScopeProject, TargetContext: target, Status: domain.PlanReady, + NativeRegistryRoot: filepath.Join(project, ".cursor"), + Components: []domain.ComponentDecision{{Kind: domain.ComponentSkill, Name: "inert", Support: domain.SupportNative}}} + refiner, ok := clients.As[clients.PlanRefiner](registry, domain.ClientCursor) + if !ok { + t.Fatal("project Cursor planning capability disappeared") + } + if err := refiner.RefinePlan(context.Background(), clients.PlanInput{Client: client, TargetContext: target}, &plan); err != nil || plan.Activation != domain.ActivationPrepared { + t.Fatalf("Agent presence upgraded project configuration beyond Prepared: %+v, %v", plan, err) + } + preflight, ok := clients.As[clients.ActivationPreflighter](registry, domain.ClientCursor) + if !ok { + t.Fatal("project Cursor activation preflight disappeared") + } + err := preflight.PreflightActivation(clients.Env{}, domain.ActivationRequest{Client: client, Plan: plan, ActivationComplete: true}) + if err == nil || !strings.Contains(err.Error(), "cannot be established by activation attestation") { + t.Fatalf("Agent presence bypassed native discovery attestation refusal: %v", err) + } +} + +func cursorSelectionHome(t *testing.T, config, invalid bool) string { + t.Helper() + home, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + root := filepath.Join(home, ".cursor") + if invalid { + if err := os.WriteFile(root, []byte("inert non-directory profile"), 0o600); err != nil { + t.Fatal(err) + } + } else if config { + if err := os.Mkdir(root, 0o700); err != nil { + t.Fatal(err) + } + } + return home +} + +func cursorSelectionDetector(home string, registry *clients.Registry, binaries map[string]string) Detector { + return Detector{ + HomeDir: home, WorkingDir: home, GOOS: "linux", Registry: registry, + Lstat: os.Lstat, ReadDir: os.ReadDir, EvalSymlinks: filepath.EvalSymlinks, + LookPath: func(name string) (string, error) { + if path := binaries[name]; path != "" { + return path, nil + } + return "", fmt.Errorf("inert absent binary %s", name) + }, + } +} + +func cursorSelectionClient(t *testing.T, detector Detector, probe bool) domain.DetectedClient { + t.Helper() + var detected []domain.DetectedClient + var err error + if probe { + detected, err = detector.DetectTargetsWithVersionProbe(context.Background(), []domain.ClientID{domain.ClientCursor}) + } else { + detected, err = detector.Detect(context.Background()) + } + if err != nil { + t.Fatal(err) + } + for _, client := range detected { + if client.ClientID == domain.ClientCursor { + return client + } + } + t.Fatal("real registry lost Cursor") + return domain.DetectedClient{} +} + +func assertCursorSelection(t *testing.T, client domain.DetectedClient, detected, invalid bool, home, editor string) { + t.Helper() + if (client.Status == domain.DetectionDetected) != detected || (client.DetectionError != nil) != invalid { + t.Fatalf("Cursor selection or profile-error precedence changed: %+v", client) + } + if invalid { + if client.ConfigRoot != "" || client.ExecutablePath != "" || client.Version != "" || len(client.Surfaces) != 0 { + t.Fatalf("invalid Cursor profile retained authority: %+v", client) + } + return + } + if client.ConfigRoot != filepath.Join(home, ".cursor") || client.ExecutablePath != editor || client.Version != "" { + t.Fatalf("Cursor project selection replaced editor/profile identity: %+v", client) + } +} diff --git a/install/integrationctl/agentplugins/clients/all/registry.go b/install/integrationctl/agentplugins/clients/all/registry.go index e2e68932c..2743123f0 100644 --- a/install/integrationctl/agentplugins/clients/all/registry.go +++ b/install/integrationctl/agentplugins/clients/all/registry.go @@ -45,14 +45,16 @@ var projectRegistry = sync.OnceValue(func() *clients.Registry { return buildRegi func buildRegistry(project bool) *clients.Registry { var workspace clients.Adapter = vscode.New() + var cursorComponents clients.Adapter = cursor.New() if project { workspace = vscode.NewProject() + cursorComponents = cursor.NewProject() } registry, err := clients.NewRegistry( pi.New(), codex.New(), chatgpt.New(), - cursor.New(), + cursorComponents, copilot.New(), workspace, kiro.New(), diff --git a/install/integrationctl/agentplugins/clients/cursor/detect.go b/install/integrationctl/agentplugins/clients/cursor/detect.go index c3cc654d6..e1a49f233 100644 --- a/install/integrationctl/agentplugins/clients/cursor/detect.go +++ b/install/integrationctl/agentplugins/clients/cursor/detect.go @@ -9,11 +9,15 @@ import ( ) // Adapter implements the client contract for Cursor. -type Adapter struct{} +type Adapter struct{ projectComponents bool } // New builds the adapter. The composition root registers it in clients/all. func New() *Adapter { return &Adapter{} } +// NewProject selects project components using either the editor or Agent +// surface. It does not qualify native discovery or replace editor identity. +func NewProject() *Adapter { return &Adapter{projectComponents: true} } + var ( _ clients.Adapter = (*Adapter)(nil) _ clients.HostDetector = (*Adapter)(nil) @@ -25,7 +29,7 @@ func (*Adapter) ID() domain.ClientID { return domain.ClientCursor } // DetectSurfaces reports editor and agent binary presence separately. The // retained package preparation contract is for the editor; neither presence // nor a leftover directory qualifies native agent plugins or Stop events. -func (*Adapter) DetectSurfaces(host clients.Host) clients.Detection { +func (adapter *Adapter) DetectSurfaces(host clients.Host) clients.Detection { configRoot, err := detectedProfileRoot(host) if err != nil { return clients.Detection{Err: err} @@ -43,10 +47,14 @@ func (*Adapter) DetectSurfaces(host clients.Host) clients.Detection { } else if host.GOOS() == "linux" { surfaces = append(surfaces, host.LinuxDesktopSurface("cursor_desktop", "cursor.desktop")) } + selection := []string{"cursor_editor", "cursor_desktop"} + if adapter.projectComponents { + selection = append(selection, "cursor_agent") + } return clients.Detection{ ConfigRoot: configRoot, ExecutablePath: editor, Surfaces: surfaces, - SelectionSurfaceIDs: []string{"cursor_editor", "cursor_desktop"}, + SelectionSurfaceIDs: selection, } } From bd2a8375c501ad0a14b7f73e56ccfe30d6b2fedc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D0=B8=D1=8F?= Date: Thu, 29 Oct 2026 17:05:44 +0200 Subject: [PATCH 4/5] test: capture Cursor public project MCP installation --- .../remaining-native-qualification.md | 27 ++ ...gins_cursor_project_native_attempt_test.go | 251 ++++++++++++++++++ ...ins_cursor_project_native_contract_test.go | 95 +++++++ ...lugins_cursor_project_native_proof_test.go | 240 +++++++++++++++++ scripts/project_native_runtime.py | 7 +- scripts/test_project_native_cursor_runtime.py | 50 ++++ scripts/test_project_native_runtime.py | 1 + 7 files changed, 670 insertions(+), 1 deletion(-) create mode 100644 repotests/agentplugins_cursor_project_native_attempt_test.go create mode 100644 repotests/agentplugins_cursor_project_native_contract_test.go create mode 100644 repotests/agentplugins_cursor_project_native_proof_test.go create mode 100644 scripts/test_project_native_cursor_runtime.py diff --git a/docs/plans/project-install-e2e/remaining-native-qualification.md b/docs/plans/project-install-e2e/remaining-native-qualification.md index 79304be6b..647788965 100644 --- a/docs/plans/project-install-e2e/remaining-native-qualification.md +++ b/docs/plans/project-install-e2e/remaining-native-qualification.md @@ -2025,3 +2025,30 @@ startup/cache effects remain unqualified and are not adopted as trusted config. All five project/MCP flags stay false, with scope, authentication and real-model work explicitly unobserved. Independent actual hosted raw review must establish the next supported component contract before additional qualification work. + +### Cursor first public project MCP capture, 2026-10-29 + +The genuine seven-probe hosted help capture on `1eb5be73` confirms the pinned +Agent wrapper, version and ordinary `mcp list` surface. The project-only Agent +selection amendment is published at `e231c9e5`; User/editor selection is unchanged. +This next slice reuses the existing runtime acquisition/build/scanner path and +adds one bounded Cursor test. It runs two Git initializations, exact version, +empty A/B/outside MCP listings, one real public project-A MCP-only add with +`--no-save --format json`, then installed-A and B/outside control listings. +There is no credential, approval, trust, provider or private configuration seed. + +The fixture holds all 445 runtime files and the exact public installer, scanner, +probe and Git identities. The current-source add must produce a Prepared/installed +project binding, owned MCP receipt and exact managed stdio recipe. Authored and +selected installed bytes/config/state remain held; original-HOME/B/outside MCP +sources remain absent. Raw MCP output and any harmless owned-probe event bytes +are retained without guessing an installed-list parser or inferring that an +unapproved entry cannot start a process. Unknown native HOME/cache effects are +unqualified and are never adopted as trusted configuration. + +The first installed capture intentionally fails for independent raw review and +keeps every lifecycle, restart/removal and MCP qualification false. It does not +attempt skills discovery, User/B lifecycle, native enable, authentication or +model use. Both independent plan reviews approved the ten literal commands, +Cursor-only reader labels and unchanged descendant supervision/bounds. Local +validation is inert; actual vendor execution remains hosted-only. diff --git a/repotests/agentplugins_cursor_project_native_attempt_test.go b/repotests/agentplugins_cursor_project_native_attempt_test.go new file mode 100644 index 000000000..f2907fef7 --- /dev/null +++ b/repotests/agentplugins_cursor_project_native_attempt_test.go @@ -0,0 +1,251 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "testing" + "time" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/process" + "github.com/777genius/plugin-kit-ai/install/integrationctl/ports" +) + +const cursorProjectVersion = "2026.10.22-27dcf5f" +const cursorProjectArchive = "85296944e4600a83c4b764f578d1502bf9c416db200d33a593e8f3ecabfdc294" + +type cursorProjectCommand struct { + label, cwd string + argv []string +} +type cursorProjectHeld struct { + info os.FileInfo + digest string +} +type cursorProjectAttempt struct { + base *nativeFixture + client, installer, git, other, outside, output string + ctx context.Context + cancel context.CancelFunc + started time.Time + files map[string][]byte + held map[string]cursorProjectHeld + runtimeRoot string + runtimeMembers map[string]string + absent []string + evidence map[string]any +} + +func (f *cursorProjectAttempt) commands() []cursorProjectCommand { + a := f.base.Project + return []cursorProjectCommand{ + {"git-a", a, []string{f.git, "init", "--quiet", "--initial-branch=main"}}, + {"git-b", f.other, []string{f.git, "init", "--quiet", "--initial-branch=main"}}, + {"version", f.outside, []string{f.client, "--version"}}, + {"empty-a-mcp", a, []string{f.client, "mcp", "list"}}, + {"empty-b-mcp", f.other, []string{f.client, "mcp", "list"}}, + {"empty-outside-mcp", f.outside, []string{f.client, "mcp", "list"}}, + {"add-a", a, []string{f.installer, "add", f.base.Package, "--no-save", "--scope", "project", "--project-root", a, "--target", "cursor", "--format", "json"}}, + {"native-attempt", a, []string{f.client, "mcp", "list"}}, + {"control-b-mcp", f.other, []string{f.client, "mcp", "list"}}, + {"control-outside-mcp", f.outside, []string{f.client, "mcp", "list"}}, + } +} +func (f *cursorProjectAttempt) hold(path string) error { + digest, err := projectNativeRuntimeDigest(path) + if err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + f.held[path] = cursorProjectHeld{info, digest} + return nil +} +func (f *cursorProjectAttempt) check() error { + if f.runtimeRoot != "" { + if err := f.runtimeClosure(); err != nil { + return err + } + } + for path, want := range f.held { + digest, err := projectNativeRuntimeDigest(path) + info, statErr := os.Lstat(path) + if err != nil || statErr != nil || digest != want.digest || !os.SameFile(info, want.info) || !projectNativeRuntimeStableStat(info.Sys(), want.info.Sys()) { + return fmt.Errorf("Cursor held file identity changed: %s", path) + } + } + for _, path := range f.absent { + if _, err := os.Lstat(path); !os.IsNotExist(err) { + return fmt.Errorf("Cursor other-source absence changed: %s", path) + } + } + return nil +} +func (f *cursorProjectAttempt) keep(t *testing.T, name string, body []byte) { + t.Helper() + total := len(body) + for _, previous := range f.files { + total += len(previous) + } + _, exists := f.files[name] + if filepath.Base(name) != name || exists || len(f.files) >= 32 || len(body) > 1<<20 || total > 16<<20 { + t.Fatal("Cursor closed capture bound") + } + f.files[name] = body +} +func (f *cursorProjectAttempt) command(t *testing.T, command cursorProjectCommand) ports.CommandResult { + t.Helper() + if err := f.check(); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(f.ctx, 30*time.Second) + defer cancel() + began := time.Now() + env := append(nativePlatformEnvironment(f.base.Root, f.base.Home, filepath.Dir(f.client), filepath.Dir(f.git)), "AGENTPLUGINS_HOME="+filepath.Join(f.base.Root, "installer-state"), "NO_COLOR=1", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL="+os.DevNull, "GIT_TERMINAL_PROMPT=0") + result, err := (process.OS{}).RunWithDescendantExitGrace(ctx, ports.Command{Argv: command.argv, Env: env, Dir: command.cwd, StdoutLimitBytes: 1 << 20}, 5*time.Second) + receipt, _ := json.Marshal(map[string]any{"argv": command.argv, "cwd": command.cwd, "exit": result.ExitCode, "error": err != nil, "deadline": ctx.Err() != nil, "elapsed_ms": time.Since(began).Milliseconds(), "cleanup": "full descendant supervision; forced cleanup fails"}) + f.keep(t, command.label+".command.json", receipt) + f.keep(t, command.label+".stdout.log", result.Stdout) + f.keep(t, command.label+".stderr.log", result.Stderr) + if err := f.check(); err != nil { + t.Fatal(err) + } + if err != nil || ctx.Err() != nil || result.ExitCode != 0 || bytes.Contains(result.Stderr, []byte("stderr bytes omitted")) { + t.Fatalf("Cursor %s process/capture/cleanup failed; see bounded capture", command.label) + } + return result +} +func newCursorProjectAttempt(t *testing.T) *cursorProjectAttempt { + t.Helper() + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" || os.Getenv("GITHUB_ACTIONS") != "true" || os.Getenv("RUNNER_ENVIRONMENT") != "github-hosted" || os.Getenv("AGENTPLUGINS_NATIVE_DISPOSABLE_HOSTED") != "1" { + t.Fatal("Cursor attempt requires actual opted-in disposable GitHub-hosted Linux amd64") + } + f := &cursorProjectAttempt{started: time.Now(), files: map[string][]byte{}, held: map[string]cursorProjectHeld{}, evidence: map[string]any{"client": "cursor", "version": cursorProjectVersion, "activation": "prepared", "runtime_attempt_complete": false, "vendor_authentication_qualified": false, "real_model_qualified": false, "qualification": map[string]bool{"project-lifecycle": false, "native-restart-removal": false, "mcp-initialize": false, "mcp-tools-list": false, "mcp-tools-call": false}}} + f.ctx, f.cancel = context.WithDeadline(context.Background(), f.started.Add(150*time.Second)) + f.output = os.Getenv("AGENTPLUGINS_PROJECT_NATIVE_OUTPUT") + if err := projectNativeRuntimeParents(filepath.Join(f.output, "leaf")); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(f.output) + if err != nil || len(entries) != 0 { + t.Fatal("Cursor output must be existing empty ordinary directory") + } + f.client = nativeBinary(t, "AGENTPLUGINS_CURSOR_BIN") + f.installer = nativeBinary(t, "AGENTPLUGINS_INSTALLER_BIN") + f.git, err = exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + f.freezeAcquisition(t) + for _, path := range []string{f.installer, f.git, nativeBinary(t, "AGENTPLUGINS_NATIVE_PROBE_BIN"), nativeBinary(t, "AGENTPLUGINS_LINTAI_BIN")} { + if err := f.hold(path); err != nil { + t.Fatal(err) + } + } + source, err := nativeSourceIdentity(os.Getenv("AGENTPLUGINS_INSTALLER_COMMIT"), os.Getenv("AGENTPLUGINS_INSTALLER_TREE"), os.Getenv("AGENTPLUGINS_INSTALLER_PATCH_SHA256")) + if err != nil { + t.Fatal(err) + } + f.evidence["source_identity"] = source + f.base = newNativeFixture(t, false) + f.other = filepath.Join(f.base.Root, "project-B") + f.outside = filepath.Join(f.base.Root, "outside") + for _, path := range []string{f.other, f.outside} { + if err := os.Mkdir(path, 0700); err != nil { + t.Fatal(err) + } + } + f.absent = []string{filepath.Join(f.base.Home, ".cursor", "mcp.json"), filepath.Join(f.other, ".cursor", "mcp.json"), filepath.Join(f.outside, ".cursor", "mcp.json"), filepath.Join(f.base.Root, ".cursor", "mcp.json")} + f.evidence["other_source_absent"] = f.absent + scanner := nativeProvisionScanner(t, f.base) + f.evidence["scanner"] = scanner + if err := f.hold(filepath.Join(f.base.Root, "installer-state", "security", "lintai", "0.1.3", scanner["platform"], "lintai")); err != nil { + t.Fatal(err) + } + if err := f.hold(f.base.Probe); err != nil { + t.Fatal(err) + } + return f +} +func (f *cursorProjectAttempt) finish(t *testing.T) { + t.Helper() + defer f.cancel() + if err := f.check(); err != nil { + t.Error(err) + } + if time.Since(f.started) > 180*time.Second { + t.Error("Cursor total attempt budget exceeded") + } + events, err := projectNativeRuntimeRead(f.base.Events, 1<<20) + if os.IsNotExist(err) { + events = []byte{} + } else if err != nil { + t.Error(err) + } + f.keep(t, "probe-events.jsonl", events) + f.evidence["test_failed"] = t.Failed() + f.evidence["elapsed_ms"] = time.Since(f.started).Milliseconds() + body, err := json.MarshalIndent(f.evidence, "", " ") + if err != nil { + t.Error(err) + return + } + f.keep(t, "runtime-attempt.json", append(body, '\n')) + file, err := os.OpenFile(filepath.Join(f.output, "research.zip"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + t.Error(err) + return + } + archive := zip.NewWriter(file) + names := make([]string, 0, len(f.files)) + for name := range f.files { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + writer, err := archive.Create(name) + if err != nil { + t.Error(err) + break + } + if _, err := writer.Write(f.files[name]); err != nil { + t.Error(err) + break + } + } + if err := archive.Close(); err != nil { + t.Error(err) + } + if err := file.Close(); err != nil { + t.Error(err) + } +} +func TestAgentpluginsCursorNativeProjectRuntimeAttempt(t *testing.T) { + if os.Getenv("AGENTPLUGINS_CURSOR_PROJECT_RUNTIME_ATTEMPT") != "1" { + t.Skip("opt-in current-source Cursor component capture") + } + f := newCursorProjectAttempt(t) + defer f.finish(t) + f.packageSource(t) + for _, command := range f.commands() { + result := f.command(t, command) + if command.label == "version" && (string(result.Stdout) != cursorProjectVersion+"\n" || len(result.Stderr) != 0) { + t.Fatal("exact Cursor pinned version differs") + } + if command.label == "add-a" { + f.installed(t, result.Stdout) + } + } + t.Fatal("Cursor first installed MCP capture requires independent raw review; all runtime qualifications remain false") +} diff --git a/repotests/agentplugins_cursor_project_native_contract_test.go b/repotests/agentplugins_cursor_project_native_contract_test.go new file mode 100644 index 000000000..5b8638e4d --- /dev/null +++ b/repotests/agentplugins_cursor_project_native_contract_test.go @@ -0,0 +1,95 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestCursorProjectCaptureCommandAuthority(t *testing.T) { + f := &cursorProjectAttempt{base: &nativeFixture{Project: "/owned/A", Package: "/owned/source"}, client: "/pin/dist-package/cursor-agent", installer: "/owned/installer", git: "/usr/bin/git", other: "/owned/B", outside: "/owned/outside"} + want := []cursorProjectCommand{ + {"git-a", "/owned/A", []string{"/usr/bin/git", "init", "--quiet", "--initial-branch=main"}}, + {"git-b", "/owned/B", []string{"/usr/bin/git", "init", "--quiet", "--initial-branch=main"}}, + {"version", "/owned/outside", []string{"/pin/dist-package/cursor-agent", "--version"}}, + {"empty-a-mcp", "/owned/A", []string{"/pin/dist-package/cursor-agent", "mcp", "list"}}, + {"empty-b-mcp", "/owned/B", []string{"/pin/dist-package/cursor-agent", "mcp", "list"}}, + {"empty-outside-mcp", "/owned/outside", []string{"/pin/dist-package/cursor-agent", "mcp", "list"}}, + {"add-a", "/owned/A", []string{"/owned/installer", "add", "/owned/source", "--no-save", "--scope", "project", "--project-root", "/owned/A", "--target", "cursor", "--format", "json"}}, + {"native-attempt", "/owned/A", []string{"/pin/dist-package/cursor-agent", "mcp", "list"}}, + {"control-b-mcp", "/owned/B", []string{"/pin/dist-package/cursor-agent", "mcp", "list"}}, + {"control-outside-mcp", "/owned/outside", []string{"/pin/dist-package/cursor-agent", "mcp", "list"}}, + } + if !reflect.DeepEqual(f.commands(), want) { + t.Fatal("Cursor first component call authority changed") + } +} +func TestCursorProjectCaptureHeldAndAbsentAuthority(t *testing.T) { + for _, change := range []string{"bytes", "replacement", "alias", "hardlink", "other-source"} { + t.Run(change, func(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "held") + projectNativeRuntimeWrite(t, path, []byte("held bytes"), 0600) + f := &cursorProjectAttempt{held: map[string]cursorProjectHeld{}, absent: []string{filepath.Join(root, "absent")}} + if err := f.hold(path); err != nil { + t.Fatal(err) + } + if err := f.check(); err != nil { + t.Fatal(err) + } + switch change { + case "bytes": + if err := os.WriteFile(path, []byte("changed"), 0600); err != nil { + t.Fatal(err) + } + case "replacement", "alias": + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if change == "replacement" { + projectNativeRuntimeWrite(t, path, []byte("held bytes"), 0600) + } else if err := os.Symlink(f.absent[0], path); err != nil { + t.Fatal(err) + } + case "hardlink": + if err := os.Link(path, filepath.Join(root, "alias")); err != nil { + t.Fatal(err) + } + case "other-source": + projectNativeRuntimeWrite(t, f.absent[0], []byte("{}"), 0600) + } + if f.check() == nil { + t.Fatal("changed Cursor held/other-source authority accepted") + } + }) + } +} +func TestCursorProjectCaptureRuntimeClosure(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "member") + projectNativeRuntimeWrite(t, path, []byte("runtime"), 0600) + f := &cursorProjectAttempt{runtimeRoot: root, runtimeMembers: map[string]string{path: "file"}} + if err := f.runtimeClosure(); err != nil { + t.Fatal(err) + } + unknown := filepath.Join(root, "unknown") + projectNativeRuntimeWrite(t, unknown, []byte("extra"), 0600) + if f.runtimeClosure() == nil { + t.Fatal("unknown runtime file accepted") + } + if err := os.Remove(unknown); err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink(unknown, path); err != nil { + t.Fatal(err) + } + if f.runtimeClosure() == nil { + t.Fatal("aliased runtime member accepted") + } +} diff --git a/repotests/agentplugins_cursor_project_native_proof_test.go b/repotests/agentplugins_cursor_project_native_proof_test.go new file mode 100644 index 000000000..4533e785f --- /dev/null +++ b/repotests/agentplugins_cursor_project_native_proof_test.go @@ -0,0 +1,240 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/managedstdio" +) + +type cursorProjectIdentity struct { + Client string `json:"client"` + Acquisition struct { + SHA string `json:"sha256"` + Pin bool `json:"pin_verified"` + Members []struct { + Name string `json:"name"` + Kind string `json:"kind"` + SHA string `json:"sha256"` + } `json:"members"` + } `json:"acquisition"` + RuntimeFiles []struct { + Path string `json:"path"` + SHA string `json:"sha256"` + } `json:"runtime_files"` +} + +func (f *cursorProjectAttempt) freezeAcquisition(t *testing.T) { + t.Helper() + body, err := projectNativeRuntimeRead(os.Getenv("AGENTPLUGINS_PROJECT_NATIVE_IDENTITY"), 1<<20) + if err != nil { + t.Fatal(err) + } + if _, err := projectNativeRuntimeJSON(body); err != nil { + t.Fatal(err) + } + var identity cursorProjectIdentity + if json.Unmarshal(body, &identity) != nil || identity.Client != "cursor" || !identity.Acquisition.Pin || identity.Acquisition.SHA != cursorProjectArchive || len(identity.Acquisition.Members) != 565 || len(identity.RuntimeFiles) != 445 { + t.Fatal("Cursor exact acquisition identity differs") + } + f.runtimeRoot = filepath.Dir(filepath.Dir(f.client)) + f.runtimeMembers = map[string]string{} + expected := map[string]string{} + for _, member := range identity.Acquisition.Members { + name := member.Name + if name == "" || filepath.IsAbs(name) || filepath.Clean(name) != name || strings.Contains(name, "\\") || strings.HasPrefix(name, "../") || (member.Kind != "file" && member.Kind != "directory") { + t.Fatal("Cursor acquisition member differs") + } + path := filepath.Join(f.runtimeRoot, name) + if f.runtimeMembers[path] != "" { + t.Fatal("Cursor duplicate acquisition member") + } + f.runtimeMembers[path] = member.Kind + if member.Kind == "file" { + expected[path] = member.SHA + } + } + if len(expected) != 445 || expected[f.client] != "2ccc9a8e167797641448b5e5c936f006ba137a2555f117f38c5eb76a5238a233" || expected[filepath.Join(filepath.Dir(f.client), "node")] != "e32e2d16081cf644f88392934288086e7662529d0d43277c34f7aad2207cfafb" || expected[filepath.Join(filepath.Dir(f.client), "index.js")] != "7b4bb3badb36cff7b6cee7996d61bd3c32866cf354214374b77f5a5d33a0b3f2" { + t.Fatal("Cursor launcher/bundled Node/index closure differs") + } + for _, file := range identity.RuntimeFiles { + if expected[file.Path] == "" || expected[file.Path] != file.SHA || f.held[file.Path].info != nil { + t.Fatal("Cursor runtime closure differs") + } + if err := f.hold(file.Path); err != nil || f.held[file.Path].digest != file.SHA { + t.Fatal("Cursor runtime member digest differs") + } + } + if err := f.runtimeClosure(); err != nil { + t.Fatal(err) + } + f.evidence["acquisition"] = json.RawMessage(body) +} +func (f *cursorProjectAttempt) runtimeClosure() error { + seen := 0 + err := filepath.WalkDir(f.runtimeRoot, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == f.runtimeRoot { + return nil + } + kind := f.runtimeMembers[path] + if (kind == "directory" && !entry.IsDir()) || (kind == "file" && !entry.Type().IsRegular()) || kind == "" { + return fmt.Errorf("Cursor unknown/aliased runtime member") + } + seen++ + return nil + }) + if err != nil || seen != len(f.runtimeMembers) { + return fmt.Errorf("Cursor complete runtime member set changed") + } + return nil +} +func (f *cursorProjectAttempt) packageSource(t *testing.T) { + t.Helper() + manifest := []byte("{\"$schema\":\"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json\",\"name\":\"native-proof\",\"version\":\"1.0.0\"}\n") + server := map[string]any{"type": "stdio", "command": "./bin/probe", "args": []string{"cursor-component"}, "cwd": "${PLUGIN_ROOT}", "env": map[string]string{"UAP_TEST_ROOT": f.base.Root, "UAP_TEST_EVENTS": f.base.Events, "UAP_TEST_NONCE": f.base.Nonce, "UAP_TEST_REVISION": "cursor-a-one"}} + config, err := json.Marshal(map[string]any{"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", "mcpServers": map[string]any{"uapcursora": server}}) + if err != nil { + t.Fatal(err) + } + probe, err := projectNativeRuntimeRead(f.base.Probe, 32<<20) + if err != nil { + t.Fatal(err) + } + authored := map[string][]byte{"plugin.json": manifest, "mcp.json": append(config, '\n'), "bin/probe": probe} + for relative, body := range authored { + path := filepath.Join(f.base.Package, relative) + mode := os.FileMode(0600) + if relative == "bin/probe" { + mode = 0700 + } + projectNativeRuntimeWrite(t, path, body, mode) + if err := f.hold(path); err != nil { + t.Fatal(err) + } + } + f.evidence["fixture"] = map[string]any{"root": f.base.Root, "home": f.base.Home, "a": f.base.Project, "b": f.other, "outside": f.outside, "source": f.base.Package, "nonce": f.base.Nonce, "mcp_name": "uapcursora", "manifest": string(manifest), "mcp": string(config), "probe_sha256": f.held[f.base.Probe].digest} +} +func (f *cursorProjectAttempt) installed(t *testing.T, stdout []byte) { + t.Helper() + if _, err := projectNativeRuntimeJSON(stdout); err != nil { + t.Fatal(err) + } + if err := nativeValidateInstallerJSON(stdout); err != nil { + t.Fatal(err) + } + path := filepath.Join(f.base.Project, ".uap", "state-v2.json") + body, err := projectNativeRuntimeRead(path, 1<<20) + if err != nil { + t.Fatal(err) + } + var state domain.StateFileV2 + if json.Unmarshal(body, &state) != nil || len(state.Installations) != 1 { + t.Fatal("Cursor exact project installation missing") + } + installation := state.Installations[0] + if installation.DeclaredName != "native-proof" || installation.Source.CanonicalSource != f.base.Package || installation.Source.SourceBindingID == "" || installation.Source.TreeDigest == "" || installation.Package.DeclaredName != "native-proof" || installation.Package.Version != "1.0.0" || installation.Package.ManifestDigest == "" || len(installation.Clients) != 1 { + t.Fatal("Cursor selected source/package authority differs") + } + for _, binding := range installation.Clients { + f.installedBinding(t, binding, installation.DataReceipts[binding.DataReceiptID]) + } + if err := f.hold(path); err != nil { + t.Fatal(err) + } + f.evidence["installed_state"] = json.RawMessage(body) +} +func (f *cursorProjectAttempt) installedBinding(t *testing.T, binding domain.ClientBinding, data domain.DataReceipt) { + t.Helper() + if binding.ClientID != "cursor" || binding.Scope != "project" || binding.NativeProfileRoot != filepath.Join(f.base.Project, ".cursor") || binding.Activation != domain.ActivationPrepared || binding.Verification != domain.VerificationInstalled || data.State != domain.DataReceiptOwned || data.Scope != "project" || data.DataReceiptID != binding.DataReceiptID || data.PhysicalBackend != binding.PhysicalArtifact || data.OwnershipDigest == "" { + t.Fatal("Cursor Prepared binding/data receipt differs") + } + nativeRequireContained(t, filepath.Join(f.base.Project, ".uap", "managed"), binding.TargetLocator) + nativeRequireContained(t, filepath.Join(f.base.Project, ".uap", "plugin-data"), data.Locator) + configPath := filepath.Join(f.base.Project, ".cursor", "mcp.json") + owned := 0 + for _, object := range binding.NativeObjects { + if object.Kind == "cursor_project_mcp_server" { + if object.Path != configPath || object.LogicalName != "uapcursora" || object.ManagedDigest == "" { + t.Fatal("Cursor MCP ownership differs") + } + owned++ + } + } + if owned != 1 { + t.Fatal("Cursor single owned MCP receipt missing") + } + config, err := projectNativeRuntimeRead(configPath, 1<<20) + if err != nil { + t.Fatal(err) + } + f.configBinding(t, config, binding.TargetLocator, data.Locator) + for _, relative := range []string{"plugin.json", "bin/probe"} { + target := filepath.Join(binding.TargetLocator, relative) + body, err := projectNativeRuntimeRead(target, 32<<20) + source, sourceErr := projectNativeRuntimeRead(filepath.Join(f.base.Package, relative), 32<<20) + if err != nil || sourceErr != nil || !bytes.Equal(body, source) { + t.Fatal("Cursor installed source bytes differ") + } + if err := f.hold(target); err != nil { + t.Fatal(err) + } + } + projected := filepath.Join(binding.TargetLocator, "mcp.json") + projectedBody, err := projectNativeRuntimeRead(projected, 1<<20) + if err != nil { + t.Fatal(err) + } + f.configBinding(t, projectedBody, binding.TargetLocator, data.Locator) + if err := f.hold(projected); err != nil { + t.Fatal(err) + } + if err := f.hold(filepath.Join(data.Locator, ".agentplugins-data-owner.json")); err != nil { + t.Fatal(err) + } + launcher := filepath.Join(binding.TargetLocator, filepath.FromSlash(managedstdio.RelativeDirectory), managedstdio.ExecutableName) + if err := f.hold(launcher); err != nil { + t.Fatal(err) + } + if f.held[launcher].digest != f.held[f.installer].digest { + t.Fatal("Cursor managed launcher differs from held current installer") + } + if err := f.hold(configPath); err != nil { + t.Fatal(err) + } + f.evidence["installed_config"] = string(config) + f.evidence["binding"] = binding + f.evidence["data_receipt"] = data +} +func (f *cursorProjectAttempt) configBinding(t *testing.T, body []byte, active, data string) { + t.Helper() + value, err := projectNativeRuntimeJSON(body) + object, ok := value.(map[string]any) + if err != nil || !ok || projectNativeRuntimeKeys(object, "mcpServers", "") != nil { + t.Fatal("Cursor native config shape differs") + } + servers, ok := object["mcpServers"].(map[string]any) + if !ok || len(servers) != 1 { + t.Fatal("Cursor exact native MCP inventory differs") + } + server, ok := servers["uapcursora"].(map[string]any) + if !ok || projectNativeRuntimeKeys(server, "command args env", "") != nil { + t.Fatal("Cursor native MCP fields differ") + } + env := map[string]any{"UAP_TEST_ROOT": f.base.Root, "UAP_TEST_EVENTS": f.base.Events, "UAP_TEST_NONCE": f.base.Nonce, "UAP_TEST_REVISION": "cursor-a-one", "PLUGIN_ROOT": active, "PLUGIN_DATA": data} + args := []any{managedstdio.Mode, active, data, active, "plugin", "--", "./bin/probe", "cursor-component"} + if server["command"] != filepath.Join(active, filepath.FromSlash(managedstdio.RelativeDirectory), managedstdio.ExecutableName) || !reflect.DeepEqual(server["args"], args) || !reflect.DeepEqual(server["env"], env) { + t.Fatal("Cursor installed managed stdio recipe differs") + } +} diff --git a/scripts/project_native_runtime.py b/scripts/project_native_runtime.py index 521b3cedd..38aa465cf 100644 --- a/scripts/project_native_runtime.py +++ b/scripts/project_native_runtime.py @@ -26,6 +26,9 @@ SOURCE = shared.SOURCE CASES = { + "cursor": {"test": "TestAgentpluginsCursorNativeProjectRuntimeAttempt", + "file": "repotests/agentplugins_cursor_project_native_attempt_test.go", + "optin": "AGENTPLUGINS_CURSOR_PROJECT_RUNTIME_ATTEMPT", "seconds": 180, "phase": "runtime-attempt"}, "cline": {"test": "TestAgentpluginsClineNativeProjectCompleteCycle", "file": "repotests/agentplugins_cline_cycle_lifecycle_test.go", "optin": "AGENTPLUGINS_CLINE_PROJECT_RUNTIME_ATTEMPT", "seconds": 180, @@ -59,7 +62,7 @@ "file": "repotests/agentplugins_windsurf_project_native_attempt_test.go", "optin": "AGENTPLUGINS_WINDSURF_PROJECT_RUNTIME_ATTEMPT", "seconds": 180, "phase": "runtime-attempt"}, } -CLI_ATTEMPTS = {"qoder": "1.1.78", "grok": "1.0.70", "kiro": "2.34.0", "windsurf": "3000.11.205"} +CLI_ATTEMPTS = {"cursor": "2026.10.22-27dcf5f", "qoder": "1.1.78", "grok": "1.0.70", "kiro": "2.34.0", "windsurf": "3000.11.205"} MAX_ZIP = 32 << 20 MAX_MEMBER = 2 << 20 COMMANDS = ("git-A", "git-B", "version", "add-A", "native-attempt") @@ -241,6 +244,8 @@ def cli_attempt_members(bodies, source, client): raise ValueError("unknown fixed native CLI attempt") allowed = {"runtime-attempt.json", "probe-events.jsonl"} commands = CLI_COMMANDS + (("native-catalog",) if client == "qoder" else ()) + if client == "cursor": + commands += ("empty-a-mcp", "empty-b-mcp", "empty-outside-mcp", "control-b-mcp", "control-outside-mcp") for label in commands: triple = {label + suffix for suffix in (".command.json", ".stdout.log", ".stderr.log")} allowed |= triple diff --git a/scripts/test_project_native_cursor_runtime.py b/scripts/test_project_native_cursor_runtime.py new file mode 100644 index 000000000..cd3fba349 --- /dev/null +++ b/scripts/test_project_native_cursor_runtime.py @@ -0,0 +1,50 @@ +"""Inert first Cursor component admission; no vendor process is executed.""" +import json +import unittest + +import project_native_runtime as runtime + + +class CursorRuntimeTests(unittest.TestCase): + source = {"source_commit": "a" * 40, "source_tree": "b" * 40} + + def result(self): + return {"client": "cursor", "version": "2026.10.22-27dcf5f", "activation": "prepared", + "runtime_attempt_complete": False, "test_failed": True, + "vendor_authentication_qualified": False, "real_model_qualified": False, + "qualification": {key: False for key in runtime.shared.QUALIFICATION_CASES}, + "source_identity": {"installer_base_commit": "a" * 40, "installer_tree": "b" * 40, + "installer_source_state": "committed", "installer_patch_sha256": []}} + + def test_first_capture_registers_exact_cursor_test_and_only_cursor_labels(self): + self.assertEqual(runtime.CASES["cursor"], { + "test": "TestAgentpluginsCursorNativeProjectRuntimeAttempt", + "file": "repotests/agentplugins_cursor_project_native_attempt_test.go", + "optin": "AGENTPLUGINS_CURSOR_PROJECT_RUNTIME_ATTEMPT", "seconds": 180, "phase": "runtime-attempt"}) + labels = ("empty-a-mcp", "empty-b-mcp", "empty-outside-mcp", "control-b-mcp", "control-outside-mcp") + row = {"argv": ["/pin/cursor-agent", "mcp", "list"], "cwd": "/owned/A", "exit": 0, + "error": False, "deadline": False, "elapsed_ms": 1, + "cleanup": "full descendant supervision; forced cleanup fails"} + for label in labels: + bodies = {"runtime-attempt.json": json.dumps(self.result()).encode(), + label + ".command.json": json.dumps(row).encode(), + label + ".stdout.log": b"opaque native capture\n", label + ".stderr.log": b""} + self.assertTrue(runtime.cli_attempt_members(bodies, self.source, "cursor")["test_failed"]) + for client in ("grok", "qoder", "kiro", "windsurf"): + other = {**self.result(), "client": client, "version": runtime.CLI_ATTEMPTS[client]} + with self.assertRaises(ValueError): + runtime.cli_attempt_members({**bodies, "runtime-attempt.json": json.dumps(other).encode()}, self.source, client) + with self.assertRaises(ValueError): + runtime.cli_attempt_members({k: v for k, v in bodies.items() if k != label + ".stderr.log"}, self.source, "cursor") + + def test_native_text_cannot_qualify_initial_capture(self): + for change in ({"test_failed": False}, {"runtime_attempt_complete": True}, + {"vendor_authentication_qualified": True}, {"real_model_qualified": True}, + {"version": "latest"}, {"source_identity": {}}, + {"qualification": {key: True for key in runtime.shared.QUALIFICATION_CASES}}): + with self.assertRaises(ValueError): + runtime.cli_attempt_members({"runtime-attempt.json": json.dumps({**self.result(), **change}).encode()}, self.source, "cursor") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_project_native_runtime.py b/scripts/test_project_native_runtime.py index d2994c490..5cd993b83 100644 --- a/scripts/test_project_native_runtime.py +++ b/scripts/test_project_native_runtime.py @@ -898,6 +898,7 @@ def load_tests(loader, tests, pattern): # Broad research discovery already selects the new client-specific module. if pattern in (None, "test_project_native_runtime.py"): tests.addTests(loader.loadTestsFromName("test_project_native_cline_cycle.CompleteCycleTests")) + tests.addTests(loader.loadTestsFromName("test_project_native_cursor_runtime.CursorRuntimeTests")) return tests From 45f562161e710eec41e8d6b50a5253059415c84e Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 30 Oct 2026 02:35:34 +0100 Subject: [PATCH 5/5] test(cursor): expect encoded managed stdio arguments --- .../agentplugins/providers/project_cursor_stdio_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/install/integrationctl/agentplugins/providers/project_cursor_stdio_test.go b/install/integrationctl/agentplugins/providers/project_cursor_stdio_test.go index e6580616d..90bf2a198 100644 --- a/install/integrationctl/agentplugins/providers/project_cursor_stdio_test.go +++ b/install/integrationctl/agentplugins/providers/project_cursor_stdio_test.go @@ -2,6 +2,7 @@ package providers_test import ( "context" + "encoding/base64" "encoding/json" "errors" "os" @@ -37,7 +38,8 @@ func TestProjectCursorStdioUsesBoundManagedLauncherWithoutExecution(t *testing.T server := document.Servers["docs"] active := fixture.request.Plan.ActivePath data := filepath.Join(fixture.request.Plan.TargetContext.ProjectRoot, ".uap", "data", fixture.request.Plan.PhysicalArtifactID) - wantArgs := []string{managedstdio.Mode, active, data, active, "plugin", "--", "node", active + "/server.js", data} + wantArgs := []string{managedstdio.Mode, active, data, active, "plugin", "--argv-base64-v1", "bm9kZQ==", + base64.StdEncoding.EncodeToString([]byte(active + "/server.js")), base64.StdEncoding.EncodeToString([]byte(data))} if server.Command != filepath.Join(active, filepath.FromSlash(managedstdio.RelativeDirectory), managedstdio.ExecutableName) || !reflect.DeepEqual(server.Args, wantArgs) || server.CWD != "" || server.Env["PLUGIN_ROOT"] != active || server.Env["PLUGIN_DATA"] != data { t.Fatalf("native launcher binding = %+v; want args %q", server, wantArgs) }