diff --git a/cli/cmd/agentplugins/cursor_project_fixture_test.go b/cli/cmd/agentplugins/cursor_project_fixture_test.go new file mode 100644 index 000000000..9a6a285e0 --- /dev/null +++ b/cli/cmd/agentplugins/cursor_project_fixture_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/cli/internal/agentpluginscli" + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/sourceacquisition" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/specregistry" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/ports" +) + +type cursorProjectDetector struct{ root string } + +func (detector cursorProjectDetector) Detect(context.Context) ([]domain.DetectedClient, error) { + return []domain.DetectedClient{{ClientID: domain.ClientCursor, ConfigRoot: detector.root, Status: domain.DetectionDetected}}, nil +} + +// Reuse the core journey's production CLI/state helpers and real TLS Git/ZIP +// transports. Cursor supplies its own detected profile and native layout. +type cursorProjectJourney struct{ projectJourney } + +func newCursorProjectJourney(t *testing.T, transport *projectJourneyTransport) cursorProjectJourney { + t.Helper() + f := cursorProjectJourney{projectJourney{home: t.TempDir(), a: t.TempDir(), b: t.TempDir(), transport: transport}} + f.cache = filepath.Join(f.home, "uap") + for _, root := range []string{"", f.a, f.b} { + native := f.cursorRoot(root) + projectJourneyWrite(t, filepath.Join(native, "mcp.json"), `{"mcpServers":{"foreign":{"url":"https://foreign.test/mcp","disabled":true}},"foreignPreference":"keep"}`) + projectJourneyWrite(t, filepath.Join(native, "skills", "foreign", "SKILL.md"), "foreign skill\n") + projectJourneyWrite(t, filepath.Join(native, "hooks.json"), "foreign hooks\n") + } + registry, err := specregistry.New() + if err != nil { + t.Fatal(err) + } + app, err := newAgentpluginsCLIApp(f.home, f.cache, nil, nil, nil, registry) + if err != nil { + t.Fatal(err) + } + f.app = configureCursorProjectJourney(app, f.home, transport) + return f +} + +// Preserve the production scoped composer, installer, ownership kernel, store, +// registry and solver. Only host discovery and security assessment are inert. +func configureCursorProjectJourney(app agentpluginscli.App, home string, transport *projectJourneyTransport) agentpluginscli.App { + app.Detector = cursorProjectDetector{root: filepath.Join(home, ".cursor")} + app.SecurityIndex, app.DirectoryClient, app.DiscoveryClient = nil, nil, nil + app.SecurityEvaluator = projectJourneySecurity{} + app.SecurityEvaluatorForDryRun = func(string) ports.PackageSecurityEvaluator { return projectJourneySecurity{} } + app.SourceAcquirer = sourceacquisition.Acquirer{TempRoot: home, HTTPClient: transport.server.Client()} + app.SourceAcquirerForDryRun = func(root string) agentpluginscli.SourceAcquirer { + return sourceacquisition.Acquirer{TempRoot: root, HTTPClient: transport.server.Client()} + } + app.DirectoryClientForDryRun, app.CommitDirectoryPreview = nil, nil + compose := app.ForScope + app.ForScope = func(scope domain.InstallScope, root string) (agentpluginscli.App, error) { + selected, err := compose(scope, root) + if err != nil { + return selected, err + } + return configureCursorProjectJourney(selected, home, transport), nil + } + return app +} + +func (f cursorProjectJourney) cursorRoot(project string) string { + if project == "" { + return filepath.Join(f.home, ".cursor") + } + return filepath.Join(project, ".cursor") +} + +func (f cursorProjectJourney) assertCursorOwned(t *testing.T, project, skill, endpoint string) (domain.Installation, domain.ClientBinding) { + t.Helper() + installation, binding := f.owned(t, project) + if binding.Scope != string(domain.ScopeProject) || binding.NativeProfileRoot != f.cursorRoot(project) || binding.Activation != domain.ActivationPrepared || binding.Verification != domain.VerificationInstalled { + t.Fatalf("Cursor project lifecycle lost its exact scope or conservative observation: %+v", binding) + } + if err := pathpolicy.RequireContainedChild(filepath.Join(project, ".uap"), binding.TargetLocator); err != nil { + t.Fatal(err) + } + for _, root := range []string{binding.TargetLocator, f.cursorRoot(project)} { + if projectJourneyRead(t, filepath.Join(root, "skills", "docs", "SKILL.md")) != skill { + t.Fatal("Cursor managed/native skill bytes differ from the selected pin") + } + } + settings := projectJourneyRead(t, filepath.Join(f.cursorRoot(project), "mcp.json")) + for _, text := range []string{endpoint, "foreign.test", "foreignPreference", `"disabled":true`} { + if !strings.Contains(strings.ReplaceAll(settings, " ", ""), text) { + t.Fatalf("Cursor settings lost selected or foreign member %q: %s", text, settings) + } + } + return installation, binding +} + +// Select inert remote configuration so the composed proof never executes an +// MCP command, copies a test binary as a launcher, or starts a native client. +func cursorProjectCommitSource(t *testing.T, transport *projectJourneyTransport, skill, endpoint string) { + t.Helper() + projectJourneyWrite(t, filepath.Join(transport.repo, "skills", "docs", "SKILL.md"), skill) + projectJourneyWrite(t, filepath.Join(transport.repo, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"docs":{"type":"streamable-http","url":"`+endpoint+`"}}}`) + projectJourneyGit(t, transport.repo, "add", ".") + projectJourneyGit(t, transport.repo, "commit", "-m", "select inert Cursor project components") + projectJourneyGit(t, transport.repo, "push", "--quiet", transport.remote, "main") + transport.mu.Lock() + defer transport.mu.Unlock() + transport.archive = projectJourneyArchive(t, transport.repo) +} diff --git a/cli/cmd/agentplugins/cursor_project_lifecycle_test.go b/cli/cmd/agentplugins/cursor_project_lifecycle_test.go new file mode 100644 index 000000000..71b2f2080 --- /dev/null +++ b/cli/cmd/agentplugins/cursor_project_lifecycle_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// This is the production CLI/dependency/installer/native-filesystem composition +// with real loopback TLS Git and ZIP acquisition. Native Cursor discovery, +// enablement, MCP initialization, model use and runtime are not certified. +func TestProjectCursorComposedSourceLifecycle(t *testing.T) { + for _, kind := range []string{"git", "zip"} { + t.Run(kind, func(t *testing.T) { + transport := newProjectJourneyTransport(t, kind) + cursorProjectCommitSource(t, transport, projectJourneySkill, "https://managed.test/v1") + f := newCursorProjectJourney(t, transport) + userBefore := securityFixtureTree(t, f.cursorRoot("")) + for _, root := range []string{f.a, f.b} { + f.run(t, root, "add", transport.endpoint, "--alias", "docs", "--target", "cursor") + f.assertCursorOwned(t, root, projectJourneySkill, "https://managed.test/v1") + } + bNative := securityFixtureTree(t, f.cursorRoot(f.b)) + bState := projectJourneyRead(t, filepath.Join(f.namespace(f.b), "state-v2.json")) + cursorProjectReconcileAndRepair(t, f) + cursorProjectUpdateAndTransfer(t, f) + cursorProjectRemove(t, f) + if !reflect.DeepEqual(userBefore, securityFixtureTree(t, f.cursorRoot(""))) || !reflect.DeepEqual(bNative, securityFixtureTree(t, f.cursorRoot(f.b))) || bState != projectJourneyRead(t, filepath.Join(f.namespace(f.b), "state-v2.json")) { + t.Fatal("Cursor project-A lifecycle changed user or project-B native configuration/state") + } + f.assertCursorOwned(t, f.b, projectJourneySkill, "https://managed.test/v1") + }) + } +} + +func cursorProjectReconcileAndRepair(t *testing.T, f cursorProjectJourney) { + t.Helper() + statePath := filepath.Join(f.namespace(f.a), "state-v2.json") + before := projectJourneyRead(t, statePath) + f.run(t, f.a, "deps", "sync", "--locked", "--yes") + if before != projectJourneyRead(t, statePath) { + t.Fatal("Cursor locked no-op changed exact ownership receipts") + } + installation, _ := f.owned(t, f.a) + native := securityFixtureTree(t, f.cursorRoot(f.a)) + for _, args := range [][]string{{"list"}, {"info", installation.InstallationID}, {"doctor", installation.InstallationID}, {"outdated", installation.InstallationID}, {"deps", "status"}, {"deps", "tree"}} { + f.run(t, f.a, args...) + } + if before != projectJourneyRead(t, statePath) || !reflect.DeepEqual(native, securityFixtureTree(t, f.cursorRoot(f.a))) { + t.Fatal("Cursor project read commands changed native files or ownership receipts") + } + if err := os.RemoveAll(filepath.Join(f.cursorRoot(f.a), "skills", "docs")); err != nil { + t.Fatal(err) + } + f.run(t, f.a, "repair", installation.InstallationID, "--target", "cursor") + f.assertCursorOwned(t, f.a, projectJourneySkill, "https://managed.test/v1") + f.inspectNoRecovery(t, f.a) +} + +func cursorProjectUpdateAndTransfer(t *testing.T, f cursorProjectJourney) { + t.Helper() + previous, binding := f.owned(t, f.a) + cursorProjectCommitSource(t, f.transport, projectJourneyAdvancedSkill, "https://managed.test/v2") + f.run(t, f.a, "update", "docs", "--target", "cursor") + updated, selected := f.assertCursorOwned(t, f.a, projectJourneyAdvancedSkill, "https://managed.test/v2") + if updated.Source.SourceBindingID != previous.Source.SourceBindingID || updated.Source.TreeDigest == previous.Source.TreeDigest || selected.ClientBindingID != binding.ClientBindingID || selected.TargetLocator != binding.TargetLocator { + t.Fatal("Cursor update did not retain source/binding authority while advancing the pin") + } + pair := f.assertPortablePair(t, f.a) + exchange := t.TempDir() + f.run(t, f.a, "deps", "export", exchange, "--yes") + if pair != f.assertPortablePair(t, exchange) { + t.Fatal("Cursor export changed exact portable pins") + } + cold := newCursorProjectJourney(t, f.transport) + projectJourneyAbsent(t, cold.cache, filepath.Join(cold.a, ".uap")) + cold.run(t, cold.a, "deps", "import", exchange, "--install", "--yes") + restored, _ := cold.assertCursorOwned(t, cold.a, projectJourneyAdvancedSkill, "https://managed.test/v2") + if restored.Source.SourceBindingID != updated.Source.SourceBindingID || restored.Source.TreeDigest != updated.Source.TreeDigest || pair != cold.assertPortablePair(t, cold.a) { + t.Fatal("Cursor cold import changed source identity or portable pins") + } + cold.inspectNoRecovery(t, cold.a) +} + +func cursorProjectRemove(t *testing.T, f cursorProjectJourney) { + t.Helper() + _, binding := f.owned(t, f.a) + f.run(t, f.a, "remove", "docs", "--target", "cursor") + projectJourneyAbsent(t, binding.TargetLocator, filepath.Join(f.cursorRoot(f.a), "skills", "docs")) + settings := projectJourneyRead(t, filepath.Join(f.cursorRoot(f.a), "mcp.json")) + if strings.Contains(settings, "managed.test") || !strings.Contains(settings, "foreign.test") || !strings.Contains(settings, "foreignPreference") { + t.Fatalf("Cursor removal changed foreign MCP settings or retained its entry: %s", settings) + } + if projectJourneyRead(t, filepath.Join(f.cursorRoot(f.a), "skills", "foreign", "SKILL.md")) != "foreign skill\n" || projectJourneyRead(t, filepath.Join(f.cursorRoot(f.a), "hooks.json")) != "foreign hooks\n" { + t.Fatal("Cursor removal changed foreign skill or hooks") + } + if len(f.state(t, f.a).EnvironmentReceipts) != 0 { + t.Fatal("Cursor removal retained an environment ownership receipt") + } +} diff --git a/docs/plans/project-install-e2e/client-plans.md b/docs/plans/project-install-e2e/client-plans.md index 76e87fa89..8a5b6a316 100644 --- a/docs/plans/project-install-e2e/client-plans.md +++ b/docs/plans/project-install-e2e/client-plans.md @@ -234,6 +234,17 @@ editor or official `cursor-agent`/`agent` version must load project MCP/skill in A and not B; CLI and UI evidence are separate. No genuine Stop or plugin discovery claim follows from this component work. +### Implemented component contract checkpoint + +The Cursor child stack implements native project component ownership at +`.cursor/skills` and `.cursor/mcp.json`, using `.uap` managed artifacts and the +existing kernel/managed stdio launcher. User package preparation remains manual. +Production loader/planner/renderer/stager/provider contracts pass for scopes, +updates, missing-same-pin repair, exact removal and failure preservation. Project +readiness remains prepared+installed; native session discovery is unobserved +and attestation cannot upgrade it. See `cursor-implementation-evidence.md` for +current official docs, pinned read-only CLI help and explicit native proof limits. + ## 4. Gemini CLI — native project skills and settings **Evidence/choice.** Project settings are `.gemini/settings.json`; project skills diff --git a/docs/plans/project-install-e2e/cursor-code-review-r1.md b/docs/plans/project-install-e2e/cursor-code-review-r1.md new file mode 100644 index 000000000..bfd68b564 --- /dev/null +++ b/docs/plans/project-install-e2e/cursor-code-review-r1.md @@ -0,0 +1,114 @@ +# Cursor project adapter independent implementation review R1 + +Reviewer: codex_claude, 2026-10-23. +Reviewed adapter commit `524b808b401d29ebe5f932dc7ce0f5258746f31d` +in `work/uap-project-cursor`. The shared ProjectComponents helper already has +separate independent implementation reviews; this review concentrates on the +final Cursor adapter, production composition, projection and lifecycle. + +## Decision: GO for the configured project component implementation + +No correctness blocker found in the reviewed adapter diff. This decision does +not qualify Cursor native discovery, an MCP handshake or tool result, fresh +native-session behavior, plugin installation, genuine Stop, or release readiness. +The implementation and evidence correctly keep those observations pending. + +## Reviewed boundaries + +- Project scope selects exact `/.cursor` native authority while the + detected user ConfigRoot remains distinct. Planning, activation, inspection, + binding validation and removal revalidate the selected project/native root; + mismatching scope, original user root and copied project context refuse. + Prepared package state remains under the selected project's `.uap` namespace. +- Project policy is declared through core's scoped native-config lifecycle + traits. Existing user preparation and its CLI-registry traits remain intact; + project mutations do not invoke a user CLI or redirect HOME/ConfigRoot. +- Selected skills, including existing rendered command skills, receive exact + directory ownership. MCP projection goes through the existing exact-entry + kernel. The adapter strips only the shared managed-package receipt before + handing objects to the native component helper; unknown ownership kinds are + retained for closed refusal. Removal never treats a shared config as owned. +- Stdio projection uses the trusted managed launcher with absolute package, + persistent-data and cwd bindings. Active reprojection reads already-bound + staged bytes without executing or mutating them. Remote Cursor native config + uses the documented URL/headers shape. The official MCP documentation permits + the URL-only remote shape for HTTP/SSE; no native execution claim follows. +- Automatic file success is downgraded from shared activation success to + `prepared` plus `installation_verified`, with discovery, aliases, enablement, + approvals, policy and MCP initialization explicitly unobserved. Generic + `ActivationComplete` is rejected by preflight before provider shortcut + admission and also by direct adapter reuse, including prepared selections. +- Provider contracts cover unchanged verification, update, missing-same-pin + repair, withdrawn MCP, repeated remove, persistent data, malformed JSON, + foreign disabled/member/skill collisions, writer failure rollback, contained + alias refusal, and user/project-A/project-B byte-and-mode isolation. These are + loader/planner/stager/provider fixture contracts; they are not a claim that + native Cursor was run or that a hosted all-command journey has completed. + +## Independent verification + +Using the shared Go 1.26 toolchain and bounded existing caches: + +- Complete `clients/cursor` package: PASS, 0.141s. +- `providers -run 'TestProjectCursor|TestCursor' -count=1`: PASS, 0.168s; + this selected all newly added composed Cursor project contracts. The matching + pattern selected no client-package tests, so the complete client package was + run separately as recorded above. + +Inspected the production activation preflight/attestation order, shared MCP +projection and nativeconfig neutral projection, scoped domain declaration, +existing user projector and all four new provider fixtures. Consulted current +official `https://cursor.com/docs/mcp`, which documents project/global config +locations, URL/headers remote entries and native enablement/approval boundaries. +No Cursor process, model task, package payload, trust action, login, configured +MCP command or launcher was executed by this review. + +## Remaining evidence boundary + +The authored fixture proof and pinned official help do not establish runtime +discovery or initialization. A genuine pinned Cursor session in the approved +disposable-host workflow remains the separate gate for native observations. +Public reporting must keep this adapter's configured/prepared status and those +pending native observations distinct. A separate independent R2 review is still +required for the final adapter checkpoint. + +## Final R1 selected-data correction follow-up: GO at f55dc462 + +Independently reviewed the narrow corrective commit `f55dc462` after the root +identified the analogous stale selected-data authority gap. Project active +reprojection now validates each selected stdio entry against the exact managed +launcher path, selected active package, selected data receipt and resolved cwd +anchor. Both native PLUGIN_ROOT/PLUGIN_DATA values and the launcher's six-field +package/data/cwd protocol prefix must agree; missing, changed or stale values +return ErrProjectionRefreshRequired. This is read-only validation of already +digest-verified package bytes. Existing user projection and remote-only native +entries retain their prior contract. + +The existing composed stdio fixture now refuses an empty locator and a changed +selected receipt, while comparing the complete immutable active-package +inventory after each refusal. It still accepts the exact selected authority. +Independent `providers -run '^TestProjectCursor' -count=1` **PASS 0.065s** +(Go1.26.10/GOMAXPROCS3/-p2). The same pattern selected no client tests, which is +recorded rather than presented as new full-client execution; the earlier full +client run remains recorded above. No blocker remains in this correction. +Neither review execution launched Cursor, its launcher or an MCP process, and +the genuine hosted native boundary is unchanged. The independent R2 author +has separately recorded GO for the same corrective checkpoint. + +## Scoped hosted test-selection closure: GO at 32c33c6f + +Independently reviewed `32c33c6fa96d790396a6fc1be3f1a36537ef4c9c` +after the reviewed project-stack CI baseline merge `935e3e08`. The complete +source file is byte-identical after replacing only the function symbol +TestCursorProjectComposedSourceLifecycle with +TestProjectCursorComposedSourceLifecycle; no fixture or assertion changed. +The adjacent evidence accurately updates its test name and records pending +hosted/native results. + +Extracted the actual project-contracts workflow regex +`Test(TargetContext|Project|PrepareRejectsCrossEnvironmentContext)` and checked +that it selects the corrected name while excluding the previous spelling. +That job includes the public CLI package on Linux/macOS/Windows. No broad +compile or rerun is needed for this literal symbol-only correction, and none +was represented as fresh execution. The bounded selection correction is GO; +real cross-platform/native outcomes remain their separate hosted gates. diff --git a/docs/plans/project-install-e2e/cursor-code-review-r2.md b/docs/plans/project-install-e2e/cursor-code-review-r2.md new file mode 100644 index 000000000..a9206941e --- /dev/null +++ b/docs/plans/project-install-e2e/cursor-code-review-r2.md @@ -0,0 +1,85 @@ +# Cursor project implementation independent review R2 + +Reviewer: core_clients, 2026-10-23. Reviewed adapter `524b808b`, composed +public CLI lifecycle/transfer `37bbb5d7`, and corrective frozen +`f55dc4628a8ce1b671b80e82d07dd0d66011eb15`. The reviewed shared helper and +original two client-plan GO results are separate records. No production file +was edited by this reviewer. + +## Decision: GO at f55dc462 + +One selected-data-authority finding is independently verified closed. No +remaining implementation blocker was found in the configured project component +lane or its composed source lifecycle. This does not supply native Cursor, +MCP runtime, editor or release qualification. + +## R2-1: active reprojection ignored selected data authority + +At `37bbb5d7`, ProjectActiveNative ignored its final dataPath parameter. Core's +activeNativeDelivery validates and supplies the selected persisted data receipt, +but the adapter returned receipts derived only from the old immutable mcp.json. +A different selected data locator could therefore silently retain the prior +launcher/environment locator instead of refusing the inconsistent projection. + +Closed in `f55dc462`. The project-only read-only verifier now requires selected +stdio data to be absolute and nonempty, exact launcher/package/data environment +bindings, no native cwd field, and the exact managed launcher protocol prefix +for mode, package, data, resolved cwd, anchor and separator. It recomputes the +cwd prefix from the current standard envelope and supplied selected locators. +Inconsistent authority returns ErrProjectionRefreshRequired without rewriting +digest-covered package bytes. Remote components and the preserved user lane +retain their prior behavior. The existing positive stdio contract now supplies +its actual data locator; wrong and empty selected-data regressions require +refusal and an unchanged immutable package inventory. + +## Scope, ownership and lifecycle assessment + +- Exact validated project context selects `.cursor` while the original user + config root remains distinct. Planning, active projection, activation, + binding validation and removal fence the scoped registry and package path. + Mutable state/data/prepared packages stay under the selected `.uap`. +- Declarative project policy uses the existing native-config lifecycle rather + than borrowing user CLI-registry policy. User manual package preparation, + detection and existing attestation behavior remain intact. +- Skills and existing rendered command skills use exact directory receipts; + MCP members use the existing transactional ownership kernel. The adapter + removes only the managed-package receipt from native component inputs and + leaves unknown kinds visible for closed refusal. Foreign/disabled same-name + objects collide; removal never owns an entire shared config document. +- Managed stdio uses the existing launcher and explicit package/data/cwd + protocol. The new active check validates those bindings read-only. Source + directories and immutable package bytes are never executed by the proof. +- Successful file configuration remains prepared plus installed verification. + Project preflight rejects activation attestation before provider shortcuts, + including prepared component selections and direct adapter reuse. Discovery, + aliases, enablement, policy, approvals and MCP initialization stay unobserved. +- The composed Git/ZIP journey retains production scope composition, solver, + locks, immutable store, planner, stager, installer and native transactions. + Only detection and security-assessment boundaries are inert. It exercises + saved A/B add, byte-stable locked sync, read commands without effects, + missing-native-skill repair, moving pin update, exact portable export, + cold-cache import/install and exact removal. Foreign user config/hooks/skills + and project-B native/state inventories remain exact while A changes. +- Remote-only composed fixtures avoid launcher or configured-command execution. + Separate provider tests cover stdio/data, rendered commands, no-op/update, + withdrawn MCP, repeated removal, malformed JSON, foreign collisions, writer + failure rollback, scope/context mismatch and contained native aliases. + +## Independent final execution + +Go 1.26.10, GOMAXPROCS=3, `-p=2`, bounded restored RAM toolchain/cache: + +- Complete Cursor client package: **PASS 0.088s**. +- Final `^TestProjectCursor` production provider contracts: **PASS 0.124s**. +- Final public `TestCursorProjectComposedSourceLifecycle` real TLS Git/ZIP + lifecycle and cold transfer: **PASS 7.667s**. + +No Cursor process, model request, configured MCP command, package payload, +trust/approval/login change or hosted native proof was executed. Author's +corrective lint gates and the other independent review remain applicable to +the final checkpoint. Actual native discovery, fresh native restarts, editor +behavior, MCP handshake/tool invocation and release qualification remain pending. +# Hosted selector closure — 2026-10-23 + +Independent R2 rechecked isolated `32c33c6f` against its parent: the only test-code change renames `TestCursorProjectComposedSourceLifecycle` to `TestProjectCursorComposedSourceLifecycle`. The entire Git/ZIP lifecycle body and assertions are unchanged. The existing cross-platform project-contract selector `Test(TargetContext|Project|PrepareRejectsCrossEnvironmentContext)` now includes the composed test through its `TestProject` prefix. The accompanying evidence note correctly leaves pending hosted/native runs pending. **GO for this symbol-only selection correction.** No additional compilation was needed to establish this exact rename preservation; previously reviewed composed behavior retains its prior evidence. + diff --git a/docs/plans/project-install-e2e/cursor-implementation-evidence.md b/docs/plans/project-install-e2e/cursor-implementation-evidence.md new file mode 100644 index 000000000..34b2c4d1d --- /dev/null +++ b/docs/plans/project-install-e2e/cursor-implementation-evidence.md @@ -0,0 +1,174 @@ +# Cursor project component evidence + +## Pre-implementation evidence, 2026-10-23 + +The reviewed implementation selects project components. Official documentation +confirms `.cursor/mcp.json` for project MCP and `.cursor/skills/` for project +skills. User MCP remains `~/.cursor/mcp.json`; native discovery also considers +other skill roots, including `.agents/skills` and compatibility directories. +The MCP CLI documentation exposes interactive listing, connection status and +project/global configuration source. Enablement and tool approval remain native +client decisions. No enable, approve, trust, login or model command is invoked +by this adapter. + +Sources checked before coding: + +- +- +- +- + +The plugin UI documents project and user install scopes, but this implementation +does not infer a stable unattended plugin-install interface from that UI. It +retains the existing user editor package preparation path and uses exact owned +project skill directories and MCP members for project requests. It does not +write user configuration or hooks, redirect HOME, or detect an ambiguous `agent` +binary as the editor. + +## Evidence boundary + +Pinned official CLI artifact/help research is recorded below. +Production provider/loader/planner/stager contract tests will be recorded below; +they do not prove that a real editor or Cursor Agent discovers, enables or +executes these components. Project configuration success remains prepared with +installed verification, and activation attestation cannot upgrade that claim. +Native session discovery, policy, approvals, aliases and MCP initialization +remain unobserved until the existing hosted native-client workflow qualifies +that exact client/version/platform. + +## Pinned official CLI research + +Parent provisioned Cursor Agent `2026.10.22-27dcf5f` directly from the official +installer's download metadata, without executing the installer: + +- URL: +- Measured archive SHA256: `85296944e4600a83c4b764f578d1502bf9c416db200d33a593e8f3ecabfdc294`. +- The installer did not provide an independently authenticated digest; this is + a measured acquisition pin, not a vendor signature claim. +- Actual version, top-level help and MCP help exited zero in a networkless + disposable namespace with fresh HOME/project and no user mounts. These are + read-only research probes, not native component discovery qualification. +- MCP help explicitly names `.cursor/mcp.json` and `~/.cursor/mcp.json` and + exposes list/list-tools/enable/disable/login. No enable/login was invoked. +- Current top-level help also exposes `plugin`, `--plugin-dir`, and `--workspace`. + No assertion that Cursor Agent lacks plugin CLI support is made. This stack + retains the reviewed component lane; plugin installation is a separate native + delivery contract requiring its own scoped ownership qualification. + +The project component lane does not add ambiguous `agent` executable detection +or claim that agent-only detection selects the editor. Existing editor and +Cursor Agent detection surfaces remain distinct. + + +Follow-up actual plugin help exposed only the `marketplace` child. Requests for +install/list/uninstall `--help` printed the same ancestor help and did not +establish those lifecycle verbs or a project scope grammar. This component +implementation therefore does not claim that CLI plugin install/remove was +qualified by those probes. + +## Implementation and contract verification + +The adapter qualifies the catalog's Cursor project scope using native file +configuration lifecycle traits while preserving user manual preparation. +ProjectTargetRoot selects managed artifacts under project `.uap`; NativeRegistry +selects exact `.cursor`. It validates original detected user root and matching +scope/target/native root throughout plan, inspect, activate, verify and remove. +Project binding validation accepts only the selected managed artifact and exact +native ownership paths. The shared artifact receipt is handled by core; the +component helper receives only native ownership, retaining unknown kinds for +closed refusal. + +The new shared component helper has two independent plan reviews and two +independent implementation reviews in this directory. It uses the existing +nativeconfig Kernel, exact receipts, verified directory swaps and rollback; +there is no second settings/discovery engine. The project stdio projection +uses the existing trusted launcher and binds absolute package/data/cwd inputs. +User package projection and legacy attestation behavior retain their existing +contract. Project attestation is refused before generic provider shortcuts. + +Author verification includes complete Cursor/shared/domain package tests +(PASS 0.189s/0.078s/0.038s), project provider/planner contracts and unchanged +user activation goldens (PASS 0.359s/0.014s). The task host carried duplicated +uppercase/lowercase proxy environment names; the pre-existing Codex user +validator rejects those even when discarded. Removing only duplicated lowercase +proxy names for this inert golden check restored its expected existing behavior; +no production or golden change was made for that unrelated host condition. +Composed Cursor tests load standard plugin.json, render a nonrestricted command +skill, stage production projections and drive production providers. They cover +remote and stdio MCP, read-only active reprojection, repeat/noop/update, +missing-same-pin repair, dropped MCP, repeat removal and persistent data, +foreign disabled entries/hooks/skills, malformed config, lock failure rollback, +user/project-A/project-B byte and mode inventories, scope/root/copied-context +mismatch, contained native aliases and direct/provider attestation refusal. +The injected launcher bytes are inert and never executed. + +Both exact changed-code and full-file size/architecture lint gates pass without +new suppressions. The compatibility golden delta changes only the newly +qualified Cursor Scopes field from `[user]` to `[user, project]` in four reports. +Actual native session discovery, MCP handshake/tool result and fresh restart +qualification remain pending the hosted native-client matrix; none of these +fixture or help probes is presented as that evidence. + +## Composed source lifecycle checkpoint + +The Cursor stack was aligned with reviewed integration `d9f3f757` in local +merge `12177d41`. Dedicated Cursor CLI tests reuse that integration's real +loopback TLS Git and ZIP acquisition transports and its CLI/state inspection +helpers. The scoped CLI composer, dependency solver/manifest/lock, immutable +store, installer, planner, stager and native configuration writer are production +implementations. Only host detection and package security assessment are inert +fixture boundaries. Remote MCP entries avoid executing configured commands or +copying the test executable as a managed launcher. + +`TestProjectCursorComposedSourceLifecycle` covers both Git and ZIP sources: +saved project add in A and B, exact native skill/MCP paths, locked no-op sync, +list/info/doctor/outdated/dependency status/tree reads without native/state +changes, missing native skill repair, pin-advancing update that retains binding +authority, portable export and cold-cache import/install into a new project, +and exact removal. User Cursor configuration/hooks/skills and project B retain +their byte/mode inventories and recorded state while only A changes. The cold +restore retains the exact exported agreement and portable source/tree identity. +Project bindings stay prepared plus installed verification throughout. This +remote-only composed fixture does not allocate persistent stdio data; the +separate stdio provider contract remains the data/launcher evidence. + +Final author verification for the composed checkpoint: + +- Dedicated CLI Git/ZIP lifecycle: PASS, 5.865s. +- Existing project Cursor provider contracts after integration: PASS, 0.183s. +- Exact changed-code and full-file size/complexity/architecture lint gates: + 0 issues, without new suppressions. +- Git whitespace check: clean. + +Independent final adapter R1 is GO in `cursor-code-review-r1.md`; final R2 and +review of the added composed journey remain separate gates. These tests do not +launch Cursor, execute a package payload or configured MCP command, write +trust/approval/login state, or issue a model request. Genuine Cursor discovery, +effective enablement and policy, MCP initialization/tool use, editor behavior, +fresh native restarts and release qualification remain unobserved. + +## Independent R2 correction: selected data authority + +The final adapter R2 found that read-only active reprojection ignored its +selected DataReceipt locator and could reuse old embedded stdio bindings. +Project active reprojection now refuses a different or absent selected data +locator with the existing projection-refresh-required error. It verifies the +managed launcher path, reserved package/data environment, and protocol +package/data/cwd/anchor arguments against the selected active package and data +receipt before producing native ownership. It uses the existing read-only cwd +resolver and never changes digest-covered bytes to hide a mismatch. User +projection and remote-only project entries retain their previous behavior. + +The composed stdio provider regression supplies the correct bound locator for +the positive read-only case, then requires wrong/empty data locators to fail +with unchanged immutable package bytes. Full project Cursor provider selection +passes at this correction: **PASS 0.180s**. The independent R2 reviewer must +recheck this correction before closing the adapter review. + +## Cross-platform source journey selection + +The composed Git/ZIP source lifecycle test now starts with `TestProjectCursor` +so the existing project-contracts selector includes it on Linux, macOS and +Windows. Only the test name changes; its assertions and inert source/runtime +boundaries remain identical. This corrects hosted test selection and does not +claim that any pending native or cross-platform run has passed. diff --git a/install/integrationctl/agentplugins/clients/cursor/README.md b/install/integrationctl/agentplugins/clients/cursor/README.md index 979cfcfa5..71ff06df0 100644 --- a/install/integrationctl/agentplugins/clients/cursor/README.md +++ b/install/integrationctl/agentplugins/clients/cursor/README.md @@ -1,6 +1,35 @@ -# Cursor adapter: selected editor package preparation +# Cursor adapter: project components and user package preparation -This adapter retains the manual skill/MCP package workflow. It prepares an +## Project component lane + +Project requests own exact directories at `.cursor/skills/` and exact +MCP entries in `.cursor/mcp.json`. Prepared artifacts stay under the selected +project's `.uap/managed/clients/cursor/`. Detected user ConfigRoot remains user +authority and is never replaced by the workspace root. Native config ownership +uses the existing kernel's whole-entry receipts, conditional writes and locks; +skill directories stage, verify and swap with rollback around one MCP batch. +Foreign same-name or disabled entries collide, foreign hooks/settings remain +untouched, and persistent application data survives component removal. + +Project stdio uses the existing trusted managed launcher with explicit package, +data and cwd bindings. It does not rely on plugin placeholder expansion or +implicit native cwd behavior. No client, package, model or MCP code runs during +installation. Successful configuration reports `prepared` activation with +`installation_verified`; discovery, aliases, enablement, approvals, policy and +MCP initialization remain unobserved. Project activation attestation is refused +before generic provider shortcuts and on direct adapter reuse. + +The composed contract suite covers loader/planner/command rendering/staging, +remote and stdio MCP projection, repeat/update/repair/component removal, +user/project A/B isolation, malformed/disabled/foreign collisions, writer +failure rollback, exact root/context authority and contained alias refusal. +These tests do not qualify native runtime discovery. Current official docs and +pinned CLI help evidence are recorded in +`docs/plans/project-install-e2e/cursor-implementation-evidence.md`. + +## Preserved user package preparation + +The user lane retains the manual skill/MCP package workflow. It prepares an editor compatibility manifest at `.cursor-plugin/plugin.json`, selected skills, and one package-local `mcp.json`. Native plugin discovery, agent CLI plugin or skill loading, and genuine native Stop are unqualified. Plan results say @@ -13,7 +42,7 @@ installation_verified without adapter dispatch. That operator assertion does not qualify native discovery, genuine Stop, or automatic activation. The single MCP strategy in this contract is **plugin package projection**. -It never writes user or project `mcp.json`, enables a disabled server, or +The user lane never writes user or project `mcp.json`, enables a disabled server, or registers a second copy. CLI user-config MCP lifecycle is the separate conditional UC4 slice. It cannot be inferred from this editor package contract. @@ -47,17 +76,15 @@ under changed ambient HOME/USERPROFILE/cwd. Foreign disabled user MCP and hooks remain byte-identical; the sibling namespace and prepared manifest survive. No client, helper, MCP process, model or hook is executed by these tests. -`ValidateBindingProfile` exposes the existing optional client contract for an -exact recorded artifact locator and, if present, `NativeProfileRoot`. -The current shared facade does not dispatch this interface or persist a Cursor -physical directory identity. Its `ReserveIdentity` can return an existing -binding without validating a supplied profile; update/repair still require -explicit roots. UC-P must bind those operations to recorded profile authority, -expose that authority in a public roots/facts view, and refuse mismatches or -replaced directories before effects. This adapter adds no parallel state store. -The existing registry inspector's clear finding covers absence of an additional -managed executable registration only; the prepared-directory walk still owns -namespace collisions. It is not an exhaustive native registry observation. +`ValidateBindingProfile` checks the selected native registry and recorded +artifact locator. Its user branch retains the exact `plugins/local` preparation +layout. Its project branch requires exact `.cursor` authority, the selected +`.uap/managed/clients/cursor/` artifact and exact native object +paths. Generic lifecycle checks independently validate scope, selected project +namespace and recorded authority; this adapter adds no parallel state store. +The user registry inspector retains its limited clear finding for executable +registration; the project inspector checks only the exact configured component +namespace. Neither is exhaustive native discovery evidence. Source evidence: macOS editor 3.22.7 bundle SHA256 `a9157bf9054d3f27a2a6910c855e028bd5e38896fa74405ca2f766abff590cb8`; diff --git a/install/integrationctl/agentplugins/clients/cursor/identity.go b/install/integrationctl/agentplugins/clients/cursor/identity.go index 31b7093d7..e19624525 100644 --- a/install/integrationctl/agentplugins/clients/cursor/identity.go +++ b/install/integrationctl/agentplugins/clients/cursor/identity.go @@ -11,10 +11,22 @@ var _ clients.RegistryInspector = (*Adapter)(nil) func (*Adapter) UsesNativeRegistryExecutable() bool { return false } -func (*Adapter) InspectNativeRegistry(ctx context.Context, _ clients.Env, _ domain.DetectedClient, _ domain.DeliveryPlan, _ *domain.ClientBinding) (clients.RegistryFinding, error) { +func (*Adapter) InspectNativeRegistry(ctx context.Context, env clients.Env, client domain.DetectedClient, plan domain.DeliveryPlan, managed *domain.ClientBinding) (clients.RegistryFinding, error) { if err := ctx.Err(); err != nil { return clients.RegistryIndeterminate, err } + if plan.Scope == domain.ScopeProject { + root, err := cursorPlanRoot(client, plan) + if err != nil { + return clients.RegistryIndeterminate, err + } + if managed != nil { + selected := *managed + selected.NativeObjects = cursorNativeObjects(managed.NativeObjects) + managed = &selected + } + return cursorComponents(root).Inspect(plan.Components, managed, env.NativeConfig) + } // This manual preparation contract has no managed executable registration. // Clear leaves the existing prepared-directory collision walk in charge; // UsesNativeRegistryExecutable=false means no native discovery was attempted. diff --git a/install/integrationctl/agentplugins/clients/cursor/lifecycle.go b/install/integrationctl/agentplugins/clients/cursor/lifecycle.go index ade065c9e..73f16e0f1 100644 --- a/install/integrationctl/agentplugins/clients/cursor/lifecycle.go +++ b/install/integrationctl/agentplugins/clients/cursor/lifecycle.go @@ -15,13 +15,19 @@ var ( // Activate reports manual preparation. A reload or operator attestation cannot // establish native discovery, CLI capability, or genuine Stop qualification. -func (*Adapter) Activate(ctx context.Context, _ clients.Env, request domain.ActivationRequest) (domain.ActivationOutcome, error) { +func (adapter *Adapter) Activate(ctx context.Context, env clients.Env, request domain.ActivationRequest) (domain.ActivationOutcome, error) { if err := ctx.Err(); err != nil { return domain.ActivationOutcome{}, err } if err := shared.ActivationIdentityMismatch(request); err != nil { return domain.ActivationOutcome{}, err } + if err := adapter.PreflightActivation(env, request); err != nil { + return domain.ActivationOutcome{}, err + } + if request.Plan.Scope == domain.ScopeProject { + return adapter.activateProject(ctx, env, request) + } outcome := shared.StartedActivation(request) outcome.Activation = domain.ActivationManual if request.VerifyOnly { @@ -35,9 +41,12 @@ func (*Adapter) Activate(ctx context.Context, _ clients.Env, request domain.Acti } // Deactivate has nothing to unwind: Cursor never received a managed CLI entry. -func (*Adapter) Deactivate(ctx context.Context, _ clients.Env, _ domain.DeactivationRequest) (domain.DeactivationOutcome, error) { +func (*Adapter) Deactivate(ctx context.Context, env clients.Env, request domain.DeactivationRequest) (domain.DeactivationOutcome, error) { if err := ctx.Err(); err != nil { return domain.DeactivationOutcome{}, err } + if request.TargetContext.Scope == domain.ScopeProject { + return deactivateCursorProject(ctx, env, request) + } return shared.StartedDeactivation(), nil } diff --git a/install/integrationctl/agentplugins/clients/cursor/plan.go b/install/integrationctl/agentplugins/clients/cursor/plan.go index 33350510e..65f9db23f 100644 --- a/install/integrationctl/agentplugins/clients/cursor/plan.go +++ b/install/integrationctl/agentplugins/clients/cursor/plan.go @@ -27,13 +27,19 @@ func (adapter *Adapter) TargetRoot(client domain.DetectedClient, mode domain.Pac return root, filepath.Join(root, "plugins", "local"), nil } -func (*Adapter) RefinePlan(ctx context.Context, _ clients.PlanInput, plan *domain.DeliveryPlan) error { +func (*Adapter) RefinePlan(ctx context.Context, in clients.PlanInput, plan *domain.DeliveryPlan) error { if err := ctx.Err(); err != nil { return err } if plan.Status == domain.PlanUnsupported { return nil } + if in.TargetContext.Scope == domain.ScopeProject { + shared.PromoteNativeReady(plan, plan.NativeRegistryRoot, shared.OnlyNativeComponents(plan.Components)) + plan.Activation = domain.ActivationPrepared + plan.UserActions = shared.AppendUnique(plan.UserActions, "project skill directories and MCP entries can be configured automatically; discovery, enablement, approval and policy require observation in Cursor") + return nil + } plan.Status = domain.PlanManualActivationRequired // PackageMode describes the retained manifest/layout contract. Readiness // and selected components carry preparation status independently of it. diff --git a/install/integrationctl/agentplugins/clients/cursor/profile.go b/install/integrationctl/agentplugins/clients/cursor/profile.go index 4713acf44..92f33d227 100644 --- a/install/integrationctl/agentplugins/clients/cursor/profile.go +++ b/install/integrationctl/agentplugins/clients/cursor/profile.go @@ -63,6 +63,9 @@ func detectedProfileRoot(host clients.Host) (string, error) { // only an exact artifact path can establish its filesystem profile authority. // Directory replacement identity still requires the shared UC-P seam. func (adapter *Adapter) ValidateBindingProfile(root string, binding domain.ClientBinding) error { + if binding.Scope == string(domain.ScopeProject) { + return validateProjectBinding(root, binding) + } selected, err := adapter.ResolveProfileRoot(root) if err != nil { return err @@ -79,3 +82,24 @@ func (adapter *Adapter) ValidateBindingProfile(root string, binding domain.Clien } return nil } + +func validateProjectBinding(root string, binding domain.ClientBinding) error { + if err := profileSpelling(root); err != nil { + return err + } + if filepath.Base(root) != ".cursor" || binding.NativeProfileRoot != root { + return fmt.Errorf("recorded Cursor project registry differs from selected root") + } + if err := pathpolicy.ValidateLeafID(binding.PhysicalArtifact); err != nil { + return err + } + project := filepath.Dir(root) + expected := filepath.Join(project, ".uap", "managed", "clients", "cursor", binding.PhysicalArtifact) + if binding.TargetLocator != expected { + return fmt.Errorf("recorded Cursor artifact differs from selected project") + } + if err := pathpolicy.RequireContainedChild(project, expected); err != nil { + return err + } + return cursorComponents(root).ValidateObjects(cursorNativeObjects(binding.NativeObjects)) +} diff --git a/install/integrationctl/agentplugins/clients/cursor/project.go b/install/integrationctl/agentplugins/clients/cursor/project.go index 7e82d6c63..290c7f252 100644 --- a/install/integrationctl/agentplugins/clients/cursor/project.go +++ b/install/integrationctl/agentplugins/clients/cursor/project.go @@ -16,7 +16,7 @@ var _ clients.ActiveNativeProjector = (*Adapter)(nil) // ProjectActiveNative validates the verified package's requested projection. // Cursor's package layout owns no profile entries. The lifecycle retains any // separately acknowledged Stop receipt; a planned receipt is not ownership. -func (*Adapter) ProjectActiveNative(ctx context.Context, root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan, _ string) ([]domain.NativeObjectOwnership, error) { +func (*Adapter) ProjectActiveNative(ctx context.Context, root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan, dataPath string) ([]domain.NativeObjectOwnership, error) { if err := ctx.Err(); err != nil { return nil, err } @@ -26,6 +26,12 @@ func (*Adapter) ProjectActiveNative(ctx context.Context, root string, envelope d if err := plan.SelectedDelivery.ValidatePlan(plan, envelope.TreeDigest); err != nil { return nil, err } + if plan.Scope == domain.ScopeProject { + if err := validateCursorProjectStdio(root, envelope, plan, dataPath); err != nil { + return nil, err + } + return cursorProjectObjects(root, envelope, plan) + } return nil, nil } @@ -37,6 +43,12 @@ func (*Adapter) Project(ctx context.Context, in clients.ProjectionInput) ([]doma if err := projectCursor(in.StagingPath, in.Envelope, in.Plan, in.PluginDataPath); err != nil { return nil, err } + if in.Plan.Scope == domain.ScopeProject { + if err := projectCursorStdio(in); err != nil { + return nil, err + } + return cursorProjectObjects(in.StagingPath, in.Envelope, in.Plan) + } return nil, nil } diff --git a/install/integrationctl/agentplugins/clients/cursor/project_lifecycle.go b/install/integrationctl/agentplugins/clients/cursor/project_lifecycle.go new file mode 100644 index 000000000..4a5d514e6 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/cursor/project_lifecycle.go @@ -0,0 +1,89 @@ +package cursor + +import ( + "context" + "fmt" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +var ( + _ clients.AutomaticActivator = (*Adapter)(nil) + _ clients.ReadOnlyVerifier = (*Adapter)(nil) + _ clients.ActivationPreflighter = (*Adapter)(nil) +) + +func (*Adapter) AutomaticallyActivates(_ clients.Env, request domain.ActivationRequest) bool { + return request.Plan.Scope == domain.ScopeProject && request.Plan.InstallIntent == domain.InstallIntentAutomatic && shared.OnlyNativeComponents(request.Plan.Components) +} + +func (*Adapter) VerifierAvailable(_ domain.DetectedClient, plan domain.DeliveryPlan, _ string) bool { + return plan.Scope == domain.ScopeProject && len(plan.Components) > 0 +} + +func (*Adapter) PreflightActivation(env clients.Env, request domain.ActivationRequest) error { + if request.Plan.Scope != domain.ScopeProject && request.Plan.TargetContext.Scope != domain.ScopeProject { + return nil + } + if _, err := cursorPlanRoot(request.Client, request.Plan); err != nil { + return err + } + if request.ActivationComplete { + return fmt.Errorf("cursor project native discovery cannot be established by activation attestation; inspect skills and MCP in the selected project") + } + return env.NativeConfig.RequireFileIO() +} + +func (adapter *Adapter) activateProject(ctx context.Context, env clients.Env, request domain.ActivationRequest) (domain.ActivationOutcome, error) { + root, err := cursorPlanRoot(request.Client, request.Plan) + if err != nil { + return domain.ActivationOutcome{}, err + } + if err := adapter.PreflightActivation(env, request); err != nil { + return domain.ActivationOutcome{}, err + } + layout := cursorComponents(root) + outcome, err := shared.CompleteNativeConfigActivation(ctx, request, shared.NativeConfigActivation{ + Automatic: adapter.AutomaticallyActivates(env, request), + UnavailableAction: "select only supported Cursor project skills and MCP servers", + RepairAction: "repair managed Cursor project skills and MCP entries", + RetryAction: "retry managed Cursor project component installation", + CompletedAction: "open the selected project in Cursor and inspect skills and MCP servers", + Verify: func() error { + return layout.Verify(cursorNativeObjects(request.Delivery.NativeObjects), false, env.NativeConfig) + }, + Activate: func(ctx context.Context, request domain.ActivationRequest) error { + servers, err := cursorDesiredServers(request.Delivery.ActivePath, cursorNativeObjects(request.Delivery.NativeObjects)) + if err != nil { + return &shared.NativeEffectError{Effect: domain.NativeEffectUnchanged, Err: err} + } + return (shared.ProjectComponentMutation{Layout: layout, ActivePath: request.Delivery.ActivePath, + Previous: cursorNativeObjects(request.PreviousNativeObjects), Desired: cursorNativeObjects(request.Delivery.NativeObjects), Servers: servers, Kernel: env.NativeConfig}).Apply(ctx) + }, + }) + if err == nil && outcome.Verification == domain.VerificationInstalled { + outcome.Activation, outcome.ActivationAttested = domain.ActivationPrepared, false + outcome.UserActions = shared.AppendUnique(outcome.UserActions, "Cursor project configuration is installed; native discovery, aliases, enablement, approvals, team policy and MCP initialization remain unobserved") + } + return outcome, err +} + +func deactivateCursorProject(ctx context.Context, env clients.Env, request domain.DeactivationRequest) (domain.DeactivationOutcome, error) { + outcome := shared.StartedDeactivation() + root, err := cursorNativeRoot(request.Client, request.TargetContext, request.NativeRegistryRoot) + if err != nil { + return outcome, err + } + if !request.Confirmed { + outcome.UserActions = append(outcome.UserActions, "agentplugins will remove its exact owned Cursor project skill directories and MCP members") + return outcome, nil + } + err = (shared.ProjectComponentMutation{Layout: cursorComponents(root), Previous: cursorNativeObjects(request.NativeObjects), Kernel: env.NativeConfig}).Apply(ctx) + if err != nil && !shared.CommittedNativeDeactivationCleanup(&outcome, err) { + return outcome, err + } + outcome.ExternalRemovalComplete = true + return outcome, nil +} diff --git a/install/integrationctl/agentplugins/clients/cursor/project_native.go b/install/integrationctl/agentplugins/clients/cursor/project_native.go new file mode 100644 index 000000000..ac18798ec --- /dev/null +++ b/install/integrationctl/agentplugins/clients/cursor/project_native.go @@ -0,0 +1,143 @@ +package cursor + +import ( + "encoding/json" + "fmt" + "path/filepath" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +const ( + CursorProjectSkillKind = "cursor_project_skill_directory" + CursorProjectMCPKind = "cursor_project_mcp_server" +) + +func cursorComponents(root string) shared.ProjectComponents { + return shared.ProjectComponents{ProjectRoot: filepath.Dir(root), SkillsRoot: filepath.Join(root, "skills"), + ConfigPaths: nativeconfig.Paths{JSON: filepath.Join(root, "mcp.json")}, Codec: nativeconfig.CodecMCPServers, + SkillKind: CursorProjectSkillKind, MCPKind: CursorProjectMCPKind} +} + +func cursorProjectObjects(stagingRoot string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan) ([]domain.NativeObjectOwnership, error) { + target, err := domain.NewTargetContext(plan.TargetContext.Scope, plan.TargetContext.ProjectRoot, plan.TargetContext.UserConfigRoot) + if err != nil { + return nil, err + } + if plan.Scope != domain.ScopeProject || target.Scope != domain.ScopeProject || plan.NativeRegistryRoot != filepath.Join(target.ProjectRoot, ".cursor") { + return nil, fmt.Errorf("cursor projection differs from selected project") + } + if err := profileSpelling(stagingRoot); err != nil { + return nil, err + } + if err := pathpolicy.RequireContainedChild(target.ProjectRoot, stagingRoot); err != nil { + return nil, err + } + layout := cursorComponents(plan.NativeRegistryRoot) + objects := []domain.NativeObjectOwnership{} + for _, component := range plan.Components { + if component.Support == domain.SupportUnsupported { + continue + } + object, err := cursorProjectObject(layout, stagingRoot, envelope, component) + if err != nil { + return nil, err + } + if object != nil { + objects = append(objects, *object) + } + } + return objects, layout.ValidateObjects(objects) +} + +func cursorProjectObject(layout shared.ProjectComponents, stagingRoot string, envelope domain.PackageEnvelope, component domain.ComponentDecision) (*domain.NativeObjectOwnership, error) { + if err := pathpolicy.ValidateLeafID(component.Name); err != nil { + return nil, err + } + switch component.Kind { + case domain.ComponentSkill: + skill, ok := envelope.Skills[component.Name] + if !ok { + return nil, fmt.Errorf("planned Cursor skill %q is missing", component.Name) + } + relative := filepath.Dir(filepath.FromSlash(skill.RelativePath)) + if skill.RelativePath == "" { + relative = filepath.Join("skills", component.Name) + } + object, err := layout.SkillObject(component.Name, filepath.ToSlash(relative), stagingRoot) + return &object, err + case domain.ComponentMCPServer: + server, err := cursorProjectedServer(stagingRoot, component.Name) + if err != nil { + return nil, err + } + object, err := layout.MCPObject(component.Name, server, layout.ConfigPaths.JSON) + return &object, err + default: + return nil, fmt.Errorf("unsupported Cursor project component kind %q", component.Kind) + } +} + +func cursorProjectedServer(activeRoot, name string) (nativeconfig.Server, error) { + file, err := nativeconfig.New().ReadExactFile(filepath.Join(activeRoot, "mcp.json")) + if err != nil { + return nativeconfig.Server{}, err + } + document, err := shared.DecodeStrictJSONObject(file.Body) + if err != nil { + return nativeconfig.Server{}, err + } + entries, ok := document["mcpServers"].(map[string]any) + if !ok { + return nativeconfig.Server{}, fmt.Errorf("cursor package has no mcpServers object") + } + entry, ok := entries[name].(map[string]any) + if !ok { + return nativeconfig.Server{}, fmt.Errorf("cursor projected MCP server %q is missing", name) + } + body, err := json.Marshal(entry) + if err != nil { + return nativeconfig.Server{}, err + } + server := nativeconfig.Server{} + if err := json.Unmarshal(body, &server); err != nil { + return server, err + } + server.StdioValuesResolved, server.CWDResolved = true, true + if server.Command != "" { + server.Type = "stdio" + } else { + server.Type = "remote" + } + return server, nil +} + +func cursorDesiredServers(activeRoot string, objects []domain.NativeObjectOwnership) (map[string]nativeconfig.Server, error) { + servers := map[string]nativeconfig.Server{} + for _, object := range objects { + if object.Kind != CursorProjectMCPKind { + continue + } + server, err := cursorProjectedServer(activeRoot, object.LogicalName) + if err != nil { + return nil, err + } + servers[object.LogicalName] = server + } + return servers, nil +} + +// Package directory ownership belongs to the shared artifact lifecycle, not the +// native component writer. Other unknown kinds remain visible and fail closed. +func cursorNativeObjects(objects []domain.NativeObjectOwnership) []domain.NativeObjectOwnership { + result := []domain.NativeObjectOwnership{} + for _, object := range objects { + if object.Kind != "managed_package_directory" { + result = append(result, object) + } + } + return result +} diff --git a/install/integrationctl/agentplugins/clients/cursor/project_stdio.go b/install/integrationctl/agentplugins/clients/cursor/project_stdio.go new file mode 100644 index 000000000..dbaae5e92 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/cursor/project_stdio.go @@ -0,0 +1,83 @@ +package cursor + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/managedstdio" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/pathcontract" +) + +// The project lane uses the existing trusted launcher to establish cwd rather +// than assuming Cursor interprets the portable cwd field or plugin placeholders. +func projectCursorStdio(in clients.ProjectionInput) error { + if err := shared.DeliverManagedStdio(in.DeliverLauncher(), in.StagingPath, in.Envelope, in.Plan); err != nil { + return err + } + if len(shared.SupportedMCPNames(in.Plan)) == 0 { + return nil + } + file, err := nativeconfig.New().ReadExactFile(filepath.Join(in.StagingPath, "mcp.json")) + if err != nil { + return err + } + document, err := shared.DecodeStrictJSONObject(file.Body) + if err != nil { + return err + } + servers, ok := document["mcpServers"].(map[string]any) + if !ok { + return fmt.Errorf("cursor package has no MCP collection") + } + for _, name := range shared.SupportedMCPNames(in.Plan) { + server := in.Envelope.MCP.Servers[name] + if server.Type != "stdio" { + continue + } + entry, ok := servers[name].(map[string]any) + if !ok { + return fmt.Errorf("cursor projected stdio entry is missing") + } + if err := bindCursorStdio(entry, server, in.Plan.ActivePath, in.PluginDataPath); err != nil { + return err + } + } + return shared.WriteJSON(filepath.Join(in.StagingPath, "mcp.json"), document) +} + +func bindCursorStdio(entry map[string]any, server domain.MCPServer, activePath, dataPath string) error { + if !filepath.IsAbs(activePath) || !filepath.IsAbs(dataPath) { + return fmt.Errorf("cursor managed stdio roots must be absolute") + } + rawCWD, _ := server.Decoded["cwd"].(string) + cwd, err := pathcontract.ExpandCWD(rawCWD, activePath, dataPath) + if err != nil { + return err + } + absoluteCWD, ok := entry["cwd"].(string) + if !ok { + return fmt.Errorf("cursor projected stdio cwd is missing") + } + rawArgs, _ := entry["args"].([]any) + args := make([]string, 0, len(rawArgs)) + for _, value := range rawArgs { + text, ok := value.(string) + if !ok { + return fmt.Errorf("cursor stdio arguments must be strings") + } + args = append(args, text) + } + command, _ := server.Decoded["command"].(string) + if strings.HasPrefix(command, "${package.root}/") { + command = "./" + strings.TrimPrefix(command, "${package.root}/") + } + entry["command"] = filepath.Join(activePath, filepath.FromSlash(managedstdio.RelativeDirectory), managedstdio.ExecutableName) + entry["args"] = managedstdio.Arguments(activePath, dataPath, absoluteCWD, cwd.Anchor, command, args) + delete(entry, "cwd") + return nil +} diff --git a/install/integrationctl/agentplugins/clients/cursor/project_stdio_verify.go b/install/integrationctl/agentplugins/clients/cursor/project_stdio_verify.go new file mode 100644 index 000000000..d5f81ec14 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/cursor/project_stdio_verify.go @@ -0,0 +1,45 @@ +package cursor + +import ( + "fmt" + "path/filepath" + "slices" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/managedstdio" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/pathcontract" +) + +// Active package bytes are immutable. A different selected data receipt needs +// a fresh projection; old launcher/environment locators cannot authorize it. +func validateCursorProjectStdio(root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan, dataPath string) error { + for _, name := range shared.SupportedMCPNames(plan) { + source := envelope.MCP.Servers[name] + if source.Type != "stdio" { + continue + } + server, err := cursorProjectedServer(root, name) + if err != nil { + return err + } + launcher := filepath.Join(plan.ActivePath, filepath.FromSlash(managedstdio.RelativeDirectory), managedstdio.ExecutableName) + if !filepath.IsAbs(dataPath) || server.Type != "stdio" || server.Command != launcher || server.CWD != "" || server.Env["PLUGIN_ROOT"] != plan.ActivePath || server.Env["PLUGIN_DATA"] != dataPath { + return fmt.Errorf("%w: Cursor stdio package/data binding differs from selected locators", domain.ErrProjectionRefreshRequired) + } + rawCWD, _ := source.Decoded["cwd"].(string) + cwd, err := pathcontract.ExpandCWD(rawCWD, plan.ActivePath, dataPath) + if err != nil { + return err + } + _, resolvedCWD, err := shared.ResolveStdioPaths("", rawCWD, plan.ActivePath, dataPath, root) + if err != nil { + return err + } + prefix := managedstdio.Arguments(plan.ActivePath, dataPath, resolvedCWD, cwd.Anchor, "", nil)[:6] + if len(server.Args) < 7 || !slices.Equal(server.Args[:6], prefix) { + return fmt.Errorf("%w: Cursor launcher package/data/cwd arguments differ from selected locators", domain.ErrProjectionRefreshRequired) + } + } + return nil +} diff --git a/install/integrationctl/agentplugins/clients/cursor/scope.go b/install/integrationctl/agentplugins/clients/cursor/scope.go new file mode 100644 index 000000000..d3fb198a9 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/cursor/scope.go @@ -0,0 +1,74 @@ +package cursor + +import ( + "fmt" + "path/filepath" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +var ( + _ clients.ProjectTargetLayout = (*Adapter)(nil) + _ clients.NativeRegistryLayout = (*Adapter)(nil) + _ clients.PlanPrecondition = (*Adapter)(nil) +) + +// NativeRegistry keeps the detected user profile distinct from workspace files. +func (*Adapter) NativeRegistry(in clients.PlanInput) (string, string) { + if in.TargetContext.Scope == domain.ScopeProject { + return filepath.Join(in.TargetContext.ProjectRoot, ".cursor"), "" + } + return in.Client.ConfigRoot, in.Client.ExecutablePath +} + +func (*Adapter) ProjectTargetRoot(client domain.DetectedClient, mode domain.PackageMode, managedRoot string, target domain.TargetContext) (string, string, error) { + if target.Scope != domain.ScopeProject { + return "", "", fmt.Errorf("cursor project layout requires project scope") + } + if _, err := cursorNativeRoot(client, target, ""); err != nil { + return "", "", err + } + if err := pathpolicy.RequireContainedChild(target.ProjectRoot, managedRoot); err != nil { + return "", "", err + } + return shared.ManagedTargetRoot(client, mode, managedRoot) +} + +func (*Adapter) CheckPlanPrecondition(in clients.PlanInput, plan *domain.DeliveryPlan) error { + if in.TargetContext.Scope != domain.ScopeProject { + return nil + } + _, err := cursorNativeRoot(in.Client, in.TargetContext, plan.NativeRegistryRoot) + return err +} + +func cursorPlanRoot(client domain.DetectedClient, plan domain.DeliveryPlan) (string, error) { + if plan.Scope != domain.ScopeProject || plan.TargetContext.Scope != domain.ScopeProject { + return "", fmt.Errorf("cursor component lifecycle requires matching project scope") + } + return cursorNativeRoot(client, plan.TargetContext, plan.NativeRegistryRoot) +} + +func cursorNativeRoot(client domain.DetectedClient, target domain.TargetContext, planned string) (string, error) { + validated, err := domain.NewTargetContext(target.Scope, target.ProjectRoot, target.UserConfigRoot) + if err != nil { + return "", err + } + if validated.Scope != domain.ScopeProject { + return "", fmt.Errorf("cursor native components require project scope") + } + if validated.UserConfigRoot != "" && validated.UserConfigRoot != client.ConfigRoot { + return "", fmt.Errorf("cursor detected user profile differs from selected target") + } + root := filepath.Join(validated.ProjectRoot, ".cursor") + if planned != "" && planned != root { + return "", fmt.Errorf("cursor native registry differs from selected project") + } + if err := pathpolicy.RequireContainedChild(validated.ProjectRoot, root); err != nil { + return "", err + } + return root, nil +} diff --git a/install/integrationctl/agentplugins/domain/clients.go b/install/integrationctl/agentplugins/domain/clients.go index eaed3b52d..8922cd443 100644 --- a/install/integrationctl/agentplugins/domain/clients.go +++ b/install/integrationctl/agentplugins/domain/clients.go @@ -107,10 +107,10 @@ var clientDefinitions = []ClientDefinition{ RequiresPersonalMappingForPrepare: true, }), DirectoryResolveContext7ChatGPTPreparation)), - clientDefinition(ClientCursor, "Cursor", "cursor", "managed", "native", true, PackageNative, SupportNative, SupportNative, SupportNative, SupportUnsupported, SupportNative, ClientTraits{ + withProjectScope(clientDefinition(ClientCursor, "Cursor", "cursor", "managed", "native", true, PackageNative, SupportNative, SupportNative, SupportNative, SupportUnsupported, SupportNative, ClientTraits{ InstallIntents: []InstallIntent{InstallIntentAutomatic}, LifecycleKind: LifecycleCLIRegistry, - }), + }), LifecycleNativeConfig), withoutCommands(clientDefinition(ClientCopilot, "GitHub Copilot CLI", "github-copilot", "managed", "native", true, PackageNative, SupportNative, SupportNative, SupportNative, SupportUnsupported, SupportNative, ClientTraits{ InstallIntents: []InstallIntent{InstallIntentAutomatic}, LifecycleKind: LifecycleCLIRegistry, diff --git a/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json b/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json index 29d3cdfc2..f29b840b8 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json @@ -217,7 +217,8 @@ }, "PackageMode": "native", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "native" }, diff --git a/install/integrationctl/agentplugins/planner/testdata/golden/compat_remote_mcp_and_app.json b/install/integrationctl/agentplugins/planner/testdata/golden/compat_remote_mcp_and_app.json index 9c6afc8df..fa8983dcf 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_remote_mcp_and_app.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_remote_mcp_and_app.json @@ -328,7 +328,8 @@ }, "PackageMode": "native", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "native" }, diff --git a/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json b/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json index 1352c239c..a54fc90a6 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json @@ -197,7 +197,8 @@ }, "PackageMode": "native", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "native" }, diff --git a/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json b/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json index d1c06431c..00333c9f6 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json @@ -236,7 +236,8 @@ }, "PackageMode": "native", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "native" }, diff --git a/install/integrationctl/agentplugins/providers/project_cursor_boundaries_test.go b/install/integrationctl/agentplugins/providers/project_cursor_boundaries_test.go new file mode 100644 index 000000000..9452a1945 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_cursor_boundaries_test.go @@ -0,0 +1,108 @@ +package providers_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/cursor" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +func TestProjectCursorAttestationCannotUpgradeConfiguration(t *testing.T) { + fixture := newCursorProjectFixture(t, false) + before := projectGeminiInventory(t, fixture.request.Plan.NativeRegistryRoot) + fixture.request.ActivationComplete = true + for _, prepared := range []bool{false, true} { + if prepared { + fixture.request.Plan.Components[0].Support = domain.SupportPrepared + } + _, err := fixture.activator(nativeconfig.New()).Activate(context.Background(), fixture.request) + if err == nil || !strings.Contains(err.Error(), "cannot be established by activation attestation") { + t.Fatalf("attestation bypassed preflight: %v", err) + } + } + if _, err := cursor.New().Activate(context.Background(), clients.Env{NativeConfig: nativeconfig.New()}, fixture.request); err == nil { + t.Fatal("direct adapter bypassed preflight") + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, fixture.request.Plan.NativeRegistryRoot)) { + t.Fatal("attestation changed project files") + } +} + +func TestProjectCursorForeignDisabledMalformedAndWriterFailurePreserveNativeFiles(t *testing.T) { + for _, failure := range []string{"disabled-collision", "malformed", "writer-lock", "skill-collision"} { + t.Run(failure, func(t *testing.T) { + fixture := newCursorProjectFixture(t, false) + root := fixture.request.Plan.NativeRegistryRoot + kernel := nativeconfig.New() + switch failure { + case "disabled-collision": + projectGeminiWrite(t, filepath.Join(root, "mcp.json"), `{"mcpServers":{"docs":{"url":"https://foreign.test/keep","disabled":true}}}`) + case "malformed": + projectGeminiWrite(t, filepath.Join(root, "mcp.json"), `{"mcpServers":{},}`) + case "skill-collision": + projectGeminiWrite(t, filepath.Join(root, "skills", "docs", "SKILL.md"), "foreign same-name skill\n") + case "writer-lock": + kernel = nativeconfig.NewWithLockAcquirer(func(nativeconfig.Paths, nativeconfig.Codec) (func() error, error) { + return nil, errors.New("inert writer refusal") + }) + } + before, user, other := projectGeminiInventory(t, root), projectGeminiInventory(t, fixture.user), projectGeminiInventory(t, fixture.other) + if _, err := fixture.activator(kernel).Activate(context.Background(), fixture.request); err == nil { + t.Fatal("unsafe/failed native mutation succeeded") + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, root)) || !reflect.DeepEqual(user, projectGeminiInventory(t, fixture.user)) || !reflect.DeepEqual(other, projectGeminiInventory(t, fixture.other)) { + t.Fatal("failed install changed native files") + } + }) + } +} + +func TestProjectCursorScopeRootAndBindingAuthorityRefuse(t *testing.T) { + for _, mismatch := range []string{"user-registry", "user-context", "other-user", "copied-project"} { + t.Run(mismatch, func(t *testing.T) { + fixture := newCursorProjectFixture(t, false) + root := fixture.request.Plan.NativeRegistryRoot + before := projectGeminiInventory(t, root) + switch mismatch { + case "user-registry": + fixture.request.Plan.NativeRegistryRoot = fixture.user + case "user-context": + fixture.request.Plan.TargetContext.Scope = domain.ScopeUser + case "other-user": + fixture.request.Plan.TargetContext.UserConfigRoot = fixture.other + case "copied-project": + fixture.request.Plan.TargetContext.ProjectRoot = fixture.other + fixture.request.Plan.NativeRegistryRoot = filepath.Join(fixture.other, ".cursor") + } + if _, err := fixture.activator(nativeconfig.New()).Activate(context.Background(), fixture.request); err == nil { + t.Fatal("mismatched project authority admitted") + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, root)) { + t.Fatal("authority refusal changed original project") + } + }) + } +} + +func TestProjectCursorContainedNativeRootAliasRefuses(t *testing.T) { + fixture := newCursorProjectFixture(t, false) + root := fixture.request.Plan.NativeRegistryRoot + original := root + "-physical" + if err := os.Rename(root, original); err != nil { + t.Fatal(err) + } + if err := os.Symlink(original, root); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := fixture.activator(nativeconfig.New()).Activate(context.Background(), fixture.request); err == nil { + t.Fatal("contained native root alias admitted") + } +} diff --git a/install/integrationctl/agentplugins/providers/project_cursor_fixture_test.go b/install/integrationctl/agentplugins/providers/project_cursor_fixture_test.go new file mode 100644 index 000000000..c8183e5f9 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_cursor_fixture_test.go @@ -0,0 +1,121 @@ +package providers_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/loader" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/specregistry" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/cursor" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/commandext" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/managedstdio" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/planner" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/providers" +) + +type cursorProjectFixture struct { + request domain.ActivationRequest + user, other, source string + envelope domain.PackageEnvelope + registry *clients.Registry + stager providers.Stager +} + +func newCursorProjectFixture(t *testing.T, stdio bool) *cursorProjectFixture { + t.Helper() + base := projectGeminiTemp(t) + project, user, other := filepath.Join(base, "A"), filepath.Join(base, "user", ".cursor"), filepath.Join(base, "B") + for _, root := range []string{user, filepath.Join(project, ".cursor"), filepath.Join(other, ".cursor")} { + projectGeminiWrite(t, filepath.Join(root, "mcp.json"), `{"mcpServers":{"foreign":{"url":"https://foreign.test/mcp","disabled":true}},"policy":"preserve"}`) + projectGeminiWrite(t, filepath.Join(root, "hooks.json"), "foreign hooks\n") + projectGeminiWrite(t, filepath.Join(root, "skills", "foreign", "SKILL.md"), "foreign skill\n") + } + registry, err := clients.NewRegistry(cursor.New()) + if err != nil { + t.Fatal(err) + } + fixture := &cursorProjectFixture{source: filepath.Join(base, "source"), user: user, other: other, registry: registry} + fixture.stager = providers.Stager{Registry: registry, Paths: pathpolicy.Policy{}, PluginDataRoot: filepath.Join(project, ".uap", "data")} + fixture.request.Client = domain.DetectedClient{ClientID: domain.ClientCursor, ConfigRoot: user, ExecutablePath: "fixture-editor-never-executed", Status: domain.DetectionDetected} + fixture.request.Plan.TargetContext, err = domain.NewTargetContext(domain.ScopeProject, project, user) + if err != nil { + t.Fatal(err) + } + projectGeminiWrite(t, filepath.Join(fixture.source, "plugin.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"demo","version":"1.0.0"}`) + projectGeminiWrite(t, filepath.Join(fixture.source, "skills", "docs", "SKILL.md"), "---\nname: docs\ndescription: Inert project fixture\n---\nVersion one.\n") + projectGeminiWrite(t, filepath.Join(fixture.source, filepath.FromSlash(commandext.RelativeDirectory), "echo.md"), "---\ndescription: Echo inert marker\ndisable-model-invocation: false\n---\nUAP-CURSOR-PROJECT-MARKER\n") + mcp := `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"docs":{"type":"streamable-http","url":"https://managed.test/v1"}}}` + if stdio { + mcp = `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"docs":{"type":"stdio","command":"node","args":["${PLUGIN_ROOT}/server.js","${PLUGIN_DATA}"],"cwd":"${PLUGIN_ROOT}"}}}` + projectGeminiWrite(t, filepath.Join(fixture.source, "server.js"), "// Inert fixture; never executed.\n") + fixture.stager.LauncherSource = cursorFixtureLauncher(t, base) + } + projectGeminiWrite(t, filepath.Join(fixture.source, "mcp.json"), mcp) + fixture.stage(t, "initial") + return fixture +} + +func cursorFixtureLauncher(t *testing.T, base string) *managedstdio.Source { + t.Helper() + if !managedstdio.Supported() { + t.Skip("managed stdio platform unsupported") + } + path := filepath.Join(base, "inert-launcher") + if err := os.WriteFile(path, []byte("trusted fixture bytes; never executed"), 0700); err != nil { + t.Fatal(err) + } + source, err := managedstdio.NewSource(path, "fixture") + if err != nil { + t.Fatal(err) + } + return source +} + +func (fixture *cursorProjectFixture) stage(t *testing.T, operation string) { + t.Helper() + schemas, err := specregistry.New() + if err != nil { + t.Fatal(err) + } + fixture.envelope, err = (loader.Loader{Registry: schemas}).Load(context.Background(), domain.LoadInput{SnapshotRoot: fixture.source}) + if err != nil { + t.Fatal(err) + } + project := fixture.request.Plan.TargetContext.ProjectRoot + planning := planner.Planner{ProjectRoot: project, ManagedRoot: filepath.Join(project, ".uap", "managed"), Paths: pathpolicy.Policy{}, Registry: fixture.registry} + plan, err := planning.Plan(context.Background(), domain.PlanRequest{Envelope: fixture.envelope, Client: fixture.request.Client, Scope: domain.ScopeProject, ProjectRoot: project, PhysicalArtifactID: "demo-0123456789ab"}) + if err != nil || plan.Status != domain.PlanReady || plan.Activation != domain.ActivationPrepared { + t.Fatalf("Cursor project plan = %+v, %v", plan, err) + } + delivery, err := fixture.stager.Stage(context.Background(), fixture.envelope, plan, operation, domain.CompatibilityHints{}) + if err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(plan.ActivePath); err == nil { + if err := os.Rename(plan.ActivePath, plan.ActivePath+"-"+operation+"-backup"); err != nil { + t.Fatal(err) + } + } else if !os.IsNotExist(err) { + t.Fatal(err) + } + if err := os.Rename(delivery.StagingPath, plan.ActivePath); err != nil { + t.Fatal(err) + } + delivery.StagingPath = "" + fixture.request.Plan, fixture.request.Delivery, fixture.request.DeclaredName = plan, delivery, "demo" +} + +func (fixture *cursorProjectFixture) activator(kernel nativeconfig.Kernel) providers.Activator { + return providers.Activator{Registry: fixture.registry, NativeConfig: &kernel} +} + +func (fixture *cursorProjectFixture) removal(objects []domain.NativeObjectOwnership) domain.DeactivationRequest { + return domain.DeactivationRequest{Client: fixture.request.Client, TargetContext: fixture.request.Plan.TargetContext, + NativeRegistryRoot: fixture.request.Plan.NativeRegistryRoot, Confirmed: true, NativeObjects: objects} +} diff --git a/install/integrationctl/agentplugins/providers/project_cursor_lifecycle_test.go b/install/integrationctl/agentplugins/providers/project_cursor_lifecycle_test.go new file mode 100644 index 000000000..002f41919 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_cursor_lifecycle_test.go @@ -0,0 +1,98 @@ +package providers_test + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/cursor" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +func cursorAssertPrepared(t *testing.T, outcome domain.ActivationOutcome, err error) { + t.Helper() + if err != nil || outcome.Activation != domain.ActivationPrepared || outcome.Verification != domain.VerificationInstalled || outcome.ActivationAttested { + t.Fatalf("Cursor configured state = %+v, %v", outcome, err) + } +} + +// Inert production loader/planner/renderer/stager/provider evidence; this does +// not execute Cursor, the injected launcher, a model or an MCP server. +func TestProjectCursorComposedLifecycleAndScopeIsolation(t *testing.T) { + fixture := newCursorProjectFixture(t, false) + userBefore, bBefore := projectGeminiInventory(t, fixture.user), projectGeminiInventory(t, fixture.other) + activator := fixture.activator(nativeconfig.New()) + outcome, err := activator.Activate(context.Background(), fixture.request) + cursorAssertPrepared(t, outcome, err) + root := fixture.request.Plan.NativeRegistryRoot + body, err := os.ReadFile(filepath.Join(root, "skills", "demo-echo", "SKILL.md")) + if err != nil || !strings.Contains(string(body), "UAP-CURSOR-PROJECT-MARKER") { + t.Fatalf("rendered command skill = %q, %v", body, err) + } + activeBefore := projectGeminiInventory(t, fixture.request.Plan.ActivePath) + objects, err := fixture.stager.ProjectActiveNative(context.Background(), fixture.envelope, fixture.request.Plan, fixture.request.Delivery.ArtifactDigest, "") + if err != nil || len(objects) != 3 || !reflect.DeepEqual(activeBefore, projectGeminiInventory(t, fixture.request.Plan.ActivePath)) { + t.Fatalf("read-only projection = %+v, %v", objects, err) + } + binding := domain.ClientBinding{Scope: "project", PhysicalArtifact: fixture.request.Plan.PhysicalArtifactID, + TargetLocator: fixture.request.Plan.ActivePath, NativeProfileRoot: root, NativeObjects: outcome.NativeObjects} + if err := cursor.New().ValidateBindingProfile(root, binding); err != nil { + t.Fatal(err) + } + fixture.request.PreviousNativeObjects = outcome.NativeObjects + for _, verify := range []bool{false, true} { + fixture.request.VerifyOnly = verify + outcome, err = activator.Activate(context.Background(), fixture.request) + cursorAssertPrepared(t, outcome, err) + } + data := filepath.Join(fixture.request.Plan.TargetContext.ProjectRoot, ".uap", "data", "keep.txt") + projectGeminiWrite(t, data, "persistent data\n") + for range 2 { + removed, err := activator.Deactivate(context.Background(), fixture.removal(outcome.NativeObjects)) + if err != nil || !removed.ExternalRemovalComplete { + t.Fatalf("repeat exact remove = %+v, %v", removed, err) + } + } + projectGeminiAssert(t, data, "persistent data\n") + if _, err := os.Stat(filepath.Join(root, "skills", "docs")); !os.IsNotExist(err) { + t.Fatalf("managed skill survived remove: %v", err) + } + if !reflect.DeepEqual(userBefore, projectGeminiInventory(t, fixture.user)) || !reflect.DeepEqual(bBefore, projectGeminiInventory(t, fixture.other)) { + t.Fatal("Cursor project lifecycle changed user or project B") + } + projectGeminiAssert(t, filepath.Join(root, "hooks.json"), "foreign hooks\n") +} + +func TestProjectCursorUpdateRepairAndRemovedComponent(t *testing.T) { + fixture := newCursorProjectFixture(t, false) + activator := fixture.activator(nativeconfig.New()) + outcome, err := activator.Activate(context.Background(), fixture.request) + cursorAssertPrepared(t, outcome, err) + fixture.request.PreviousNativeObjects = outcome.NativeObjects + projectGeminiWrite(t, filepath.Join(fixture.source, "skills", "docs", "SKILL.md"), "---\nname: docs\ndescription: Updated inert fixture\n---\nVersion two.\n") + projectGeminiWrite(t, filepath.Join(fixture.source, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"docs":{"type":"streamable-http","url":"https://managed.test/v2"}}}`) + fixture.stage(t, "update") + outcome, err = activator.Activate(context.Background(), fixture.request) + cursorAssertPrepared(t, outcome, err) + skill := filepath.Join(fixture.request.Plan.NativeRegistryRoot, "skills", "docs") + if err := os.RemoveAll(skill); err != nil { + t.Fatal(err) + } + fixture.request.PreviousNativeObjects = outcome.NativeObjects + outcome, err = activator.Activate(context.Background(), fixture.request) + cursorAssertPrepared(t, outcome, err) + projectGeminiAssert(t, filepath.Join(skill, "SKILL.md"), "---\nname: docs\ndescription: Updated inert fixture\n---\nVersion two.\n") + projectGeminiWrite(t, filepath.Join(fixture.source, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{}}`) + fixture.request.PreviousNativeObjects = outcome.NativeObjects + fixture.stage(t, "drop-mcp") + outcome, err = activator.Activate(context.Background(), fixture.request) + cursorAssertPrepared(t, outcome, err) + config, err := os.ReadFile(filepath.Join(fixture.request.Plan.NativeRegistryRoot, "mcp.json")) + if err != nil || strings.Contains(string(config), "managed.test") || !strings.Contains(string(config), `"disabled":true`) { + t.Fatalf("removed component/foreign data = %s, %v", config, err) + } +} diff --git a/install/integrationctl/agentplugins/providers/project_cursor_stdio_test.go b/install/integrationctl/agentplugins/providers/project_cursor_stdio_test.go new file mode 100644 index 000000000..e6580616d --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_cursor_stdio_test.go @@ -0,0 +1,65 @@ +package providers_test + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/managedstdio" +) + +func TestProjectCursorStdioUsesBoundManagedLauncherWithoutExecution(t *testing.T) { + fixture := newCursorProjectFixture(t, true) + beforeUser, beforeB := projectGeminiInventory(t, fixture.user), projectGeminiInventory(t, fixture.other) + outcome, err := fixture.activator(nativeconfig.New()).Activate(context.Background(), fixture.request) + cursorAssertPrepared(t, outcome, err) + body, err := os.ReadFile(filepath.Join(fixture.request.Plan.NativeRegistryRoot, "mcp.json")) + if err != nil { + t.Fatal(err) + } + var document struct { + Servers map[string]struct { + Command string `json:"command"` + Args []string `json:"args"` + Env map[string]string `json:"env"` + CWD string `json:"cwd"` + } `json:"mcpServers"` + } + if err := json.Unmarshal(body, &document); err != nil { + t.Fatal(err) + } + 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} + 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) + } + projectGeminiAssert(t, server.Command, "trusted fixture bytes; never executed") + if !reflect.DeepEqual(beforeUser, projectGeminiInventory(t, fixture.user)) || !reflect.DeepEqual(beforeB, projectGeminiInventory(t, fixture.other)) { + t.Fatal("stdio configuration changed other scopes") + } + activeBefore := projectGeminiInventory(t, active) + objects, err := fixture.stager.ProjectActiveNative(context.Background(), fixture.envelope, fixture.request.Plan, fixture.request.Delivery.ArtifactDigest, data) + if err != nil || len(objects) != 3 || !reflect.DeepEqual(activeBefore, projectGeminiInventory(t, active)) { + t.Fatalf("stdio read-only projection = %+v, %v", objects, err) + } + for _, selectedData := range []string{"", filepath.Join(filepath.Dir(data), "other-receipt")} { + if _, err := fixture.stager.ProjectActiveNative(context.Background(), fixture.envelope, fixture.request.Plan, fixture.request.Delivery.ArtifactDigest, selectedData); !errors.Is(err, domain.ErrProjectionRefreshRequired) { + t.Fatalf("different selected data locator was accepted: %v", err) + } + if !reflect.DeepEqual(activeBefore, projectGeminiInventory(t, active)) { + t.Fatal("refused data locator changed immutable package bytes") + } + } + fixture.stager.LauncherSource = nil + if _, err := fixture.stager.Stage(context.Background(), fixture.envelope, fixture.request.Plan, "missing-launcher", domain.CompatibilityHints{}); err == nil { + t.Fatal("project stdio staged without trusted launcher") + } +}