diff --git a/cli/cmd/agentplugins/devin_project_admission_test.go b/cli/cmd/agentplugins/devin_project_admission_test.go new file mode 100644 index 000000000..72ceeaf30 --- /dev/null +++ b/cli/cmd/agentplugins/devin_project_admission_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/cli/internal/agentpluginscli" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +func TestProjectDevinNoSaveUsesSelectedHostAndAbsentLegacyProfile(t *testing.T) { + transport := newProjectJourneyTransport(t, "git") + devinProjectCommitSource(t, transport, projectJourneySkill, "https://managed.test/v1") + f := newDevinProjectJourney(t, transport) + user, b := securityFixtureTree(t, f.devinRoot("")), securityFixtureTree(t, f.devinRoot(f.b)) + f.run(t, f.a, "add", transport.repo, "--no-save", "--target", "devin") + state := f.state(t, f.a) + if len(state.Installations) != 1 || len(state.Installations[0].Clients) != 1 || len(state.EnvironmentReceipts) != 0 { + t.Fatalf("no-save changed ownership route: %+v", state) + } + installation := state.Installations[0] + var binding domain.ClientBinding + for _, owned := range installation.Clients { + binding = owned + } + if binding.ClientID != "windsurf" || binding.NativeProfileRoot != f.devinRoot(f.a) || binding.Activation != domain.ActivationPrepared || binding.Verification != domain.VerificationInstalled { + t.Fatalf("no-save project host evidence missing: %+v", binding) + } + if !strings.Contains(projectJourneyRead(t, filepath.Join(f.devinRoot(f.a), "mcp_config.json")), "managed.test/v1") { + t.Fatal("no-save current Devin dedicated MCP missing") + } + f.run(t, f.a, "remove", installation.InstallationID, "--target", "windsurf") + projectJourneyAbsent(t, binding.TargetLocator, filepath.Join(f.devinRoot(f.a), "skills", "docs")) + if !reflect.DeepEqual(user, securityFixtureTree(t, f.devinRoot(""))) || !reflect.DeepEqual(b, securityFixtureTree(t, f.devinRoot(f.b))) { + t.Fatal("no-save changed another native scope") + } +} + +type devinUnqualifiedDetector struct{ client domain.DetectedClient } + +func (detector devinUnqualifiedDetector) Detect(_ context.Context) ([]domain.DetectedClient, error) { + return []domain.DetectedClient{detector.client}, nil +} + +func TestProjectDevinCLIRefusesMissingOlderAndWrongHarnessBeforeNativeEffects(t *testing.T) { + transport := newProjectJourneyTransport(t, "zip") + devinProjectCommitSource(t, transport, projectJourneySkill, "https://managed.test/v1") + for _, version := range []string{"", "3000.3.0", "3000.11.205-alpha", "wrong-harness"} { + t.Run("version="+version, func(t *testing.T) { + f := newDevinProjectJourney(t, transport) + client := domain.DetectedClient{ClientID: domain.ClientWindsurf, Status: domain.DetectionDetected, ExecutablePath: filepath.Join(f.home, "devin"), Version: version} + if version == "wrong-harness" { + client.Version = "3000.11.205" + client.ExecutablePath = filepath.Join(f.home, "windsurf") + } + compose := f.app.ForScope + f.app.ForScope = func(scope domain.InstallScope, root string) (agentpluginscli.App, error) { + selected, err := compose(scope, root) + if err != nil { + return selected, err + } + selected.Detector = devinUnqualifiedDetector{client: client} + return selected, nil + } + before, user, b := securityFixtureTree(t, f.devinRoot(f.a)), securityFixtureTree(t, f.devinRoot("")), securityFixtureTree(t, f.devinRoot(f.b)) + _, err := f.execute(f.a, "add", transport.repo, "--no-save", "--target", "windsurf") + if err == nil || !strings.Contains(err.Error(), "explicitly selected primary") { + t.Fatalf("unqualified CLI host accepted: %v", err) + } + if !reflect.DeepEqual(before, securityFixtureTree(t, f.devinRoot(f.a))) || !reflect.DeepEqual(user, securityFixtureTree(t, f.devinRoot(""))) || !reflect.DeepEqual(b, securityFixtureTree(t, f.devinRoot(f.b))) { + t.Fatal("unqualified CLI host changed native scope") + } + }) + } +} diff --git a/cli/cmd/agentplugins/devin_project_fixture_test.go b/cli/cmd/agentplugins/devin_project_fixture_test.go new file mode 100644 index 000000000..83f2304d0 --- /dev/null +++ b/cli/cmd/agentplugins/devin_project_fixture_test.go @@ -0,0 +1,123 @@ +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 devinProjectDetector struct{ root string } + +func (detector devinProjectDetector) Detect(context.Context) ([]domain.DetectedClient, error) { + return []domain.DetectedClient{{ClientID: domain.ClientWindsurf, ConfigRoot: "", ExecutablePath: filepath.Join(detector.root, "devin"), Version: "3000.11.205", Status: domain.DetectionDetected}}, nil +} + +// Reuse the core journey's production CLI/state helpers and real TLS Git/ZIP +// transports. Devin supplies its own detected profile and native layout. +type devinProjectJourney struct{ projectJourney } + +func newDevinProjectJourney(t *testing.T, transport *projectJourneyTransport) devinProjectJourney { + t.Helper() + f := devinProjectJourney{projectJourney{home: devinJourneyTemp(t), a: devinJourneyTemp(t), b: devinJourneyTemp(t), transport: transport}} + f.cache = filepath.Join(f.home, "uap") + for _, root := range []string{"", f.a, f.b} { + native := f.devinRoot(root) + projectJourneyWrite(t, filepath.Join(native, "mcp_config.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 = configureDevinProjectJourney(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 configureDevinProjectJourney(app agentpluginscli.App, home string, transport *projectJourneyTransport) agentpluginscli.App { + app.Detector = devinProjectDetector{root: filepath.Join(home, ".devin")} + 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 configureDevinProjectJourney(selected, home, transport), nil + } + return app +} + +func (f devinProjectJourney) devinRoot(project string) string { + if project == "" { + return filepath.Join(f.home, ".devin") + } + return filepath.Join(project, ".devin") +} + +func (f devinProjectJourney) assertDevinOwned(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.devinRoot(project) || binding.Activation != domain.ActivationPrepared || binding.Verification != domain.VerificationInstalled { + t.Fatalf("Devin 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.devinRoot(project)} { + if projectJourneyRead(t, filepath.Join(root, "skills", "docs", "SKILL.md")) != skill { + t.Fatal("Devin managed/native skill bytes differ from the selected pin") + } + } + settings := projectJourneyRead(t, filepath.Join(f.devinRoot(project), "mcp_config.json")) + for _, text := range []string{endpoint, "foreign.test", "foreignPreference", `"disabled":true`} { + if !strings.Contains(strings.ReplaceAll(settings, " ", ""), text) { + t.Fatalf("Devin 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 devinProjectCommitSource(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 Devin project components") + projectJourneyGit(t, transport.repo, "push", "--quiet", transport.remote, "main") + transport.mu.Lock() + defer transport.mu.Unlock() + transport.archive = projectJourneyArchive(t, transport.repo) +} + +func devinJourneyTemp(t *testing.T) string { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return root +} diff --git a/cli/cmd/agentplugins/devin_project_lifecycle_test.go b/cli/cmd/agentplugins/devin_project_lifecycle_test.go new file mode 100644 index 000000000..9511a4b3a --- /dev/null +++ b/cli/cmd/agentplugins/devin_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 Devin discovery, +// enablement, MCP initialization, model use and runtime are not certified. +func TestProjectDevinComposedSourceLifecycle(t *testing.T) { + for _, kind := range []string{"git", "zip"} { + t.Run(kind, func(t *testing.T) { + transport := newProjectJourneyTransport(t, kind) + devinProjectCommitSource(t, transport, projectJourneySkill, "https://managed.test/v1") + f := newDevinProjectJourney(t, transport) + userBefore := securityFixtureTree(t, f.devinRoot("")) + for _, root := range []string{f.a, f.b} { + f.run(t, root, "add", transport.endpoint, "--alias", "docs", "--target", "windsurf") + f.assertDevinOwned(t, root, projectJourneySkill, "https://managed.test/v1") + } + bNative := securityFixtureTree(t, f.devinRoot(f.b)) + bState := projectJourneyRead(t, filepath.Join(f.namespace(f.b), "state-v2.json")) + devinProjectReconcileAndRepair(t, f) + devinProjectUpdateAndTransfer(t, f) + devinProjectRemove(t, f) + if !reflect.DeepEqual(userBefore, securityFixtureTree(t, f.devinRoot(""))) || !reflect.DeepEqual(bNative, securityFixtureTree(t, f.devinRoot(f.b))) || bState != projectJourneyRead(t, filepath.Join(f.namespace(f.b), "state-v2.json")) { + t.Fatal("Devin project-A lifecycle changed user or project-B native configuration/state") + } + f.assertDevinOwned(t, f.b, projectJourneySkill, "https://managed.test/v1") + }) + } +} + +func devinProjectReconcileAndRepair(t *testing.T, f devinProjectJourney) { + 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("Devin locked no-op changed exact ownership receipts") + } + installation, _ := f.owned(t, f.a) + native := securityFixtureTree(t, f.devinRoot(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.devinRoot(f.a))) { + t.Fatal("Devin project read commands changed native files or ownership receipts") + } + if err := os.RemoveAll(filepath.Join(f.devinRoot(f.a), "skills", "docs")); err != nil { + t.Fatal(err) + } + f.run(t, f.a, "repair", installation.InstallationID, "--target", "windsurf") + f.assertDevinOwned(t, f.a, projectJourneySkill, "https://managed.test/v1") + f.inspectNoRecovery(t, f.a) +} + +func devinProjectUpdateAndTransfer(t *testing.T, f devinProjectJourney) { + t.Helper() + previous, binding := f.owned(t, f.a) + devinProjectCommitSource(t, f.transport, projectJourneyAdvancedSkill, "https://managed.test/v2") + f.run(t, f.a, "update", "docs", "--target", "windsurf") + updated, selected := f.assertDevinOwned(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("Devin 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("Devin export changed exact portable pins") + } + cold := newDevinProjectJourney(t, f.transport) + projectJourneyAbsent(t, cold.cache, filepath.Join(cold.a, ".uap")) + cold.run(t, cold.a, "deps", "import", exchange, "--install", "--yes") + restored, _ := cold.assertDevinOwned(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("Devin cold import changed source identity or portable pins") + } + cold.inspectNoRecovery(t, cold.a) +} + +func devinProjectRemove(t *testing.T, f devinProjectJourney) { + t.Helper() + _, binding := f.owned(t, f.a) + f.run(t, f.a, "remove", "docs", "--target", "windsurf") + projectJourneyAbsent(t, binding.TargetLocator, filepath.Join(f.devinRoot(f.a), "skills", "docs")) + settings := projectJourneyRead(t, filepath.Join(f.devinRoot(f.a), "mcp_config.json")) + if strings.Contains(settings, "managed.test") || !strings.Contains(settings, "foreign.test") || !strings.Contains(settings, "foreignPreference") { + t.Fatalf("Devin removal changed foreign MCP settings or retained its entry: %s", settings) + } + if projectJourneyRead(t, filepath.Join(f.devinRoot(f.a), "skills", "foreign", "SKILL.md")) != "foreign skill\n" || projectJourneyRead(t, filepath.Join(f.devinRoot(f.a), "hooks.json")) != "foreign hooks\n" { + t.Fatal("Devin removal changed foreign skill or hooks") + } + if len(f.state(t, f.a).EnvironmentReceipts) != 0 { + t.Fatal("Devin removal retained an environment ownership receipt") + } +} diff --git a/cli/internal/agentpluginscli/deps_sync.go b/cli/internal/agentpluginscli/deps_sync.go index a085a68d7..44f5a6f8c 100644 --- a/cli/internal/agentpluginscli/deps_sync.go +++ b/cli/internal/agentpluginscli/deps_sync.go @@ -352,6 +352,9 @@ func (adapter *dependencyInstaller) populateDependencyTargets(view installer.Ins continue } for _, binding := range installation.Bindings { + if binding.ClientID == request.ClientID { + continue + } sibling, ok := adapter.detected[domain.ClientID(binding.ClientID)] if !ok { return fmt.Errorf("installed sibling %s is not detected", binding.ClientID) diff --git a/cli/internal/agentpluginscli/project_host_version_test.go b/cli/internal/agentpluginscli/project_host_version_test.go new file mode 100644 index 000000000..bea24e8c2 --- /dev/null +++ b/cli/internal/agentpluginscli/project_host_version_test.go @@ -0,0 +1,68 @@ +package agentpluginscli + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/codex" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/installer" +) + +type versionBoundaryCodex struct { + *codex.Adapter + observed *string +} + +func (adapter *versionBoundaryCodex) CheckPlanPrecondition(in clients.PlanInput, _ *domain.DeliveryPlan) error { + *adapter.observed = in.Client.Version + return errors.New("inert host-version boundary reached") +} + +// Windsurf's direct no-save path already retains the domain client. This proves +// the existing qualified facade builder forwards its own selected metadata too. +func TestProjectHostVersionCrossesExistingNoSaveFacadeBoundary(t *testing.T) { + client := fixtureClient(t, domain.ClientCodex) + client.ExecutablePath = filepath.Join(t.TempDir(), "codex") + client.Version = "selected-host-version" + f := newCLIFixture(t, []domain.DetectedClient{client}) + observed := "" + registry, err := clients.NewRegistry(&versionBoundaryCodex{Adapter: codex.New(), observed: &observed}) + if err != nil { + t.Fatal(err) + } + engine, err := installer.New(installer.Config{StateRoot: filepath.Join(f.root, "facade"), Registry: registry}) + if err != nil { + t.Fatal(err) + } + f.app.Installer = engine + f.app.Lifecycle.Planner = nil + _, _, err = f.execute(false, "add", writeCLIPlugin(t), "--no-save", "--target", "codex", "--format", "json") + if err == nil || !strings.Contains(err.Error(), "inert host-version boundary reached") || observed != client.Version { + t.Fatalf("selected host evidence=%q,%v", observed, err) + } +} + +func TestProjectDependencyKnownFactsRetainOnlyUnselectedSiblings(t *testing.T) { + root := t.TempDir() + sibling := domain.DetectedClient{ClientID: domain.ClientGemini, ConfigRoot: filepath.Join(root, "gemini"), ExecutablePath: filepath.Join(root, "gemini-cli")} + adapter := dependencyInstaller{app: App{Scope: domain.ScopeProject, ProjectRoot: root}, detected: map[domain.ClientID]domain.DetectedClient{domain.ClientGemini: sibling}} + view := installer.Inspection{Installations: []installer.InspectedInstallation{{InstallationID: "selected", Bindings: []installer.InspectedBinding{{ClientID: "windsurf", BindingID: "selected-binding"}, {ClientID: "gemini", BindingID: "sibling-binding"}}}}} + request := installer.Request{ClientID: "windsurf", ClientVersion: "3000.11.205"} + if err := adapter.populateDependencyTargets(view, "selected", &request); err != nil { + t.Fatal(err) + } + if len(request.KnownTargets) != 1 || request.KnownTargets[0] != (installer.TargetFacts{ClientID: "gemini", BindingID: "sibling-binding", ConfigRoot: sibling.ConfigRoot, Executable: sibling.ExecutablePath}) { + t.Fatalf("selected/sibling facts mixed: %+v", request.KnownTargets) + } + if request.ClientVersion != "3000.11.205" || request.ClientConfigRoot != "" { + t.Fatal("primary host evidence changed") + } + delete(adapter.detected, domain.ClientGemini) + if err := adapter.populateDependencyTargets(view, "selected", &installer.Request{ClientID: "windsurf"}); err == nil { + t.Fatal("unselected sibling missing facts accepted") + } +} diff --git a/docs/plans/project-install-e2e/devin-native-comments-amendment.md b/docs/plans/project-install-e2e/devin-native-comments-amendment.md new file mode 100644 index 000000000..1333fd1bd --- /dev/null +++ b/docs/plans/project-install-e2e/devin-native-comments-amendment.md @@ -0,0 +1,73 @@ +# Devin native configuration comments — pre-code review correction + +2026-10-23. This is a narrow supplemental amendment to the twice-reviewed client +plan and frozen adapter132b8c38. Independent R1 found that strict JSON currently +rejects normal commented native Devin configuration. Automatic approval review +rejected the initial corrective source command because this shared parsing +amendment had not received two independent plan/fix GO reports. The command did +not execute; the source remains frozen and clean. Commit this plan and both +independent GO reports before implementing the correction. + +## Evidence and defect + +The genuine3000.11.205 distribution's pinned official +reference/configuration/config-file.mdx has an explicit JSON with Comments +section supporting line, inline and block comments. It also declares the +dedicated .devin/mcp_config.json and higher mcp_config.local.json native files; +the matching MCP documentation shows this native configuration. References: +https://docs.devin.ai/cli/reference/configuration/config-file and +https://docs.devin.ai/cli/extensibility/mcp/configuration. This is native file +grammar evidence, not native project execution or trailing-comma support. + +The new adapter's readDevinProjectDocument passes native main/local policy +bytes into the strict portable JSON decoder, so an unrelated native comment +blocks installation. The new CodecDevin also currently takes native target +mcp_config.json through strict parseDocument(false), rejecting commented target +files before an otherwise exact owned patch. Legacy CodecWindsurf must retain +its current grammar and user behavior. + +## Narrow implementation + +Reuse the existing comments-only native AST mechanics already used for Gemini: +extract its private syntax admission into parseCommentsOnlyDocument(body,label) +without changing Gemini's accepted/rejected grammar or existing error text. +CodecGemini delegates with its existing label; only the new CodecDevin opts in +with a Devin label. Every other codec, especially legacy CodecWindsurf, keeps +its existing branch. Preserve the original AST and commentsOnly render flag, +existing trailing-comma rejection/cleanup, duplicate-key checks, native name/ +path validation, no-follow IO, writer locking, entry digests and ownership, +transaction effects/rollback and receipt rules. Do not add a second writer or +change mutation/recovery semantics. + +Add one small read-only nativeconfig.StandardizeCommentedJSON helper using that +same comments-only syntax admission followed by existing hujson.Standardize. +The adapter's native main/local policy inspection calls it before the existing +strict unique-key object decoder. Original files remain unmodified and exact +bytes are retained; the normalized bytes are only ephemeral inspection input. +Duplicate keys and undocumented trailing commas refuse, rather than accepting +all JSONC grammar accidentally. Do not relax portable plugin/MCP/manifest JSON, +change a general shared strict decoder, infer trust/policy, migrate old config, +or rewrite local overrides. + +## Meaningful verification + +Add a current-Devin native codec regression with line/block comments and exact +foreign disabled entry/policy trivia. Add/remove only the owned member and +verify foreign comments/member bytes survive. Refuse duplicate keys and trailing +commas through both the target codec and the read-only normalizer. Assert legacy +CodecWindsurf still rejects the same commented document; run affected existing +Gemini comments/grammar tests to prove its behavior and error contract remain. + +At the composed provider boundary, accept unrelated commented config.json, +config.local.json and a foreign-name mcp_config.local.json while preserving each +file byte-for-byte. Refuse commented legacy main mcpServers and same-name local +shadowing with native/user/B inventories unchanged. Retain the exact scope, +artifact, receipt/data and attestation regressions, plus visible-write rollback +and committed-cleanup cases. Existing project CLI/SDK evidence remains separate +from genuine native discovery. Run bounded affected codec/provider suites, +changed/full-file lint and diff checks; freeze the correction for independent +R1/R2 implementation refresh before parent publication. + +No native executable, trust/model/account/MCP process, installer retry, new +acquisition, hosted guard change, user capability expansion, main/release merge +or native runtime qualification is authorized by this syntax correction. diff --git a/docs/plans/project-install-e2e/devin-selected-facts-amendment.md b/docs/plans/project-install-e2e/devin-selected-facts-amendment.md new file mode 100644 index 000000000..f6618c5f6 --- /dev/null +++ b/docs/plans/project-install-e2e/devin-selected-facts-amendment.md @@ -0,0 +1,45 @@ +# Selected Devin facts are not sibling facts + +Pre-code narrow follow-up, 2026-10-23. The two reviewed host-evidence/absent-profile +contracts and original client plan remain in force. No implementation of this +specific correction exists at this checkpoint. + +The new real TLS Git/ZIP public CLI regression succeeds on first saved Devin +project install with no legacy Cascade profile, then fails during saved repair: +`target facts are unavailable or conflict with owned state: ConfigRoot must be +an explicit absolute clean path for windsurf`. The source is +`dependencyInstaller.populateDependencyTargets`: it appends every binding, +including the selected request client, into KnownTargets. The SDK field contract +says KnownTargets is for installed sibling bindings not selected by the request. +Its existing stricter sibling root validation is not the right place to repeat +primary request admission. + +Implement only a selected-client exclusion in that CLI collector: +`if binding.ClientID == request.ClientID { continue }`, before looking up and +appending the sibling fact. Keep the existing knownTargetIndex validation, +BindingID/executable/root requirements and all unselected sibling facts unchanged. +Do not introduce an empty-root exception or version field for sibling facts. +The primary selected Request already carries its fresh observed executable/ +version and optional original root under the reviewed project-only admission. + +Independent tracing of compatibilityChecks shows that selected single-target +bindings already use req.ClientConfigRoot/ClientExecutable directly; only +binding.ClientID != req.ClientID consults KnownTargets and checks its BindingID. +The selected target's prepared DetectedClient replaces its observational +reconstruction in compatibilityBindingCheck, retaining the fresh version. +The selected binding remains bound by core installation/binding/environment +receipts, namespace, target/profile/native object and immutable package guards. +No duplicate selected KnownTargets value supplies additional authority there. + +Verification: retain the failing current-only no-profile real TLS saved +install/locked-sync/repair/update/cold-import/remove journey until it passes; +add an affected-builder assertion that selected facts are omitted while exact +unselected sibling facts remain. Existing knownTargetIndex user/sibling missing/ +invalid root and binding identity tests stay unchanged and must pass. No native +process, new public schema, group expansion, detection order, user capability, +profile fallback or version-dependent cleanup is added. + +Obtain both independent plan GO reports before applying this correction. Freeze +it with the final Devin adapter for both independent code reviews; root owns +publication. This remains prepared file-delivery evidence, not native discovery, +trust, MCP runtime, Desktop harness or release qualification. diff --git a/docs/plans/project-install-e2e/reviews/devin-code-review-r1.md b/docs/plans/project-install-e2e/reviews/devin-code-review-r1.md new file mode 100644 index 000000000..dc076eecc --- /dev/null +++ b/docs/plans/project-install-e2e/reviews/devin-code-review-r1.md @@ -0,0 +1,83 @@ +# Current Devin project adapter — independent code R1 + +Reviewed frozen `132b8c38226f87a39724637cf5ac11b7e0978ea9` in +`work/uap-project-windsurf` on 2026-10-23. Reviewer did not author the adapter. +**NO-GO pending the native-comments correction below.** The separately reviewed +shared host-version prerequisite `64c7ee6d` already has both implementation GOs; +its approval is not repeated or widened here. The selected-versus-sibling facts +amendment has both plan GOs before the exact three-line collector exclusion. +No vendor/native client, launcher, model, trust/auth or MCP process was executed. + +## Blocking finding + +`clients/windsurf/project_policy.go:readDevinProjectDocument` directly passes +native config.json/config.local.json/mcp_config.local.json into the strict JSON +decoder. `nativeconfig.parseCodecDocument` also routes new CodecDevin target +mcp_config.json through strict JSON admission. Current official config-file +documentation explicitly supports JSON comments and the pinned distribution's +documentation agrees. Ordinary unrelated comments in a main/local file or +target therefore block an otherwise qualified project installation. + +Correct native policy reads and CodecDevin only using the existing comments-only +AST grammar, keeping duplicate-key/name/path checks, original foreign bytes, +receipt digests and ownership/rollback intact. Preserve legacy CodecWindsurf, +portable JSON and Gemini's existing grammar/error text. Reject undocumented +trailing commas rather than broadening to all hujson syntax. The author accepted +the finding and froze amendment `e09d06d3`; its R1 plan is GO in the separate +report, with R2/source/implementation closure still required. + +Required regressions: commented unrelated main/local files and target patches +preserve exact foreign comments/disabled entries; commented legacy MCP and +same-name local shadowing still refuse without effects; duplicate/trailing +input remains refused in both reader paths; legacy Windsurf strict and Gemini +comments contracts remain unchanged. + +## Inspected remaining boundaries + +The new automatic lane requires the selected primary absolute clean devin or +devin.exe and exact host-observed version 3000.11.205. It does not infer current +Devin from legacy editor presence, probe from SDK/adapter, rewrite HOME, choose a +fallback profile or execute native mutation. An absent original legacy profile +is legitimate only under the previously reviewed project SDK admission. +The original user root must still agree exactly with TargetContext. + +Plan/client/scope/native-root/artifact and exact managed package layout are +checked before effects. Direct activation requires the delivered package path; +pre-staging alone permits it absent. Previous/desired native receipt paths, +kinds, IDs, duplicates and source paths use the reviewed shared validation. +Binding inspection rejects copied scope/artifact/package/native roots. Active +stdio reuse verifies selected package/data/cwd launcher coordinates and reserved +environment values without rewriting immutable bytes. Project migration and +same-name higher local policy refuse; other imported/effective policy is +truthfully unobserved. + +The dedicated current codec retains command/args/env without native cwd and +explicit remote transport, separate from legacy Cascade serverUrl. All writes +use the reviewed component transaction/kernel; the authored tests cover stale +ownership, collision, visible replacement rollback and committed cleanup +receipts. Project attestation is refused, and configured results remain prepared +with installation verification. Exact recorded removal does not require a +current executable/version and does not call default-local native remove. + +The selected KnownTargets exclusion follows the existing sibling-only contract; +primary Request/prepared client evidence remains authoritative, while all +unselected sibling root/executable/BindingID checks remain unchanged. Four +compatibility goldens change only Windsurf's project scope list. Legacy user +source/selection and useful tests are retained. Public TLS Git/ZIP and SDK +scenarios are honestly fixture composition with no native runtime claim. + +Independent execution of the final corrected frozen checkpoint will be recorded +with closure below. The author's existing passes remain separate evidence, +not independently rerun results in this interim report. + +## Additional exact client authority finding + +Concur with independent R2: `validateDevinProjectBinding` must also require literal `binding.ClientID == "windsurf"`. Scope/root/artifact/object checks alone allow a copied contradictory owner to reach direct binding inspection. Require refusal in direct validation/inspection with a copied-client-ID unchanged-effects regression. The author accepted this original exact-client authority correction; final R1 remains pending both this and native-comments closure at a frozen SHA. + +## Final corrected source and integration closure + +**GO at clean frozen `d69091b3328a1b626d9475643e8e97a8bf040b4a`.** Both accepted blockers are closed. Both native-comments plan GO reports precede source4f5d9558. CodecDevin and read-only policy inspection now reuse the existing comments-only AST admission: duplicate keys at every nesting level and undocumented trailing commas refuse. Strict normalized JSON is used only for read-only inspection; original file bytes and AST remain the mutation/ownership authority. Gemini's existing comments grammar/error text and legacy CodecWindsurf/portable strict admission are preserved. New tests cover unrelated commented main/local policies without rewrites, commented legacy/shadow refusal, foreign target trivia preservation, duplicate/trailing refusal and legacy strictness. Exact literal Windsurf ClientID is required by shared project binding validation, with direct and inspection copied-codex refusal. + +Independently inspected all three manual union conflicts in the true merge of reviewed d39: Codex TOML dispatch remains first, Gemini plus Devin comments-only parsing remains separate; project server dispatch retains Copilot and Devin with all prior codecs; supportedCodec retains Codex, Copilot, Devin and every previous codec. No authored Windsurf/provider/SDK path changed from4f5 to the merge, and every other client directory is byte-identical to d39. No new semantic merge resolution found. + +The authored public TLS Git/ZIP/SDK/provider/legacy passes remain separately attributed evidence. Parent reports the other independent reviewer ran final full nativeconfig PASS3.471s and preserved legacy client PASS0.006s. This R1 did not repeat those results or execute a native client. Local compiler capacity was exhausted; parent explicitly directed static final closure instead of redundant broad rebuilds, reserving the next slot for genuinely new Source corrections. The complete static review and targeted unchanged-authority regressions leave no code blocker. Genuine project discovery/trust/session/MCP/native loading and release qualification remain pending; prepared/installation evidence must not be reported as attestation. diff --git a/docs/plans/project-install-e2e/reviews/devin-code-review-r2.md b/docs/plans/project-install-e2e/reviews/devin-code-review-r2.md new file mode 100644 index 000000000..53a87e691 --- /dev/null +++ b/docs/plans/project-install-e2e/reviews/devin-code-review-r2.md @@ -0,0 +1,59 @@ +# Current Devin project adapter — independent implementation R2 + +## Frozen initial review + +Reviewer codex_claude, 2026-10-23. Initial target `132b8c38226f87a39724637cf5ac11b7e0978ea9` in work/uap-project-windsurf. **Not yet GO:** the accepted R1 native-comments seam and the bounded direct-binding client discriminator finding below require closure at a corrected frozen source SHA. Reviewer has not edited this worktree or executed a vendor/native process. + +## Findings + +1. R1 independently identified documented comments in current native Devin configuration. The selected target CodecDevin and native main/local policy decode currently reject them as strict portable JSON. Exact vendor3000.11.205 docs confirm line/inline/block comment grammar. The separate docs-only comments correction plan `e09d06d372575b0ad8edc0bf5ffa5f9ecc030e0f` has this reviewer's R2 GO in research/devin-native-comments-plan-r2.md; retain Gemini grammar/errors, all legacy/portable strictness, duplicate/trailing refusal and original comment bytes. No parser source changed by this reviewer. + +2. `validateDevinProjectBinding` validates scope/root/artifact/path/objects but omits `binding.ClientID`. An otherwise identical direct binding tagged for another client is therefore passed to ProjectComponents.Inspect as ownership. That helper deliberately checks component receipt mechanics, not client identity, so the native record can be reported RegistryExpected despite contradictory owner identity. Require exact existing product ID `windsurf`, leaving user behavior unchanged. Update the valid copied-binding fixture to include its real ClientID and add a wrong-client inspector case. Higher production composition already fences IDs; this correction makes the direct project ownership boundary agree with its stated contract. + +## Static coverage completed + +Traced the current selected primary basename/version gate, original user-context equality including legitimate both-empty current-only profile, exact project/native root and `.uap/managed/clients/windsurf/` binding, selected-delivery/path fences, shared host-version propagation and selected-versus-sibling exclusion, project-only native lifecycle/backend policy, new Devin transport codec versus preserved Cascade codec, skill/MCP object receipts, no-follow paths, immutable selected stdio package/data/cwd/environment reuse, direct/composed attestation refusal, and version-independent exact recorded removal. No additional blocker found in those boundaries. + +Project policy refuses legacy main-config MCP migration and same-name local override rather than modifying it; unrelated imported/user/ancestor/aliases/cloud/team/session effects remain unobserved. Project materialization uses the reviewed component transaction/native kernel with existing rollback and committed-cleanup receipt semantics. It never calls default-local native remove or registers a user plugin. Existing detection/channel ordering and legacy user paths/lifecycle remain preserved. + +Inspected meaningful fixture coverage: production CLI saved TLS Git/ZIP add/locked-no-op/read/repair/update/export/cold-import/remove plus no-save, public SDK A/B stdio/data/exact repair/restart/remove and missing/wrong-version/empty-profile/non-injection admission, selected-only KnownTargets correction with exact sibling facts, direct/composed root/artifact/delivery/receipt refusal, foreign disabled/stale/symlink preservation, visible-write rollback and committed-cleanup verification. These are configuration/ownership fixtures with inert host/security/launcher boundaries, not genuine native loaded/runtime proof. Ten actual bounded version/help observations and pins remain pre-code evidence only. + +Independent affected test results and final correction closure will be appended at the next frozen checkpoint. Native discovery/trust/permissions/authentication/MCP initialization/runtime/Desktop/release and all-client completion remain unqualified by this review. + +## Final correction and component-base closure + +**GO** at frozen clean `d69091b3328a1b626d9475643e8e97a8bf040b4a`. +Both initial findings are closed. The native-comments amendment and both plan +GO reports were committed ate8f638ed before corrective4f5d9558 source. Its new +Devin branch reuses the existing comments-only AST admission and retains Gemini +error labels/explicit JSONC behavior. Duplicate keys and trailing commas refuse +in the target codec and the ephemeral policy normalizer. Original foreign +comments/entries remain in the mutation AST. Legacy Cascade and portable strict +JSON retain their previous grammar. Commented main/local migration and same-name +shadowing policy has meaningful preserved-byte success/refusal coverage. + +The direct project validator now requires literal binding.ClientID `windsurf`. +Its valid binding includes that discriminator, and copied-client bindings are +checked by both direct validation and native inspection. User-scope validator +behavior remains unchanged. + +Independently examined all three manual d39e40ef component-base merge conflicts: +Codex TOML dispatch precedes the Gemini/new-Devin comments parser, projectServer +retains both Copilot and Devin dispatches, and supportedCodec retains every +baseline member plus Devin. All foreign client directories are byte-identical +to d39e40ef; the domain diff is only the Windsurf project wrapper. All four +compatibility JSON goldens preserve every non-Scopes field. The first static +comparison used lowercase `scopes` and correctly failed; rerunning with the +actual `Scopes` field confirmed the narrow change. No source changed in review. + +Independent full nativeconfig tests passed3.471s at this frozen target, including +Codex/Copilot/Gemini/Devin codec routes and existing kernel ownership/rollback +checks; the preserved Windsurf client suite passed0.006s. A subsequent independent +project provider/SDK/public CLI selection could not execute its intended +assertions because package snapshot creation under actual /tmp failed ENOSPC. +Those are recorded resource failures, not passes or semantic regressions. Parent +explicitly requested no duplicate broad builds under the shared-space shortage. +Prior authored actual composed/provider/public SDK/CLI successes recorded above +remain evidence for unchanged paths, and the focused native codec execution plus +static correction/merge review support this source GO. No new vendor/native +process ran, and current effective native discovery/runtime remains pending. diff --git a/docs/plans/project-install-e2e/reviews/devin-native-comments-plan-r1.md b/docs/plans/project-install-e2e/reviews/devin-native-comments-plan-r1.md new file mode 100644 index 000000000..0470a135f --- /dev/null +++ b/docs/plans/project-install-e2e/reviews/devin-native-comments-plan-r1.md @@ -0,0 +1,40 @@ +# Devin native comments amendment — independent plan R1 + +Reviewed documentation-only frozen +`e09d06d372575b0ad8edc0bf5ffa5f9ecc030e0f` in `work/uap-project-windsurf` on +2026-10-23. **GO** after both plan GO reports are committed. The reviewer +identified the original native-comments admission defect but did not author +this amendment or corrective source. No native process was executed. + +The defect is present in both strict native-policy inspection and CodecDevin's +target document path. Official current configuration documentation and the +author's exact-version distribution documentation support JSON comments; they +do not establish trailing commas. The plan correctly treats that as a narrow +native grammar correction rather than relaxing portable JSON or inferring +native project execution. Automatic approval review rejected the earlier source +attempt because this shared parsing amendment lacked two plan GOs; the command +did not execute, so source remains frozen pending this gate. + +Reusing the existing comments-only AST admission preserves exact foreign trivia, +duplicate-key/name/path checks, no-follow IO, locks, canonical entry digests and +transaction/receipt behavior. Gemini delegates with its existing label and +error/grammar contract; only CodecDevin additionally opts in. Legacy Windsurf +and other codecs keep their current grammar. The read-only normalizer uses that +same comments-only admission before standardization and the existing strict +unique-key object decoder, preserving original policy bytes and refusing +undocumented trailing commas rather than accepting all hujson extensions. + +Planned meaningful regressions cover commented target add/remove with exact +foreign disabled-policy trivia, duplicate/trailing refusal in both entry paths, +unchanged legacy strict refusal and Gemini behavior. Composed unrelated main/ +local override files must remain byte-identical while commented legacy MCP or +same-name shadowing still refuse with user/B unchanged. Existing exact context, +artifact, data, attestation, visible-write rollback and cleanup receipt cases +remain mandatory. No second writer, migration, permission/trust write, profile +fallback, process, acquisition or parser-policy change outside these native +inputs is introduced. + +No blocking plan issue found. Freeze the implementation for both independent +R1/R2 refreshes before root publication. The underlying Devin adapter R1 stays +pending until that correction is independently inspected and verified; this +plan GO creates no native/runtime/project-success or release claim. diff --git a/docs/plans/project-install-e2e/reviews/devin-native-comments-plan-r2.md b/docs/plans/project-install-e2e/reviews/devin-native-comments-plan-r2.md new file mode 100644 index 000000000..a9ff9c399 --- /dev/null +++ b/docs/plans/project-install-e2e/reviews/devin-native-comments-plan-r2.md @@ -0,0 +1,13 @@ +# Devin native comments correction — independent supplemental plan R2 + +**GO** at docs-only frozen `e09d06d372575b0ad8edc0bf5ffa5f9ecc030e0f`, work/uap-project-windsurf, reviewer codex_claude, 2026-10-23. No corrective source or native process executed by this review. Original client plan and final implementation review requirements remain in force. + +Independently read the exact3000.11.205 distribution's pinned reference/configuration/config-file.mdx JSON-with-Comments section: line, inline and block comments are explicitly supported. Independently traced the current readDevinProjectDocument strict portable decode and CodecDevin native target path. They reject documented native comments; no genuine native project success can be inferred or fabricated from this static grammar correction. The amendment transparently records the earlier automatic approval review rejection and that the source command did not execute. + +The proposed reuse is suitably narrow. Extracting Gemini's existing comments-only admission into a private labeled helper preserves its original AST, duplicate-key rejection, commentsOnly rendering and trailing-comma refusal/cleanup. Keep Gemini's existing branch behavior and exact error labels/text, including its existing explicit JSONC branch, unchanged. Opt only the new CodecDevin into the helper; all old codecs, especially legacy CodecWindsurf, retain their previous grammar and ownership behavior. Do not broadly accept all JSONC features merely because hujson can parse them. + +A read-only StandardizeCommentedJSON wrapper using the same admitted syntax followed by standardization is appropriate for Devin main/local policy inspection. Strict unique-key object decoding remains after normalization; original bytes/AST and native file/receipt digests are never replaced by standardized inspection bytes. No portable decoder, manifest, runtime policy, migration, local override, no-follow IO, transaction, rollback or trust/attestation authority needs to change. + +The proposed codec and composed-provider regressions resolve the concrete risk: accept and preserve valid foreign comments/policy, refuse duplicate keys and unsupported trailing commas in both codec and normalizer, keep legacy Windsurf strict and existing Gemini grammar/errors, refuse commented migration/same-name local shadowing with unchanged user/A/B inventories, and retain exact receipt/data/rollback/committed-cleanup gates. Bounded affected tests/lint, two plan GO reports before code and refreshed R1/R2 code reviews before root publication are sufficient. + +No plan blocker found. This syntax GO does not establish native project discovery/runtime or release qualification. diff --git a/docs/plans/project-install-e2e/reviews/devin-selected-facts-plan-r1.md b/docs/plans/project-install-e2e/reviews/devin-selected-facts-plan-r1.md new file mode 100644 index 000000000..d4b7206e3 --- /dev/null +++ b/docs/plans/project-install-e2e/reviews/devin-selected-facts-plan-r1.md @@ -0,0 +1,47 @@ +# Selected-client facts follow-up — independent plan R1 + +Reviewed frozen documentation-only `c48bbc58` in `work/uap-project-windsurf` on +2026-10-23. **GO** for the narrow selected-client exclusion, after both plan GO +reports are committed. No correction source or native operation was performed +by this reviewer. The unfinished later adapter is outside this plan review. + +Independently traced dependencyInstaller.Prepare: it selects the primary client +from the detected map and builds Request.ClientID/ClientConfigRoot/executable/ +ClientVersion before populateDependencyTargets. The collector currently loops +every existing binding and duplicates that selected primary into KnownTargets. +The published Request comment explicitly reserves KnownTargets for unselected +installed siblings. Its knownTargetIndex correctly requires each sibling's +clean absolute root and exact BindingID; weakening that separate boundary to +admit a legitimate absent primary profile would be the wrong correction. + +The planned early exclusion compares only binding.ClientID to the already +selected Request.ClientID before sibling lookup/collection. All unselected +binding facts, missing-sibling errors, executable selection, binding IDs and +strict knownTargetIndex checks remain unchanged. No new empty-root exception, +version field, group behavior or user capability is added. For the current +single-client dependency request there is no need to duplicate selected facts. + +Independently traced SDK detectedClients and compatibilityChecks. The primary +continues through Request reconstruction under the reviewed exact engine/scope/ +namespace admission. Compatibility uses the Request's primary root/executable +directly when binding.ClientID equals req.ClientID; only a different binding +looks up KnownTargets and compares its owned ClientBindingID. The subsequent +compatibilityBindingCheck restores the prepared target.Client for the selected +client, retaining its fresh captured version/authority. The duplicate selected +KnownTargets entry supplies no extra selected-binding authority there. Existing +installation/binding/environment selection, package pin, target/native object, +profile and namespace validation still guard the mutation. + +The proposed verification covers the actual composition defect: current-only +no-legacy-profile public saved install must continue through locked sync, +repair, update, transfer and removal. The focused builder regression must show +the selected client absent from KnownTargets while an exact unselected sibling +remains; existing missing/invalid sibling profile, stale BindingID and separate +executable compatibility tests must stay intact and pass. The earlier genuine +failure is valid author evidence, while final execution proof belongs to the +frozen correction and adapter implementation review. + +No blocking plan issue found. This permits no profile guessing, default HOME, +version-dependent cleanup, native process, trust/model/account/MCP action or +discovery/runtime qualification. Freeze the correction with the final adapter +for both independent implementation reviews; root owns publication. diff --git a/docs/plans/project-install-e2e/reviews/devin-selected-facts-plan-r2.md b/docs/plans/project-install-e2e/reviews/devin-selected-facts-plan-r2.md new file mode 100644 index 000000000..9bd0b9297 --- /dev/null +++ b/docs/plans/project-install-e2e/reviews/devin-selected-facts-plan-r2.md @@ -0,0 +1,11 @@ +# Selected Devin facts versus sibling facts — independent plan R2 + +**GO** at docs-only frozen `c48bbc58` (`docs/plans/project-install-e2e/devin-selected-facts-amendment.md`, work/uap-project-windsurf), reviewer codex_claude, 2026-10-23. No correction source changed or native process executed by this review. + +Independently traced populateDependencyTargets, knownTargetIndex/detectedClients and compatibilityChecks/compatibilityBindingCheck. The CLI collector currently repeats every installed binding as KnownTargets, including the selected single request client. knownTargetIndex eagerly applies its sibling-only nonempty clean ConfigRoot contract even to that redundant primary record. Current-only Devin correctly has no legacy Cascade root, so the duplicate record conflicts with the separately reviewed project-only primary admission. Its rejection does not establish missing selected authority. + +Skipping only binding.ClientID == request.ClientID before sibling lookup/appending matches the existing Request field contract. Primary request reconstruction still carries the selected current executable/version/original profile through its strict project/adaptor gates; selected compatibility checks use request facts, and selected prepared target.Client replaces observational reconstruction. Installation, binding/environment identity, physical scope/profile/native roots, immutable package and object receipts remain enforced. No selected KnownTargets duplicate is an additional ownership token. Groups and all unselected sibling fact collection/BindingID/executable/root/duplicate validation remain unchanged; no empty-root exception belongs in knownTargetIndex. + +The proposed failing real saved TLS Git/ZIP current-only no-profile install/sync/repair/update/cold-import/remove journey plus affected-builder selected-omission/exact-sibling assertion and unchanged existing missing/invalid sibling tests directly cover the defect. Keep user/group behavior neutral and exact removal/recovery free of new version gates. Freeze the correction with final Devin adapter code for both independent implementation reviews. + +No blocker found. This GO is not a native discovery/runtime/Desktop/account/trust/release claim. diff --git a/docs/plans/project-install-e2e/windsurf-devin-code-checkpoint.md b/docs/plans/project-install-e2e/windsurf-devin-code-checkpoint.md new file mode 100644 index 000000000..916093a60 --- /dev/null +++ b/docs/plans/project-install-e2e/windsurf-devin-code-checkpoint.md @@ -0,0 +1,138 @@ +# Current Devin project adapter implementation checkpoint + +2026-10-23. Original client plan2GO is preserved. The narrow host-evidence/ +project-only absent-profile amendment has both plan GO reports committed before +source and both independent implementation GO reports at64c7ee6d. The composed +saved repair exposed redundant selected-client KnownTargets collection; its +separate c48bbc58 amendment and both plan GO reports at2a328849 precede the +selected-client exclusion. Sibling admission is unchanged. + +## Implementation + +Only the selected primary Devin with normalized exact observed3000.11.205 enters +the new project configuration lane. Detection and the legacy Windsurf user +implementation stay intact. Package/state/data remain in .uap; native skills +use .devin/skills and MCP uses .devin/mcp_config.json. A narrow current-Devin codec +preserves command/args/env without cwd and url/headers with explicit http/sse; +legacy Cascade codec behavior is untouched. No vendor process is launched by +the project adapter or public SDK. + +All project entry points bind exact scope/original user context, native root, +client identity, artifact and .uap managed package path. Binding validation and +native receipt validation reject copied/unknown/duplicate/nonclean/traversal +objects. Active reuse checks selected stdio package/data/cwd launcher arguments +and env read-only; disagreement requires a fresh projection. Native writes use +the reviewed shared ownership kernel/component transaction. Legacy project main +config MCP and same-name local overrides refuse; unrelated local policy, foreign +disabled MCP, skills/hooks, user and project B bytes are preserved. Exact owned +remove and recovery do not depend on current version or launch default-local +native removal. + +Files remain prepared plus installation-verified. Project activation attestation +refuses. No native discovery, effective aliases/imports/policy, workspace trust, +account/model, MCP initialization/runtime, Desktop UI or release is qualified. +Ten genuine isolated version/help successes and vendor/archive/binary pins are +pre-code evidence only; actual project native proof remains pending. + +## Composed evidence + +- Production loader/planner/stager/provider suite passed0.426s, including full + skill+remote lifecycle/update/repair/component removal, read-only stdio data + mismatch rejection, exact scope/binding/artifact/receipt failures, migration/ + local-shadowing refusal, changed native aliases, attestation refusal, visible + MCP write failure with byte/skill rollback, and committed cleanup retaining + the exact new receipts. +- Public SDK suite passed0.590s: A/B stdio/data separation, repair from original + pin, fresh-installer update, exact remove without current harness/version, + current-only empty legacy profile, exact/unknown/older/wrong-primary version + admission and source-manifest non-injection. +- Public production CLI suite passed6.531s with real loopback TLS Git/ZIP source + acquisition: saved add, locked no-op, read-only commands, repair, update, + export and cold import with exact portable pins, A-only removal and user/B + preservation; direct no-save current-only add/remove and unqualified host + refusals also passed. No configured launcher/native/MCP process is executed. +- Both existing CLI metadata boundaries and selected-versus-sibling collection + regression passed0.020s. Windsurf stays outside the qualified no-save facade; + its direct usecase retains selected domain metadata, while the existing + qualified facade test proves that builder's selected version reaches SDK. +- Full client/domain/planner/native-codec suites passed0.012/0.034/0.031/3.539s. + All four changed compatibility goldens add only Windsurf project scope; + existing user planning goldens are unchanged. +- Affected legacy Windsurf provider/strict SDK target-facts/saved client tests + passed0.076/0.015/1.743s. Existing stricter user/sibling roots and profile + resolver contracts are preserved. + +Exact changed-code lint and full-file complexity/architecture lint both reported +zero issues with golangci-lint2.13.2 across all affected packages. git diff --check +passed. Two independent final adapter reviews are required on this frozen source +checkpoint. Root owns the separate stacked client PR and publication; no main/ +release merge is authorized by this checkpoint. + + +## Independent review correction checkpoint + +R1 found that documented native comments were rejected in both the read-only +project policy files and the new target MCP codec. The initial source command +was rejected by automatic approval review before execution for missing amendment +plan reviews. The e09d06d3 native-comments plan then received both independent GO +reports, committed ate8f638ed before source. The implementation reuses the existing +comments-only native AST grammar for new CodecDevin and ephemeral native policy +normalization, preserving foreign bytes and refusing duplicates/trailing commas. +Gemini's accepted/rejected grammar and error wording, explicit JSONC branches, +legacy Cascade and all portable decoders retain their existing behavior. + +R2 additionally identified that direct project binding inspection omitted the +binding.ClientID discriminator. Both reviewers confirmed that requiring the +existing exact Windsurf product identity is within the original exact-client +contract. The validator now requires ClientID `windsurf` and the copied-binding regression checks it directly and via inspection; +user-scope additional validation remains unchanged. + +The focused current-Devin/existing-Gemini codec tests passed0.014s. The next +provider relink hit actual shared-storage ENOSPC before execution; that attempt +is a resource failure, not a regression pass. Parent requested true merge of +reviewed static component baseline d39e40ef before final review refresh. Final +merged scope/provider/composed tests and lint will be recorded after headroom. + + +## Reviewed component baseline integration + +Parent requested true merge of d39e40ef, the static reviewed eight-client/shared +component baseline. Three manual native-codec dispatch conflicts were resolved +as a union: parseCodecDocument keeps Codex TOML dispatch before the unchanged +Gemini plus new-Devin comments-only branch; projectServer retains both the +Copilot and new-Devin dispatches; supportedCodec retains Codex/Copilot plus Devin +and every prior codec. Domain rows and all four compatibility scope goldens +merged automatically as a union. No foreign client source/test file was manually +edited. This merge is part of the final R1/R2 refresh target, not fresh native +qualification. Bounded post-merge compilation awaits parent resource headroom. + + +## Final independent review closure + +Both independent implementation reviews are GO at frozen merged +`d69091b3328a1b626d9475643e8e97a8bf040b4a`; their full reports are retained in +`reviews/devin-code-review-r1.md` and `reviews/devin-code-review-r2.md`. Both +accepted findings are closed: documented comments in current native policy and +MCP target configuration use the approved comments-only grammar, and direct +project binding validation requires exact product ClientID `windsurf`. The +reviewers independently checked all three manual codec-union resolutions and +confirmed foreign client implementation directories are unchanged from the +reviewed static component baseline. + +Independent final-code full nativeconfig tests passed3.471s and the complete +preserved Windsurf client suite passed0.006s. The original public TLS Git/ZIP and +SDK lifecycle test source is unchanged by these narrow corrections and baseline +integration. Final composed provider/SDK/CLI retry attempts encountered actual +temporary-storage ENOSPC before meaningful test execution; these are recorded +resource failures, not passing reruns. The earlier author-run composed passes +and lint evidence above remain attributed to their original checkpoints. Root +requested no optional duplicate compilation after recurring capacity exhaustion. +Review GOs therefore combine full source/authority/union inspection, the exact +new regressions, the passing final codec/client tests and the prior composed +evidence, with transparent final execution limits. Hosted/normal-capacity gates +must still provide fresh execution evidence. + +No native project discovery, effective policy, trust, account, MCP initialization +or runtime proof is inferred. Materialized state remains prepared/installed and +project attestation remains refused. Root owns remote publication and the +separate stacked client PR; this checkpoint authorizes no main/release merge. diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/devin.go b/install/integrationctl/agentplugins/adapters/nativeconfig/devin.go new file mode 100644 index 000000000..0e4f5f617 --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/devin.go @@ -0,0 +1,31 @@ +package nativeconfig + +import "fmt" + +// Current Devin uses command/args/env without cwd, and url/headers with an +// explicit http/sse transport. It is separate from legacy Cascade serverUrl. +func projectDevinServer(server Server, placeholders Placeholders) (map[string]any, error) { + if server.CWD != "" { + return nil, fmt.Errorf("devin stdio MCP does not accept cwd; use the managed launcher") + } + transport := "" + if server.Type == "remote" { + switch server.RemoteTransport { + case "streamable-http": + transport = "http" + case "sse": + transport = "sse" + default: + return nil, fmt.Errorf("devin remote MCP requires streamable-http or sse transport") + } + server.RemoteTransport = "" + } + projected, err := projectBaseServer(CodecMCPServers, server, placeholders) + if err != nil { + return nil, err + } + if transport != "" { + projected["transport"] = transport + } + return projected, nil +} diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/devin_test.go b/install/integrationctl/agentplugins/adapters/nativeconfig/devin_test.go new file mode 100644 index 000000000..665dd0e9e --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/devin_test.go @@ -0,0 +1,103 @@ +package nativeconfig + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDevinCodecPreservesExactCurrentTransportAndForeignPolicy(t *testing.T) { + path := filepath.Join(t.TempDir(), "mcp_config.json") + original := `{"policy":"keep","mcpServers":{"disabled":{"url":"https://foreign.test","disabled":true}}}` + if err := os.WriteFile(path, []byte(original), 0600); err != nil { + t.Fatal(err) + } + kernel := New() + for _, transport := range []string{"streamable-http", "sse"} { + server := Server{Type: "remote", URL: "https://managed.test/mcp", Headers: map[string]string{"X-Token": "literal"}, RemoteTransport: transport} + desired, err := DesiredReceipt(path, CodecDevin, "owned", server, Placeholders{}) + if err != nil { + t.Fatal(err) + } + receipt, err := kernel.Apply(Request{Paths: Paths{JSON: path}, Codec: CodecDevin, Name: "owned", Action: ActionAdd, Server: server}) + if err != nil || receipt != desired { + t.Fatalf("current transport=%+v,%v", receipt, err) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var document map[string]any + if err := json.Unmarshal(body, &document); err != nil { + t.Fatal(err) + } + entries := document["mcpServers"].(map[string]any) + managed := entries["owned"].(map[string]any) + expected := "http" + if transport == "sse" { + expected = "sse" + } + if managed["url"] != server.URL || managed["transport"] != expected || managed["serverUrl"] != nil || entries["disabled"].(map[string]any)["disabled"] != true || document["policy"] != "keep" { + t.Fatalf("native Devin shape=%s", body) + } + if _, err := kernel.Apply(Request{Paths: Paths{JSON: path}, Codec: CodecDevin, Name: "owned", Action: ActionRemove, Owned: &receipt}); err != nil { + t.Fatal(err) + } + } +} + +func TestDevinCodecRejectsUnsupportedNativeIntent(t *testing.T) { + for _, server := range []Server{ + {Type: "stdio", Command: "node", CWD: "/tmp"}, + {Type: "stdio", Command: "node", RemoteTransport: "sse"}, + {Type: "remote", URL: "https://managed.test"}, + {Type: "remote", URL: "https://managed.test", RemoteTransport: "websocket"}, + {Type: "remote", URL: "https://managed.test", RemoteTransport: "sse", OpenCodeV2: &OpenCodeV2Options{}}, + } { + if _, err := DesiredReceipt(filepath.Join(t.TempDir(), "mcp_config.json"), CodecDevin, "x", server, Placeholders{}); err == nil { + t.Fatalf("unsupported native intent accepted: %+v", server) + } + } +} + +func TestDevinNativeCommentsPreserveForeignBytesWithoutRelaxingLegacy(t *testing.T) { + path := filepath.Join(t.TempDir(), "mcp_config.json") + const original = `{ // target foreign comment + "mcpServers": {"foreign": { "url": "https://foreign.test", "disabled": true }}, + "policy": "keep" /* target policy comment */ +}` + if err := os.WriteFile(path, []byte(original), 0600); err != nil { + t.Fatal(err) + } + kernel := New() + server := Server{Type: "remote", URL: "https://managed.test", RemoteTransport: "streamable-http"} + receipt, err := kernel.Apply(Request{Paths: Paths{JSON: path}, Codec: CodecDevin, Name: "owned", Action: ActionAdd, Server: server}) + if err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, foreign := range []string{"// target foreign comment", `"foreign": { "url": "https://foreign.test", "disabled": true }`, `"policy": "keep" /* target policy comment */`} { + if !strings.Contains(string(body), foreign) { + t.Fatalf("comment/foreign bytes lost: %s", body) + } + } + if _, err := kernel.Apply(Request{Paths: Paths{JSON: path}, Codec: CodecDevin, Name: "owned", Action: ActionRemove, Owned: &receipt}); err != nil { + t.Fatal(err) + } + for _, invalid := range []string{`{"mcpServers":{},}`, `{"mcpServers":{"x":{},"x":{}}}`} { + if _, err := parseCodecDocument([]byte(invalid), false, CodecDevin); err == nil { + t.Fatalf("invalid native grammar accepted: %s", invalid) + } + if _, err := StandardizeCommentedJSON([]byte(invalid)); err == nil { + t.Fatalf("invalid policy grammar accepted: %s", invalid) + } + } + if _, err := parseCodecDocument([]byte(original), false, CodecWindsurf); err == nil { + t.Fatal("legacy Cascade codec grammar broadened") + } +} diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/gemini_document.go b/install/integrationctl/agentplugins/adapters/nativeconfig/gemini_document.go index 93b2dd4b7..b17a07ceb 100644 --- a/install/integrationctl/agentplugins/adapters/nativeconfig/gemini_document.go +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/gemini_document.go @@ -6,23 +6,31 @@ import ( "github.com/tailscale/hujson" ) -// Gemini v0.65.0 parses JSON after stripping comments. Its settings.json is -// comment-aware, but trailing commas remain invalid. Keep the original AST so -// managed entry mutations preserve comments and unrelated configuration. +// Gemini v0.65.0 and Devin3000.11.205 accept native JSON comments. Keep +// the original AST so mutations preserve comments and unrelated configuration; +// neither this admission nor a read-only policy check accepts trailing commas. func parseCodecDocument(body []byte, jsonc bool, codec Codec) (*document, error) { if codec == CodecCodex { return parseCodexTOML(body, jsonc) } - if codec != CodecGemini || jsonc { + if (codec != CodecGemini && codec != CodecDevin) || jsonc { return parseDocument(body, jsonc) } + label := "Gemini" + if codec == CodecDevin { + label = "Devin" + } + return parseCommentsOnlyDocument(body, label) +} + +func parseCommentsOnlyDocument(body []byte, label string) (*document, error) { doc, err := parseDocument(body, true) if err != nil { return nil, err } for value := range doc.ast.All() { if !geminiWithoutTrailingComma(value) { - return nil, fmt.Errorf("%w: Gemini settings do not support trailing commas", ErrMalformed) + return nil, fmt.Errorf("%w: %s settings do not support trailing commas", ErrMalformed, label) } } doc.commentsOnly = true @@ -61,3 +69,13 @@ func geminiRemoveTrailingCommas(ast *hujson.Value) { } } } + +// StandardizeCommentedJSON normalizes native JSON-with-comments grammar for +// read-only policy inspection. Duplicate keys and trailing commas refuse; +// callers retain original file bytes for mutation and ownership checks. +func StandardizeCommentedJSON(body []byte) ([]byte, error) { + if _, err := parseCommentsOnlyDocument(body, "native config"); err != nil { + return nil, err + } + return hujson.Standardize(body) +} diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/project.go b/install/integrationctl/agentplugins/adapters/nativeconfig/project.go index cff48d060..7ac295b7d 100644 --- a/install/integrationctl/agentplugins/adapters/nativeconfig/project.go +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/project.go @@ -52,6 +52,9 @@ func projectServer(codec Codec, server Server, placeholders Placeholders) (map[s if server.OpenCodeV2 != nil { return nil, fmt.Errorf("%s does not accept OpenCode V2 options", codec) } + if codec == CodecDevin { + return projectDevinServer(server, placeholders) + } if codec == CodecCopilot { return projectCopilotServer(server, placeholders) } diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/types.go b/install/integrationctl/agentplugins/adapters/nativeconfig/types.go index b89397f5f..e8bb80937 100644 --- a/install/integrationctl/agentplugins/adapters/nativeconfig/types.go +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/types.go @@ -23,6 +23,7 @@ const ( CodecOpenCode Codec = "opencode-mcp" CodecOpenCodeV2 Codec = "opencode-v2-mcp" CodecWindsurf Codec = "windsurf-mcpServers" + CodecDevin Codec = "devin-mcpServers" CodecCline Codec = "cline-mcpServers" ) @@ -220,7 +221,7 @@ func validateRequest(req Request) error { } func supportedCodec(codec Codec) bool { - return codec == CodecMCPServers || codec == CodecCopilot || codec == CodecGemini || codec == CodecCodex || codec == CodecOpenCode || codec == CodecOpenCodeV2 || codec == CodecWindsurf || codec == CodecCline + return codec == CodecMCPServers || codec == CodecCopilot || codec == CodecGemini || codec == CodecCodex || codec == CodecOpenCode || codec == CodecOpenCodeV2 || codec == CodecWindsurf || codec == CodecDevin || codec == CodecCline } func codecCollectionKey(codec Codec) string { diff --git a/install/integrationctl/agentplugins/clients/windsurf/README.md b/install/integrationctl/agentplugins/clients/windsurf/README.md new file mode 100644 index 000000000..1442bd7c2 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/windsurf/README.md @@ -0,0 +1,46 @@ +# Windsurf and current Devin project delivery + +The user lane preserves legacy Cascade channel selection and its exact owned +MCP configuration. User skills remain prepared as before. Detection ordering, +Devin/Windsurf coexistence selection and cloud configuration are unchanged. + +The project lane requires the explicitly selected primary `devin`/`devin.exe` +with host-observed normalized exact version `3000.11.205`. Other/unknown versions +and a primary legacy Windsurf executable refuse before native effects. The +optional SDK `Request.ClientVersion` carries the embedding host's observation; +SDK Discover/Prepare and this adapter perform no version probe. This assertion +qualifies only the configuration format, not the executable's provenance. + +| Delivery | Selected project location | +| --- | --- | +| Managed package, state, receipts and persistent data | `.uap` | +| Skill directories | `.devin/skills/` | +| MCP members | `.devin/mcp_config.json` under `mcpServers` | + +Current Devin uses command/args/env for stdio without a native cwd field. The +existing trusted managed launcher carries the package/data/cwd contract. Remote +servers use url/headers and explicit transport http or sse through a separate +native codec; legacy Cascade serverUrl behavior is preserved. Active projection +is read-only and rejects stored launcher/env locators that disagree with the +selected package/data receipt instead of silently accepting old data authority. + +A current-only installation can legitimately have no legacy Cascade user root. +That absence is allowed only in the validated project SDK context; nonempty +roots and stricter adapter profile resolvers retain their existing validation. +Exact original user context, selected project/native root, artifact path, +binding and every native receipt remain checked. Sibling facts keep their +existing strict root/executable/binding requirements. + +UAP refuses project main-config legacy mcpServers requiring native migration +and same-name higher local MCP overrides. It preserves local overrides, disabled +foreign members, skills, hooks, imported roots, trust and permissions. The shared +native kernel and component transaction supply exact owned updates/removal and +skill rollback. Recorded exact removal is version independent and launches no +native command; Devin's default-local `mcp remove` is never used. + +Installed files report prepared activation with installation verification. +Generic activation attestation cannot claim native discovery. Effective aliases, +imports, local/user/cloud/team policy, trust, account state, Desktop UI, MCP +initialization and runtime remain unobserved. Genuine pinned Linux version/help +outputs and artifact hashes are recorded in the project evidence document; +composed inert UAP tests do not qualify native discovery or execution. diff --git a/install/integrationctl/agentplugins/clients/windsurf/activate.go b/install/integrationctl/agentplugins/clients/windsurf/activate.go index 558df001d..84b6e7183 100644 --- a/install/integrationctl/agentplugins/clients/windsurf/activate.go +++ b/install/integrationctl/agentplugins/clients/windsurf/activate.go @@ -14,6 +14,9 @@ import ( // AutomaticallyActivates reports that Activate will write owned Windsurf MCP // entries for this exact request. func (*Adapter) AutomaticallyActivates(_ clients.Env, request domain.ActivationRequest) bool { + if request.Plan.Scope == domain.ScopeProject { + return request.Plan.InstallIntent == domain.InstallIntentAutomatic && shared.OnlyNativeComponents(request.Plan.Components) + } return request.Plan.InstallIntent == domain.InstallIntentAutomatic && strings.TrimSpace(request.Client.ConfigRoot) != "" && len(WindsurfObjects(request.Delivery.NativeObjects)) > 0 @@ -22,12 +25,18 @@ func (*Adapter) AutomaticallyActivates(_ clients.Env, request domain.ActivationR // VerifierAvailable reports that an exact native configuration listing can be // observed. It does not need a client executable. func (*Adapter) VerifierAvailable(_ domain.DetectedClient, plan domain.DeliveryPlan, _ string) bool { + if plan.Scope == domain.ScopeProject { + return len(plan.Components) > 0 + } return len(plan.Components) > 0 } // Activate installs or verifies owned Windsurf MCP entries, or leaves a // channel-selection follow-up when automatic installation is unavailable. -func (*Adapter) Activate(ctx context.Context, env 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 request.Plan.Scope == domain.ScopeProject || request.Plan.TargetContext.Scope == domain.ScopeProject { + return adapter.activateDevinProject(ctx, env, request) + } if err := shared.ActivationIdentityMismatch(request); err != nil { return domain.ActivationOutcome{}, err } @@ -73,6 +82,9 @@ func (*Adapter) Activate(ctx context.Context, env clients.Env, request domain.Ac // Deactivate removes owned Windsurf MCP entries, or asks the operator to // finish a leftover channel first. func (*Adapter) Deactivate(ctx context.Context, env clients.Env, request domain.DeactivationRequest) (domain.DeactivationOutcome, error) { + if request.TargetContext.Scope == domain.ScopeProject { + return deactivateDevinProject(ctx, env, request) + } outcome := shared.StartedDeactivation() if len(WindsurfObjects(request.NativeObjects)) == 0 { return shared.RequireExternalUninstall(outcome, request.ExternalUninstalled, "remove the prepared package manually, then rerun remove with `--external-uninstalled`"), nil diff --git a/install/integrationctl/agentplugins/clients/windsurf/identity.go b/install/integrationctl/agentplugins/clients/windsurf/identity.go index 1b20afdf2..e81b0de39 100644 --- a/install/integrationctl/agentplugins/clients/windsurf/identity.go +++ b/install/integrationctl/agentplugins/clients/windsurf/identity.go @@ -11,9 +11,24 @@ var _ clients.RegistryInspector = (*Adapter)(nil) func (*Adapter) UsesNativeRegistryExecutable() bool { return false } -func (*Adapter) InspectNativeRegistry(ctx context.Context, env clients.Env, _ domain.DetectedClient, plan domain.DeliveryPlan, managed *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 || plan.TargetContext.Scope == domain.ScopeProject { + root, err := devinProjectPlanRoot(client, plan) + if err != nil { + return clients.RegistryIndeterminate, err + } + if managed != nil { + if err := validateDevinProjectBinding(root, plan.PhysicalArtifactID, *managed); err != nil { + return clients.RegistryIndeterminate, err + } + selected := *managed + selected.NativeObjects = devinNativeObjects(managed.NativeObjects) + managed = &selected + } + return devinComponents(root).Inspect(plan.Components, managed, env.NativeConfig) + } return InspectWindsurfRegistry(plan, managed, env.NativeConfig) } diff --git a/install/integrationctl/agentplugins/clients/windsurf/plan.go b/install/integrationctl/agentplugins/clients/windsurf/plan.go index 4ad5ef996..7db4558bf 100644 --- a/install/integrationctl/agentplugins/clients/windsurf/plan.go +++ b/install/integrationctl/agentplugins/clients/windsurf/plan.go @@ -18,6 +18,9 @@ var ( // takes MCP servers through its local configuration, while skills stay in the // prepared package and are never reported as activated. func (*Adapter) RefinePlan(_ context.Context, in clients.PlanInput, plan *domain.DeliveryPlan) error { + if in.TargetContext.Scope == domain.ScopeProject || plan.Scope == domain.ScopeProject { + return refineProjectPlan(in, plan) + } hasMCP := shared.ComponentKindPresent(plan.Components, domain.ComponentMCPServer) shared.PromoteNativeReady(plan, in.Client.ConfigRoot, hasMCP) action := "select exactly one legacy Windsurf channel and import the prepared MCP configuration manually" diff --git a/install/integrationctl/agentplugins/clients/windsurf/project.go b/install/integrationctl/agentplugins/clients/windsurf/project.go index 53260838c..dfe4054fa 100644 --- a/install/integrationctl/agentplugins/clients/windsurf/project.go +++ b/install/integrationctl/agentplugins/clients/windsurf/project.go @@ -2,6 +2,7 @@ package windsurf import ( "context" + "fmt" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" @@ -11,18 +12,50 @@ import ( var _ clients.Projector = (*Adapter)(nil) var _ clients.ActiveNativeProjector = (*Adapter)(nil) -func (*Adapter) ProjectActiveNative(_ context.Context, root string, _ 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 plan.Scope == domain.ScopeProject || plan.TargetContext.Scope == domain.ScopeProject { + if err := ctx.Err(); err != nil { + return nil, err + } + if plan.ClientID != domain.ClientWindsurf || root != plan.ActivePath { + return nil, fmt.Errorf("devin active package identity differs") + } + if err := plan.SelectedDelivery.ValidatePlan(plan, envelope.TreeDigest); err != nil { + return nil, err + } + if err := validateDevinProjectionRoot(root, plan); err != nil { + return nil, err + } + if err := validateDevinProjectStdio(root, envelope, plan, dataPath); err != nil { + return nil, err + } + return devinProjectObjects(root, envelope, plan) + } return BuildWindsurfNativeObjects(root, plan) } // Project copies the managed stdio launcher when needed, writes Windsurf MCP, // and records the objects it owns. -func (*Adapter) Project(_ context.Context, in clients.ProjectionInput) ([]domain.NativeObjectOwnership, error) { +func (*Adapter) Project(ctx context.Context, in clients.ProjectionInput) ([]domain.NativeObjectOwnership, error) { + if in.Plan.Scope == domain.ScopeProject || in.Plan.TargetContext.Scope == domain.ScopeProject { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := validateDevinProjectionRoot(in.StagingPath, in.Plan); err != nil { + return nil, err + } + if err := in.Plan.SelectedDelivery.ValidatePlan(in.Plan, in.Envelope.TreeDigest); err != nil { + return nil, err + } + } if err := shared.DeliverManagedStdio(in.DeliverLauncher(), in.StagingPath, in.Envelope, in.Plan); err != nil { return nil, err } if err := ProjectWindsurfMCP(in.StagingPath, in.Envelope, in.Plan, in.PluginDataPath); err != nil { return nil, err } + if in.Plan.Scope == domain.ScopeProject { + return devinProjectObjects(in.StagingPath, in.Envelope, in.Plan) + } return BuildWindsurfNativeObjects(in.StagingPath, in.Plan) } diff --git a/install/integrationctl/agentplugins/clients/windsurf/project_lifecycle.go b/install/integrationctl/agentplugins/clients/windsurf/project_lifecycle.go new file mode 100644 index 000000000..078d851bc --- /dev/null +++ b/install/integrationctl/agentplugins/clients/windsurf/project_lifecycle.go @@ -0,0 +1,113 @@ +package windsurf + +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.ActivationPreflighter = (*Adapter)(nil) + +func (*Adapter) PreflightActivation(env clients.Env, request domain.ActivationRequest) error { + if request.Plan.Scope != domain.ScopeProject && request.Plan.TargetContext.Scope != domain.ScopeProject { + return nil + } + root, err := devinProjectPlanRoot(request.Client, request.Plan) + if err != nil { + return err + } + if err := requireCurrentDevin(request.Client); err != nil { + return err + } + if request.Delivery.ActivePath != "" && request.Delivery.ActivePath != request.Plan.ActivePath { + return fmt.Errorf("devin delivered package differs from selected project") + } + if request.Plan.ClientID != request.Client.ClientID { + return fmt.Errorf("devin project plan client identity mismatch") + } + if request.Delivery.ClientID != "" { + if err := shared.ActivationIdentityMismatch(request); err != nil { + return err + } + } + if request.ActivationComplete { + return fmt.Errorf("devin project native discovery cannot be established by activation attestation") + } + if !shared.OnlyNativeComponents(request.Plan.Components) { + return fmt.Errorf("devin project delivery requires supported skills and MCP servers only") + } + layout := devinComponents(root) + if err := layout.ValidateObjects(devinNativeObjects(request.PreviousNativeObjects)); err != nil { + return err + } + if err := layout.ValidateObjects(devinNativeObjects(request.Delivery.NativeObjects)); err != nil { + return err + } + return checkDevinProjectPolicy(root, request.Plan.Components, env.NativeConfig) +} + +func (adapter *Adapter) activateDevinProject(ctx context.Context, env clients.Env, request domain.ActivationRequest) (domain.ActivationOutcome, error) { + if err := shared.ActivationIdentityMismatch(request); err != nil { + return domain.ActivationOutcome{}, err + } + if request.Delivery.ActivePath != request.Plan.ActivePath { + return domain.ActivationOutcome{}, fmt.Errorf("devin delivered package differs from selected project") + } + if err := adapter.PreflightActivation(env, request); err != nil { + return domain.ActivationOutcome{}, err + } + layout := devinComponents(request.Plan.NativeRegistryRoot) + outcome, err := shared.CompleteNativeConfigActivation(ctx, request, shared.NativeConfigActivation{ + Automatic: adapter.AutomaticallyActivates(env, request), + UnavailableAction: "select only supported Devin project skills and MCP servers", + RepairAction: "repair managed Devin project skills and MCP entries", + RetryAction: "retry managed Devin project component installation", + CompletedAction: DevinProjectAction, + Verify: func() error { + return layout.Verify(devinNativeObjects(request.Delivery.NativeObjects), false, env.NativeConfig) + }, + Activate: func(ctx context.Context, request domain.ActivationRequest) error { + servers, err := devinDesiredServers(request.Delivery.ActivePath, devinNativeObjects(request.Delivery.NativeObjects)) + if err != nil { + return &shared.NativeEffectError{Effect: domain.NativeEffectUnchanged, Err: err} + } + return (shared.ProjectComponentMutation{Layout: layout, ActivePath: request.Delivery.ActivePath, + Previous: devinNativeObjects(request.PreviousNativeObjects), Desired: devinNativeObjects(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, DevinProjectAction) + } + return outcome, err +} + +// Exact recorded removal has no current executable/version prerequisite and +// never invokes native CLI remove (whose default scope is local). +func deactivateDevinProject(ctx context.Context, env clients.Env, request domain.DeactivationRequest) (domain.DeactivationOutcome, error) { + outcome := shared.StartedDeactivation() + root, err := devinProjectRoot(request.Client, request.TargetContext, request.NativeRegistryRoot) + if err != nil { + return outcome, err + } + layout := devinComponents(root) + if err := layout.ValidateObjects(devinNativeObjects(request.NativeObjects)); err != nil { + return outcome, err + } + if err := env.NativeConfig.RequireFileIO(); err != nil { + return outcome, err + } + if !request.Confirmed { + outcome.UserActions = []string{"agentplugins will remove its exact owned Devin project skill directories and MCP members"} + return outcome, nil + } + err = (shared.ProjectComponentMutation{Layout: layout, Previous: devinNativeObjects(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/windsurf/project_native.go b/install/integrationctl/agentplugins/clients/windsurf/project_native.go new file mode 100644 index 000000000..a9015d265 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/windsurf/project_native.go @@ -0,0 +1,138 @@ +package windsurf + +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 ( + DevinProjectSkillKind = "devin_project_skill_directory" + DevinProjectMCPKind = "devin_project_mcp_server" +) + +func devinComponents(root string) shared.ProjectComponents { + return shared.ProjectComponents{ProjectRoot: filepath.Dir(root), SkillsRoot: filepath.Join(root, "skills"), + ConfigPaths: nativeconfig.Paths{JSON: filepath.Join(root, "mcp_config.json")}, Codec: nativeconfig.CodecDevin, + SkillKind: DevinProjectSkillKind, MCPKind: DevinProjectMCPKind} +} + +func devinProjectObjects(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, ".devin") { + return nil, fmt.Errorf("devin projection differs from selected project") + } + if err := validateDevinProjectionRoot(stagingRoot, plan); err != nil { + return nil, err + } + if err := pathpolicy.RequireContainedChild(target.ProjectRoot, stagingRoot); err != nil { + return nil, err + } + layout := devinComponents(plan.NativeRegistryRoot) + objects := []domain.NativeObjectOwnership{} + for _, component := range plan.Components { + if component.Support == domain.SupportUnsupported { + continue + } + object, err := devinProjectObject(layout, stagingRoot, envelope, component) + if err != nil { + return nil, err + } + if object != nil { + objects = append(objects, *object) + } + } + return objects, layout.ValidateObjects(objects) +} + +func devinProjectObject(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 Devin 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 := devinProjectedServer(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 Devin project component kind %q", component.Kind) + } +} + +func devinProjectedServer(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("devin package has no mcpServers object") + } + entry, ok := entries[name].(map[string]any) + if !ok { + return nativeconfig.Server{}, fmt.Errorf("devin projected MCP server %q is missing", name) + } + body, err := json.Marshal(entry) + if err != nil { + return nativeconfig.Server{}, err + } + server, err := decodeProjectedWindsurfServer(body) + if err != nil { + return server, err + } + server.StdioValuesResolved, server.CWDResolved = true, true + return server, nil +} + +func devinDesiredServers(activeRoot string, objects []domain.NativeObjectOwnership) (map[string]nativeconfig.Server, error) { + servers := map[string]nativeconfig.Server{} + for _, object := range objects { + if object.Kind != DevinProjectMCPKind { + continue + } + server, err := devinProjectedServer(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 devinNativeObjects(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/windsurf/project_policy.go b/install/integrationctl/agentplugins/clients/windsurf/project_policy.go new file mode 100644 index 000000000..febff6386 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/windsurf/project_policy.go @@ -0,0 +1,63 @@ +package windsurf + +import ( + "fmt" + "path/filepath" + + "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" +) + +// This observes only selected project files. It never initiates Devin's native +// migration, edits a local override, or interprets unknown imported policy. +func checkDevinProjectPolicy(root string, components []domain.ComponentDecision, kernel nativeconfig.Kernel) error { + for _, name := range []string{"config.json", "config.local.json"} { + document, err := readDevinProjectDocument(filepath.Join(root, name), kernel) + if err != nil { + return err + } + if raw, exists := document["mcpServers"]; exists { + entries, ok := raw.(map[string]any) + if !ok || len(entries) > 0 { + return fmt.Errorf("%w: Devin project main config has legacy mcpServers; migrate separately with the selected native client before automatic installation", nativeconfig.ErrNativeMigrationRequired) + } + } + } + document, err := readDevinProjectDocument(filepath.Join(root, "mcp_config.local.json"), kernel) + if err != nil { + return err + } + raw, exists := document["mcpServers"] + if !exists { + return nil + } + entries, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("devin local MCP override must contain an object mcpServers") + } + for _, component := range components { + if component.Kind != domain.ComponentMCPServer || component.Support == domain.SupportUnsupported { + continue + } + if _, shadowed := entries[component.Name]; shadowed { + return fmt.Errorf("devin local MCP override shadows selected server %q; resolve policy separately", component.Name) + } + } + return nil +} + +func readDevinProjectDocument(path string, kernel nativeconfig.Kernel) (map[string]any, error) { + file, err := kernel.ReadExactFile(path) + if err != nil { + return nil, err + } + if !file.Exists { + return map[string]any{}, nil + } + standard, err := nativeconfig.StandardizeCommentedJSON(file.Body) + if err != nil { + return nil, err + } + return shared.DecodeStrictJSONObject(standard) +} diff --git a/install/integrationctl/agentplugins/clients/windsurf/project_stdio_verify.go b/install/integrationctl/agentplugins/clients/windsurf/project_stdio_verify.go new file mode 100644 index 000000000..e646a9e59 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/windsurf/project_stdio_verify.go @@ -0,0 +1,45 @@ +package windsurf + +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 validateDevinProjectStdio(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 := devinProjectedServer(root, name) + if err != nil { + return err + } + launcher := filepath.Join(plan.ActivePath, filepath.FromSlash(managedstdio.RelativeDirectory), managedstdio.ExecutableName) + if !filepath.IsAbs(dataPath) || filepath.Clean(dataPath) != dataPath || server.Type != "stdio" || server.Command != launcher || server.CWD != "" || server.Env["PLUGIN_ROOT"] != plan.ActivePath || server.Env["PLUGIN_DATA"] != dataPath { + return fmt.Errorf("%w: Devin 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: Devin launcher package/data/cwd arguments differ from selected locators", domain.ErrProjectionRefreshRequired) + } + } + return nil +} diff --git a/install/integrationctl/agentplugins/clients/windsurf/project_validation.go b/install/integrationctl/agentplugins/clients/windsurf/project_validation.go new file mode 100644 index 000000000..73ae7de28 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/windsurf/project_validation.go @@ -0,0 +1,61 @@ +package windsurf + +import ( + "fmt" + "path/filepath" + "strings" + + "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/domain" +) + +var _ clients.ProfileBindingValidator = (*Adapter)(nil) + +const DevinProjectAction = "Devin project configuration is installed; open the selected project and inspect skills and MCP servers. Native discovery, imported aliases, enablement, workspace trust, permissions, cloud/team policy and MCP initialization remain unobserved" + +func requireCurrentDevin(client domain.DetectedClient) error { + name := strings.ToLower(filepath.Base(client.ExecutablePath)) + if client.ClientID != domain.ClientWindsurf || !filepath.IsAbs(client.ExecutablePath) || filepath.Clean(client.ExecutablePath) != client.ExecutablePath || (name != "devin" && name != "devin.exe") || client.Version != "3000.11.205" { + return fmt.Errorf("devin project configuration requires the explicitly selected primary devin executable with observed exact version 3000.11.205; legacy Windsurf, unknown or other versions require separate qualification") + } + return nil +} + +func (*Adapter) ValidateBindingProfile(root string, binding domain.ClientBinding) error { + if binding.Scope != string(domain.ScopeProject) { + return nil + } + return validateDevinProjectBinding(root, binding.PhysicalArtifact, binding) +} + +func validateDevinProjectionRoot(root string, plan domain.DeliveryPlan) error { + if _, err := devinProjectPlanRoot(domain.DetectedClient{ClientID: domain.ClientWindsurf, ConfigRoot: plan.TargetContext.UserConfigRoot}, plan); err != nil { + return err + } + if !filepath.IsAbs(root) || filepath.Clean(root) != root || strings.TrimSpace(root) != root { + return fmt.Errorf("devin package path must be clean and absolute") + } + return pathpolicy.RequireContainedChild(plan.TargetContext.ProjectRoot, root) +} + +func validateDevinProjectBinding(root, artifact string, binding domain.ClientBinding) error { + if !filepath.IsAbs(root) || filepath.Clean(root) != root || filepath.Base(root) != ".devin" { + return fmt.Errorf("devin project registry must be an exact absolute root") + } + if binding.ClientID != string(domain.ClientWindsurf) || binding.Scope != string(domain.ScopeProject) || binding.NativeProfileRoot != root || binding.PhysicalArtifact != artifact { + return fmt.Errorf("devin binding scope, artifact or native root differs from selected project") + } + if err := pathpolicy.ValidateLeafID(artifact); err != nil { + return err + } + project := filepath.Dir(root) + expected := filepath.Join(project, ".uap", "managed", "clients", "windsurf", artifact) + if binding.TargetLocator != expected { + return fmt.Errorf("devin binding package differs from selected project") + } + if err := pathpolicy.RequireContainedChild(project, expected); err != nil { + return err + } + return devinComponents(root).ValidateObjects(devinNativeObjects(binding.NativeObjects)) +} diff --git a/install/integrationctl/agentplugins/clients/windsurf/scope.go b/install/integrationctl/agentplugins/clients/windsurf/scope.go new file mode 100644 index 000000000..d5db70858 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/windsurf/scope.go @@ -0,0 +1,109 @@ +package windsurf + +import ( + "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" + "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) +) + +func (*Adapter) NativeRegistry(in clients.PlanInput) (string, string) { + if in.TargetContext.Scope == domain.ScopeProject { + return filepath.Join(in.TargetContext.ProjectRoot, ".devin"), "" + } + 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 _, err := devinProjectRoot(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 + } + root, err := devinProjectRoot(in.Client, in.TargetContext, plan.NativeRegistryRoot) + if err != nil { + return err + } + if err := requireCurrentDevin(in.Client); err != nil { + return err + } + return checkDevinProjectPolicy(root, plan.Components, nativeconfig.New()) +} + +func devinProjectRoot(client domain.DetectedClient, target domain.TargetContext, planned string) (string, error) { + if client.ClientID != domain.ClientWindsurf { + return "", fmt.Errorf("devin selected client identity differs") + } + validated, err := domain.NewTargetContext(target.Scope, target.ProjectRoot, target.UserConfigRoot) + if err != nil { + return "", err + } + if validated.Scope != domain.ScopeProject { + return "", fmt.Errorf("devin workspace configuration requires project scope") + } + if validated.UserConfigRoot != client.ConfigRoot { + return "", fmt.Errorf("devin detected user profile differs from selected target") + } + root := filepath.Join(validated.ProjectRoot, ".devin") + if planned != "" && planned != root { + return "", fmt.Errorf("devin native registry differs from selected project") + } + return root, pathpolicy.RequireContainedChild(validated.ProjectRoot, root) +} + +func devinProjectPlanRoot(client domain.DetectedClient, plan domain.DeliveryPlan) (string, error) { + if plan.ClientID != domain.ClientWindsurf || plan.Scope != domain.ScopeProject || plan.TargetContext.Scope != domain.ScopeProject || plan.NativeRegistryRoot == "" || plan.NativeRegistryExecutable != "" { + return "", fmt.Errorf("devin project lifecycle requires matching project scope") + } + root, err := devinProjectRoot(client, plan.TargetContext, plan.NativeRegistryRoot) + if err != nil { + return "", err + } + if err := pathpolicy.ValidateLeafID(plan.PhysicalArtifactID); err != nil { + return "", err + } + expected := filepath.Join(plan.TargetContext.ProjectRoot, ".uap", "managed", "clients", "windsurf", plan.PhysicalArtifactID) + if plan.ActivePath != expected { + return "", fmt.Errorf("devin managed package differs from selected project") + } + return root, pathpolicy.RequireContainedChild(plan.TargetContext.ProjectRoot, expected) +} + +func refineProjectPlan(in clients.PlanInput, plan *domain.DeliveryPlan) error { + if err := requireCurrentDevin(in.Client); err != nil { + return err + } + root, err := devinProjectPlanRoot(in.Client, *plan) + if err != nil { + return err + } + if err := checkDevinProjectPolicy(root, plan.Components, nativeconfig.New()); err != nil { + return err + } + plan.NativeRegistryRoot, plan.NativeRegistryExecutable = root, "" + if plan.Status == domain.PlanUnsupported || !shared.OnlyNativeComponents(plan.Components) { + return nil + } + plan.Status, plan.Activation, plan.Verification = domain.PlanReady, domain.ActivationPrepared, domain.VerificationPackageValid + plan.UserActions = []string{DevinProjectAction} + plan.Warnings = shared.AppendUnique(plan.Warnings, "devin_project_effective_policy_unobserved") + return nil +} diff --git a/install/integrationctl/agentplugins/domain/clients.go b/install/integrationctl/agentplugins/domain/clients.go index 0f1fa054b..217f2a502 100644 --- a/install/integrationctl/agentplugins/domain/clients.go +++ b/install/integrationctl/agentplugins/domain/clients.go @@ -145,11 +145,11 @@ var clientDefinitions = []ClientDefinition{ InstallIntents: []InstallIntent{InstallIntentAutomatic}, LifecycleKind: LifecycleNativeConfig, }), ActivationAutomatic), - clientDefinition(ClientWindsurf, "Windsurf / Devin", "windsurf", "prepared", "prepared", false, PackagePrepared, SupportPrepared, SupportPrepared, SupportPrepared, SupportUnsupported, SupportPrepared, ClientTraits{ + withProjectScope(clientDefinition(ClientWindsurf, "Windsurf / Devin", "windsurf", "prepared", "prepared", false, PackagePrepared, SupportPrepared, SupportPrepared, SupportPrepared, SupportUnsupported, SupportPrepared, ClientTraits{ InstallIntents: []InstallIntent{InstallIntentAutomatic}, LifecycleKind: LifecycleNativeConfig, UsesManagedStdioLauncher: true, - }), + }), LifecycleNativeConfig), clientDefinition(ClientGrok, "Grok Build", "grok", "managed", "native", false, PackageNative, SupportNative, SupportNative, SupportUnsupported, SupportUnsupported, SupportUnsupported, ClientTraits{ InstallIntents: []InstallIntent{InstallIntentAutomatic}, LifecycleKind: LifecycleCLIRegistry, diff --git a/install/integrationctl/agentplugins/installer/project_devin_admission_test.go b/install/integrationctl/agentplugins/installer/project_devin_admission_test.go new file mode 100644 index 000000000..09d85138b --- /dev/null +++ b/install/integrationctl/agentplugins/installer/project_devin_admission_test.go @@ -0,0 +1,65 @@ +package installer_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/installer" +) + +func devinSDKRemotePackage(t *testing.T, root string) { + t.Helper() + devinSDKWrite(t, filepath.Join(root, "plugin.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"sdk-devin","version":"1.0.0"}`) + devinSDKWrite(t, filepath.Join(root, "skills", "docs", "SKILL.md"), "---\nname: docs\ndescription: Inert admission fixture\n---\nText only.\n") + devinSDKWrite(t, filepath.Join(root, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"docs":{"type":"sse","url":"https://managed.test/events"}}}`) +} + +func TestProjectDevinPublicSDKAbsentLegacyProfileAndVersionAdmission(t *testing.T) { + for _, evidence := range []string{"3000.11.205", "", "3000.3.0", "3000.11.205-alpha", "3000.11.205.1", "devin 3000.11.205 (6dea39440ae3)", "other-primary"} { + t.Run("version="+evidence, func(t *testing.T) { + base := devinSDKTemp(t) + pkg := filepath.Join(base, "package") + devinSDKRemotePackage(t, pkg) + f := newDevinSDKFixture(t, filepath.Join(base, "A"), "", pkg, "") + f.request.ClientVersion = evidence + if evidence == "other-primary" { + f.request.ClientVersion = "3000.11.205" + f.request.ClientExecutable = filepath.Join(base, "windsurf") + } + if evidence == "3000.11.205" { + f.apply(t, installer.OpInstall, "current-only-no-profile") + native := filepath.Join(f.project, ".devin", "mcp_config.json") + if body := devinSDKRead(t, native); !strings.Contains(body, `"transport"`) || !strings.Contains(body, `"sse"`) { + t.Fatalf("SSE transport lost: %s", body) + } + f.request.ClientVersion = "" + f.request.ClientExecutable = "" + f.apply(t, installer.OpRemove, "remove-no-current-harness") + return + } + if _, err := f.engine.Prepare(t.Context(), f.request); err == nil || !strings.Contains(err.Error(), "explicitly selected primary") { + t.Fatalf("unqualified host evidence admitted: %v", err) + } + if _, err := os.Lstat(filepath.Join(f.project, ".devin")); !os.IsNotExist(err) { + t.Fatalf("version refusal changed native files: %v", err) + } + }) + } +} + +func TestProjectDevinPortableManifestCannotSupplyVersionEvidence(t *testing.T) { + base := devinSDKTemp(t) + pkg := filepath.Join(base, "package") + devinSDKRemotePackage(t, pkg) + devinSDKWrite(t, filepath.Join(pkg, "plugin.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"sdk-devin","version":"1.0.0","clientVersion":"3000.11.205"}`) + f := newDevinSDKFixture(t, filepath.Join(base, "A"), "", pkg, "") + f.request.ClientVersion = "" + if _, err := f.engine.Prepare(t.Context(), f.request); err == nil { + t.Fatal("portable manifest supplied operational host evidence") + } + if _, err := os.Lstat(filepath.Join(f.project, ".devin")); !os.IsNotExist(err) { + t.Fatalf("injected source changed native files: %v", err) + } +} diff --git a/install/integrationctl/agentplugins/installer/project_devin_fixture_test.go b/install/integrationctl/agentplugins/installer/project_devin_fixture_test.go new file mode 100644 index 000000000..47c958819 --- /dev/null +++ b/install/integrationctl/agentplugins/installer/project_devin_fixture_test.go @@ -0,0 +1,132 @@ +package installer_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/statev2" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/windsurf" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/installer" +) + +type devinSDKFixture struct { + project, user, pkg, data string + engine *installer.Engine + request installer.Request +} + +func devinSDKWrite(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } +} + +func devinSDKRead(t *testing.T, path string) string { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(body) +} + +func newDevinSDKFixture(t *testing.T, project, user, pkg, helper string) *devinSDKFixture { + t.Helper() + if err := os.MkdirAll(project, 0700); err != nil { + t.Fatal(err) + } + if helper == "" { + var err error + helper, err = os.Executable() + if err != nil { + t.Fatal(err) + } + } + registry, err := clients.NewRegistry(windsurf.New()) + if err != nil { + t.Fatal(err) + } + engine, err := installer.New(installer.Config{ProjectRoot: project, StateRoot: filepath.Join(project, ".uap"), Registry: registry, HelperExecutable: helper, TrustedLocalPackages: true}) + if err != nil { + t.Fatal(err) + } + return &devinSDKFixture{project: project, user: user, pkg: pkg, engine: engine, request: installer.Request{Scope: domain.ScopeProject, ProjectRoot: project, Operation: installer.OpInstall, PackageRoot: pkg, ClientID: "windsurf", ClientConfigRoot: user, ClientExecutable: filepath.Join(filepath.Dir(project), "devin"), ClientVersion: "3000.11.205", + InstallationID: "00000000-0000-4000-8000-000000002331", OperationID: "devin-project-sdk", RequiredComponents: []string{"skills", "mcp"}}} +} + +func (f *devinSDKFixture) apply(t *testing.T, op installer.Operation, label string) installer.Result { + t.Helper() + f.request.Operation, f.request.OperationID = op, label + handle, err := f.engine.Prepare(t.Context(), f.request) + if err != nil { + t.Fatalf("prepare Devin SDK %s: %v", label, err) + } + defer func() { + if err := handle.Close(); err != nil { + t.Error(err) + } + }() + if op != installer.OpRemove && len(handle.Plan().Delivery.Components) != 2 { + t.Fatalf("Devin SDK omitted supported fixture component: %+v", handle.Plan()) + } + for _, component := range handle.Plan().Delivery.Components { + if component.Support == "unsupported" { + t.Fatalf("Devin SDK unsupported fixture: %+v", handle.Plan()) + } + } + result, err := f.engine.Apply(t.Context(), handle, installer.Decision{Confirmed: true}) + if err != nil || result.Outcome != installer.OutcomeCompleted && result.Outcome != installer.OutcomeUnchanged { + t.Fatalf("Devin SDK %s=%+v,%v", label, result, err) + } + if result.Binding.DataRoot != "" { + if f.data != "" && f.data != result.Binding.DataRoot { + t.Fatal("SDK changed selected persistent data authority") + } + f.data = result.Binding.DataRoot + } + return result +} + +func (f *devinSDKFixture) binding(t *testing.T) domain.ClientBinding { + t.Helper() + state, err := (statev2.Store{Path: filepath.Join(f.project, ".uap", "state-v2.json"), ProjectRoot: f.project}).Load() + if err != nil || len(state.Installations) != 1 { + t.Fatalf("Devin SDK state=%+v,%v", state, err) + } + for _, binding := range state.Installations[0].Clients { + return binding + } + t.Fatal("Devin SDK binding absent") + return domain.ClientBinding{} +} + +func devinSDKConfig(path string) (map[string]any, error) { + body, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var document struct { + Servers map[string]any `json:"mcpServers"` + } + if err := json.Unmarshal(body, &document); err != nil { + return nil, err + } + return document.Servers, nil +} + +func devinSDKTemp(t *testing.T) string { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return root +} diff --git a/install/integrationctl/agentplugins/installer/project_devin_lifecycle_test.go b/install/integrationctl/agentplugins/installer/project_devin_lifecycle_test.go new file mode 100644 index 000000000..51284e2bb --- /dev/null +++ b/install/integrationctl/agentplugins/installer/project_devin_lifecycle_test.go @@ -0,0 +1,123 @@ +package installer_test + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/installer" +) + +// Public SDK composition with real filesystem/state and inert stdio helper +// bytes. No runner, native client, launcher or MCP process is executed. +func TestProjectDevinPublicSDKStdioDataRepairRestartAndRemove(t *testing.T) { + base := devinSDKTemp(t) + user, pkg, helper := filepath.Join(base, "user", ".devin"), filepath.Join(base, "package"), filepath.Join(base, "inert-helper") + devinSDKWrite(t, helper, "inert helper bytes; never executed") + if err := os.Chmod(helper, 0700); err != nil { + t.Fatal(err) + } + const original = "---\nname: docs\ndescription: Inert Devin SDK fixture\n---\nOriginal pinned bytes.\n" + devinSDKWrite(t, filepath.Join(pkg, "plugin.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"sdk-devin","version":"1.0.0"}`) + devinSDKWrite(t, filepath.Join(pkg, "skills", "docs", "SKILL.md"), original) + devinSDKWrite(t, filepath.Join(pkg, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"docs":{"type":"stdio","command":"go","args":["${PLUGIN_ROOT}/server.js","${PLUGIN_DATA}"],"cwd":"${PLUGIN_DATA}"}}}`) + devinSDKWrite(t, filepath.Join(pkg, "server.js"), "// Inert fixture; never executed.\n") + const foreign = `{"mcpServers":{"foreign":{"url":"https://foreign.test","disabled":true}},"policy":"keep"}` + devinSDKWrite(t, filepath.Join(user, "mcp_config.json"), foreign) + a, b := newDevinSDKFixture(t, filepath.Join(base, "A"), user, pkg, helper), newDevinSDKFixture(t, filepath.Join(base, "B"), user, pkg, helper) + for _, f := range []*devinSDKFixture{a, b} { + devinSDKWrite(t, filepath.Join(f.project, ".devin", "mcp_config.json"), foreign) + f.apply(t, installer.OpInstall, "install") + binding := f.binding(t) + if binding.Activation != domain.ActivationPrepared || binding.Verification != domain.VerificationInstalled || f.data == "" { + t.Fatalf("Devin SDK overclaimed/omitted data: %+v", binding) + } + devinSDKWrite(t, filepath.Join(f.data, "keep.txt"), f.project) + devinSDKCheckStdio(t, f) + } + if a.data == b.data { + t.Fatal("project stdio data receipts alias") + } + beforeB := devinSDKRead(t, filepath.Join(b.project, ".uap", "state-v2.json")) + bBinding := b.binding(t) + devinSDKRepairUpdateRemove(t, a, pkg, helper, original) + if beforeB != devinSDKRead(t, filepath.Join(b.project, ".uap", "state-v2.json")) || !reflect.DeepEqual(bBinding, b.binding(t)) { + t.Fatal("Devin SDK changed project B ownership") + } + devinSDKCheckStdio(t, b) + if devinSDKRead(t, filepath.Join(user, "mcp_config.json")) != foreign { + t.Fatal("Devin SDK changed global native configuration") + } + for _, f := range []*devinSDKFixture{a, b} { + if devinSDKRead(t, filepath.Join(f.data, "keep.txt")) != f.project { + t.Fatal("SDK removed scoped persistent data") + } + } +} + +func devinSDKCheckStdio(t *testing.T, f *devinSDKFixture) { + t.Helper() + binding := f.binding(t) + servers, err := devinSDKConfig(filepath.Join(f.project, ".devin", "mcp_config.json")) + if err != nil { + t.Fatal(err) + } + entry := servers["docs"].(map[string]any) + env := entry["env"].(map[string]any) + args := entry["args"].([]any) + if env["PLUGIN_ROOT"] != binding.TargetLocator || env["PLUGIN_DATA"] != f.data || len(args) < 7 || args[1] != binding.TargetLocator || args[2] != f.data || args[3] != f.data || args[4] != "data" || entry["cwd"] != nil || entry["type"] != nil { + t.Fatalf("SDK stdio binding = %+v", entry) + } + if devinSDKRead(t, entry["command"].(string)) != "inert helper bytes; never executed" { + t.Fatal("SDK launcher did not come from injected trusted helper") + } +} + +func devinSDKRepairUpdateRemove(t *testing.T, f *devinSDKFixture, pkg, helper, original string) { + t.Helper() + binding := f.binding(t) + nativeSkill := filepath.Join(f.project, ".devin", "skills", "docs", "SKILL.md") + if got := f.apply(t, installer.OpUpdate, "unchanged"); got.Mutated { + t.Fatalf("unchanged=%+v", got) + } + pinned := t.TempDir() + for _, name := range []string{"plugin.json", "mcp.json", "server.js"} { + devinSDKWrite(t, filepath.Join(pinned, name), devinSDKRead(t, filepath.Join(pkg, name))) + } + devinSDKWrite(t, filepath.Join(pinned, "skills", "docs", "SKILL.md"), original) + devinSDKWrite(t, filepath.Join(pkg, "skills", "docs", "SKILL.md"), "---\nname: docs\ndescription: Advanced Devin SDK fixture\n---\nUpdated bytes.\n") + if err := os.RemoveAll(filepath.Dir(nativeSkill)); err != nil { + t.Fatal(err) + } + f.request.PackageRoot, f.request.SourceRoot = pinned, pkg + f.apply(t, installer.OpRepair, "repair-installed-pin") + if devinSDKRead(t, nativeSkill) != original { + t.Fatal("SDK repair followed moving source") + } + fresh := newDevinSDKFixture(t, f.project, f.user, pkg, helper) + fresh.data = f.data + *f = *fresh + f.apply(t, installer.OpUpdate, "update-after-restart") + if devinSDKRead(t, nativeSkill) == original { + t.Fatal("SDK update failed to advance selected project") + } + devinSDKCheckStdio(t, f) + view, err := f.engine.Inspect(t.Context()) + if err != nil || view.Recovery.Required { + t.Fatalf("Devin SDK status=%+v,%v", view, err) + } + f.request.ClientVersion, f.request.ClientExecutable = "", "" + f.apply(t, installer.OpRemove, "remove-without-current-version") + if _, err := os.Stat(binding.TargetLocator); !os.IsNotExist(err) { + t.Fatalf("SDK managed artifact survived: %v", err) + } + if _, err := os.Stat(nativeSkill); !os.IsNotExist(err) { + t.Fatalf("SDK native skill survived: %v", err) + } + servers, err := devinSDKConfig(filepath.Join(f.project, ".devin", "mcp_config.json")) + if err != nil || servers["docs"] != nil || servers["foreign"] == nil { + t.Fatalf("SDK remove ownership=%+v,%v", servers, err) + } +} 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 6412776b4..19ce2e50f 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json @@ -551,7 +551,8 @@ }, "PackageMode": "prepared_package", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "prepared" }, 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 b276273a0..1f7e1c526 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 @@ -836,7 +836,8 @@ }, "PackageMode": "prepared_package", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "prepared" }, 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 777369011..d85cbe273 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json @@ -499,7 +499,8 @@ }, "PackageMode": "prepared_package", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "prepared" }, 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 1fef4910e..1280980ec 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json @@ -594,7 +594,8 @@ }, "PackageMode": "prepared_package", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "prepared" }, diff --git a/install/integrationctl/agentplugins/providers/project_devin_boundaries_test.go b/install/integrationctl/agentplugins/providers/project_devin_boundaries_test.go new file mode 100644 index 000000000..2d441e25b --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_devin_boundaries_test.go @@ -0,0 +1,110 @@ +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/windsurf" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +func TestProjectDevinAttestationCannotUpgradeConfiguration(t *testing.T) { + fixture := newDevinProjectFixture(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 := windsurf.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 TestProjectDevinForeignDisabledMalformedAndWriterFailurePreserveNativeFiles(t *testing.T) { + for _, failure := range []string{"disabled-collision", "malformed", "skill-collision", "writer-lock"} { + t.Run(failure, func(t *testing.T) { + fixture := newDevinProjectFixture(t, false) + root := fixture.request.Plan.NativeRegistryRoot + kernel := nativeconfig.New() + switch failure { + case "disabled-collision": + projectGeminiWrite(t, filepath.Join(root, "mcp_config.json"), `{"mcpServers":{"docs":{"url":"https://foreign.test/keep","disabled":true}}}`) + case "malformed": + projectGeminiWrite(t, filepath.Join(root, "mcp_config.json"), `{"mcpServers":[]}`) + case "writer-lock": + kernel = nativeconfig.NewWithLockAcquirer(func(nativeconfig.Paths, nativeconfig.Codec) (func() error, error) { + return nil, errors.New("inert Devin writer refusal") + }) + case "skill-collision": + projectGeminiWrite(t, filepath.Join(root, "skills", "docs", "SKILL.md"), "foreign same-name skill\n") + } + 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 TestProjectDevinScopeRootAndBindingAuthorityRefuse(t *testing.T) { + for _, mismatch := range []string{"user-registry", "user-context", "other-user", "omitted-user", "copied-project"} { + t.Run(mismatch, func(t *testing.T) { + fixture := newDevinProjectFixture(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 "omitted-user": + fixture.request.Plan.TargetContext.UserConfigRoot = "" + 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, ".devin") + } + 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 TestProjectDevinContainedNativeRootAliasRefuses(t *testing.T) { + fixture := newDevinProjectFixture(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_devin_failure_test.go b/install/integrationctl/agentplugins/providers/project_devin_failure_test.go new file mode 100644 index 000000000..b373b450d --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_devin_failure_test.go @@ -0,0 +1,72 @@ +package providers_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/atomicfile" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +type devinFailAfterVisibleWrite struct{ fail bool } + +func (*devinFailAfterVisibleWrite) ReadNoFollow(path string) ([]byte, os.FileMode, bool, error) { + snapshot, err := nativeconfig.New().ReadExactFile(path) + return snapshot.Body, snapshot.Mode, snapshot.Exists, err +} +func (files *devinFailAfterVisibleWrite) WriteAtomic(path string, body []byte, mode os.FileMode) error { + if err := atomicfile.Write(path, body, mode); err != nil { + return err + } + if files.fail { + files.fail = false + return errors.New("inert failure after visible Devin MCP replacement") + } + return nil +} +func (*devinFailAfterVisibleWrite) RemoveNoFollow(path string) error { return os.Remove(path) } + +func TestProjectDevinVisibleMCPWriteFailureRestoresNativeSkillAndBytes(t *testing.T) { + f := newDevinProjectFixture(t, false) + out, err := f.activator(nativeconfig.New()).Activate(context.Background(), f.request) + devinAssertPrepared(t, out, err) + root := f.request.Plan.NativeRegistryRoot + before, user, other := projectGeminiInventory(t, root), projectGeminiInventory(t, f.user), projectGeminiInventory(t, f.other) + f.request.PreviousNativeObjects = out.NativeObjects + projectGeminiWrite(t, filepath.Join(f.source, "skills", "docs", "SKILL.md"), "---\nname: docs\ndescription: Inert new skill\n---\nNew bytes.\n") + projectGeminiWrite(t, filepath.Join(f.source, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"docs":{"type":"streamable-http","url":"https://managed.test/new"}}}`) + f.stage(t, "fault-update") + kernel := nativeconfig.NewWithFileIO(&devinFailAfterVisibleWrite{fail: true}) + failed, err := f.activator(kernel).Activate(context.Background(), f.request) + if err == nil || failed.NativeEffect == domain.NativeEffectCommitted { + t.Fatalf("visible failure misreported: %+v,%v", failed, err) + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, root)) || !reflect.DeepEqual(user, projectGeminiInventory(t, f.user)) || !reflect.DeepEqual(other, projectGeminiInventory(t, f.other)) { + t.Fatal("visible write failure failed exact rollback or affected another scope") + } +} + +func TestProjectDevinCommittedCleanupKeepsNewNativeReceipts(t *testing.T) { + f := newDevinProjectFixture(t, false) + kernel := nativeconfig.NewWithLockAcquirer(func(nativeconfig.Paths, nativeconfig.Codec) (func() error, error) { + return func() error { return errors.New("inert committed unlock failure") }, nil + }) + out, err := f.activator(kernel).Activate(context.Background(), f.request) + devinAssertPrepared(t, out, err) + if out.NativeEffect != domain.NativeEffectCommitted || !reflect.DeepEqual(out.NativeObjects, f.request.Delivery.NativeObjects) || !strings.Contains(strings.Join(out.UserActions, " "), "lock cleanup degraded") { + t.Fatalf("committed cleanup lost authority: %+v", out) + } + f.request.PreviousNativeObjects = out.NativeObjects + f.request.VerifyOnly = true + verified, err := f.activator(nativeconfig.New()).Activate(context.Background(), f.request) + devinAssertPrepared(t, verified, err) + if !reflect.DeepEqual(out.NativeObjects, verified.NativeObjects) { + t.Fatal("committed cleanup receipts were not reusable") + } +} diff --git a/install/integrationctl/agentplugins/providers/project_devin_fixture_test.go b/install/integrationctl/agentplugins/providers/project_devin_fixture_test.go new file mode 100644 index 000000000..560e2ce6e --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_devin_fixture_test.go @@ -0,0 +1,119 @@ +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/windsurf" + "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 devinProjectFixture struct { + request domain.ActivationRequest + user, other, source string + envelope domain.PackageEnvelope + registry *clients.Registry + stager providers.Stager +} + +func newDevinProjectFixture(t *testing.T, stdio bool) *devinProjectFixture { + t.Helper() + base := projectGeminiTemp(t) + project, user, other := filepath.Join(base, "A"), filepath.Join(base, "user", ".devin"), filepath.Join(base, "B") + for _, root := range []string{user, filepath.Join(project, ".devin"), filepath.Join(other, ".devin")} { + projectGeminiWrite(t, filepath.Join(root, "mcp_config.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(windsurf.New()) + if err != nil { + t.Fatal(err) + } + fixture := &devinProjectFixture{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.ClientWindsurf, ConfigRoot: user, ExecutablePath: filepath.Join(base, "devin"), Version: "3000.11.205", 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") + 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 = devinFixtureLauncher(t, base) + } + projectGeminiWrite(t, filepath.Join(fixture.source, "mcp.json"), mcp) + fixture.stage(t, "initial") + return fixture +} + +func devinFixtureLauncher(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 *devinProjectFixture) 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("Devin 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 *devinProjectFixture) activator(kernel nativeconfig.Kernel) providers.Activator { + return providers.Activator{Registry: fixture.registry, NativeConfig: &kernel} +} + +func (fixture *devinProjectFixture) 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_devin_lifecycle_test.go b/install/integrationctl/agentplugins/providers/project_devin_lifecycle_test.go new file mode 100644 index 000000000..3df734062 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_devin_lifecycle_test.go @@ -0,0 +1,94 @@ +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/windsurf" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +func devinAssertPrepared(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("Devin configured state = %+v, %v", outcome, err) + } +} + +// Inert production loader/planner/renderer/stager/provider evidence; this does +// not execute Devin, the injected launcher, a model or an MCP server. +func TestProjectDevinComposedLifecycleAndScopeIsolation(t *testing.T) { + fixture := newDevinProjectFixture(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) + devinAssertPrepared(t, outcome, err) + root := fixture.request.Plan.NativeRegistryRoot + 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) != 2 || !reflect.DeepEqual(activeBefore, projectGeminiInventory(t, fixture.request.Plan.ActivePath)) { + t.Fatalf("read-only projection = %+v, %v", objects, err) + } + binding := domain.ClientBinding{ClientID: "windsurf", Scope: "project", PhysicalArtifact: fixture.request.Plan.PhysicalArtifactID, + TargetLocator: fixture.request.Plan.ActivePath, NativeProfileRoot: root, NativeObjects: outcome.NativeObjects} + if err := windsurf.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) + devinAssertPrepared(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("Devin project lifecycle changed user or project B") + } + projectGeminiAssert(t, filepath.Join(root, "hooks.json"), "foreign hooks\n") +} + +func TestProjectDevinUpdateRepairAndRemovedComponent(t *testing.T) { + fixture := newDevinProjectFixture(t, false) + activator := fixture.activator(nativeconfig.New()) + outcome, err := activator.Activate(context.Background(), fixture.request) + devinAssertPrepared(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) + devinAssertPrepared(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) + devinAssertPrepared(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) + devinAssertPrepared(t, outcome, err) + config, err := os.ReadFile(filepath.Join(fixture.request.Plan.NativeRegistryRoot, "mcp_config.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_devin_policy_test.go b/install/integrationctl/agentplugins/providers/project_devin_policy_test.go new file mode 100644 index 000000000..2ba4d5c3a --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_devin_policy_test.go @@ -0,0 +1,119 @@ +package providers_test + +import ( + "context" + "fmt" + "path/filepath" + "reflect" + "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/windsurf" +) + +func TestProjectDevinMigrationAndLocalShadowingRefuseWithoutEffects(t *testing.T) { + for _, name := range []string{"config.json", "config.local.json", "mcp_config.local.json"} { + t.Run(name, func(t *testing.T) { + f := newDevinProjectFixture(t, false) + root := f.request.Plan.NativeRegistryRoot + projectGeminiWrite(t, filepath.Join(root, name), `{"mcpServers":{"docs":{"url":"https://foreign.test","disabled":true}},"policy":"keep"}`) + before, user, other := projectGeminiInventory(t, root), projectGeminiInventory(t, f.user), projectGeminiInventory(t, f.other) + input := clients.PlanInput{Client: f.request.Client, TargetContext: f.request.Plan.TargetContext, Envelope: f.envelope} + plan := f.request.Plan + if err := windsurf.New().CheckPlanPrecondition(input, &plan); err == nil { + t.Fatal("migration/shadowing passed planning") + } + if _, err := f.activator(nativeconfig.New()).Activate(context.Background(), f.request); err == nil { + t.Fatal("changed local policy passed activation") + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, root)) || !reflect.DeepEqual(user, projectGeminiInventory(t, f.user)) || !reflect.DeepEqual(other, projectGeminiInventory(t, f.other)) { + t.Fatal("policy refusal changed scope bytes") + } + }) + } +} + +func TestProjectDevinExactArtifactAndDeliveryCannotBeRebound(t *testing.T) { + for _, change := range []string{"existing-package", "artifact", "empty-delivery", "other-delivery"} { + t.Run(change, func(t *testing.T) { + f := newDevinProjectFixture(t, false) + root := f.request.Plan.NativeRegistryRoot + switch change { + case "existing-package": + f.request.Plan.ActivePath = f.source + f.request.Delivery.ActivePath = f.source + case "artifact": + f.request.Plan.PhysicalArtifactID = "other-artifact" + case "empty-delivery": + f.request.Delivery.ActivePath = "" + case "other-delivery": + f.request.Delivery.ActivePath = f.source + } + before := projectGeminiInventory(t, root) + if _, err := windsurf.New().Activate(context.Background(), clients.Env{NativeConfig: nativeconfig.New()}, f.request); err == nil { + t.Fatal("rebound delivery admitted") + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, root)) { + t.Fatal("artifact refusal changed native files") + } + }) + } +} + +func TestProjectDevinLocalOverrideForeignEntriesArePreserved(t *testing.T) { + f := newDevinProjectFixture(t, false) + root := f.request.Plan.NativeRegistryRoot + override := filepath.Join(root, "mcp_config.local.json") + const foreign = `{"mcpServers":{"other":{"url":"https://foreign.test","transport":"http","disabled":true}},"policy":"keep"}` + projectGeminiWrite(t, override, foreign) + out, err := f.activator(nativeconfig.New()).Activate(context.Background(), f.request) + devinAssertPrepared(t, out, err) + projectGeminiAssert(t, override, foreign) + // Removal relies on recorded ownership even if later local policy shadows it. + projectGeminiWrite(t, override, `{"mcpServers":{"docs":{"disabled":true}}}`) + removal := f.removal(out.NativeObjects) + removal.Client.Version = "" + removal.Client.ExecutablePath = "" + outRemove, err := f.activator(nativeconfig.New()).Deactivate(context.Background(), removal) + if err != nil || !outRemove.ExternalRemovalComplete { + t.Fatalf("recorded removal=%+v,%v", outRemove, err) + } + projectGeminiAssert(t, override, `{"mcpServers":{"docs":{"disabled":true}}}`) +} + +func TestProjectDevinCommentedNativePolicyPreservesBytesAndRefusesUnsafeEntries(t *testing.T) { + for _, name := range []string{"config.json", "config.local.json", "mcp_config.local.json"} { + for _, unsafe := range []bool{false, true} { + t.Run(name+fmt.Sprint(unsafe), func(t *testing.T) { + f := newDevinProjectFixture(t, false) + root := f.request.Plan.NativeRegistryRoot + body := `{ // foreign native policy +"policy":"keep" /* foreign comment */}` + if name == "mcp_config.local.json" { + body = `{ // foreign local override +"mcpServers":{"other":{"url":"https://foreign.test","disabled":true}},"policy":"keep"}` + } + if unsafe { + body = `{ // native legacy or local-shadow refusal +"mcpServers":{"docs":{"url":"https://foreign.test","disabled":true}}}` + } + path := filepath.Join(root, name) + projectGeminiWrite(t, path, body) + before := projectGeminiInventory(t, root) + out, err := f.activator(nativeconfig.New()).Activate(context.Background(), f.request) + if unsafe { + if err == nil { + t.Fatal("commented unsafe policy admitted") + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, root)) { + t.Fatal("commented refusal changed bytes") + } + } else { + devinAssertPrepared(t, out, err) + } + projectGeminiAssert(t, path, body) + }) + } + } +} diff --git a/install/integrationctl/agentplugins/providers/project_devin_receipt_test.go b/install/integrationctl/agentplugins/providers/project_devin_receipt_test.go new file mode 100644 index 000000000..627af46d3 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_devin_receipt_test.go @@ -0,0 +1,85 @@ +package providers_test + +import ( + "context" + "path/filepath" + "reflect" + "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/windsurf" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +func TestProjectDevinExactReceiptAuthorityRefusesBeforeEffects(t *testing.T) { + for _, mismatch := range []string{"unknown-kind", "id", "duplicate", "other-project", "source-traversal", "nonclean-path"} { + t.Run(mismatch, func(t *testing.T) { + f := newDevinProjectFixture(t, false) + root := f.request.Plan.NativeRegistryRoot + objects := append([]domain.NativeObjectOwnership(nil), f.request.Delivery.NativeObjects...) + skill := 0 + for i, object := range objects { + if object.Kind == windsurf.DevinProjectSkillKind { + skill = i + } + } + switch mismatch { + case "unknown-kind": + objects[skill].Kind = "foreign_kind" + case "id": + objects[skill].ObjectID = "devin-skill:foreign" + case "duplicate": + objects = append(objects, objects[skill]) + case "other-project": + objects[skill].Path = filepath.Join(f.other, ".devin", "skills", "docs") + case "source-traversal": + objects[skill].SourceRelative = "skills/../docs" + case "nonclean-path": + objects[skill].Path = filepath.Dir(objects[skill].Path) + string(filepath.Separator) + ".." + string(filepath.Separator) + "skills" + string(filepath.Separator) + "docs" + } + f.request.Delivery.NativeObjects = objects + before := projectGeminiInventory(t, root) + if _, err := f.activator(nativeconfig.New()).Activate(context.Background(), f.request); err == nil { + t.Fatal("forged project native ownership was admitted") + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, root)) { + t.Fatal("receipt refusal changed native files") + } + }) + } +} + +func TestProjectDevinRegistryRejectsCopiedBinding(t *testing.T) { + f := newDevinProjectFixture(t, false) + // Only the existing automatic intent is qualified for this project lane. + outcome, err := f.activator(nativeconfig.New()).Activate(context.Background(), f.request) + devinAssertPrepared(t, outcome, err) + binding := domain.ClientBinding{ClientID: "windsurf", Scope: "project", PhysicalArtifact: f.request.Plan.PhysicalArtifactID, TargetLocator: f.request.Plan.ActivePath, NativeProfileRoot: f.request.Plan.NativeRegistryRoot, NativeObjects: outcome.NativeObjects} + adapter := windsurf.New() + finding, err := adapter.InspectNativeRegistry(context.Background(), clients.Env{NativeConfig: nativeconfig.New()}, f.request.Client, f.request.Plan, &binding) + if err != nil || finding != clients.RegistryExpected { + t.Fatalf("exact project registry = %v,%v", finding, err) + } + for _, change := range []string{"client", "scope", "artifact", "package", "root"} { + copied := binding + switch change { + case "client": + copied.ClientID = "codex" + case "scope": + copied.Scope = "user" + case "artifact": + copied.PhysicalArtifact = "different" + case "package": + copied.TargetLocator = filepath.Join(f.other, ".uap", "managed", "clients", "windsurf", binding.PhysicalArtifact) + case "root": + copied.NativeProfileRoot = filepath.Join(f.other, ".devin") + } + if err := adapter.ValidateBindingProfile(binding.NativeProfileRoot, copied); change != "scope" && err == nil { + t.Fatalf("direct copied %s binding admitted", change) + } + if _, err := adapter.InspectNativeRegistry(context.Background(), clients.Env{NativeConfig: nativeconfig.New()}, f.request.Client, f.request.Plan, &copied); err == nil { + t.Fatalf("copied %s binding admitted", change) + } + } +} diff --git a/install/integrationctl/agentplugins/providers/project_devin_stdio_test.go b/install/integrationctl/agentplugins/providers/project_devin_stdio_test.go new file mode 100644 index 000000000..1a4fa5f47 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_devin_stdio_test.go @@ -0,0 +1,72 @@ +package providers_test + +import ( + "context" + "encoding/json" + "errors" + "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 TestProjectDevinStdioUsesBoundManagedLauncherWithoutExecution(t *testing.T) { + fixture := newDevinProjectFixture(t, true) + beforeUser, beforeB := projectGeminiInventory(t, fixture.user), projectGeminiInventory(t, fixture.other) + outcome, err := fixture.activator(nativeconfig.New()).Activate(context.Background(), fixture.request) + devinAssertPrepared(t, outcome, err) + servers, err := readDevinFixtureConfig(filepath.Join(fixture.request.Plan.NativeRegistryRoot, "mcp_config.json")) + if err != nil { + t.Fatal(err) + } + server := servers["docs"].(map[string]any) + args := make([]string, 0, len(server["args"].([]any))) + for _, arg := range server["args"].([]any) { + args = append(args, arg.(string)) + } + env := server["env"].(map[string]any) + 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(args, wantArgs) || server["cwd"] != nil || env["PLUGIN_ROOT"] != active || env["PLUGIN_DATA"] != data { + t.Fatalf("native launcher binding = %+v; want args %q", server, wantArgs) + } + projectGeminiAssert(t, server["command"].(string), "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) != 2 || !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") + } +} + +func readDevinFixtureConfig(path string) (map[string]any, error) { + file, err := nativeconfig.New().ReadExactFile(path) + if err != nil { + return nil, err + } + var document struct { + Servers map[string]any `json:"mcpServers"` + } + if err := json.Unmarshal(file.Body, &document); err != nil { + return nil, err + } + return document.Servers, nil +}