diff --git a/cli/cmd/agentplugins/project_chatgpt_fixture_test.go b/cli/cmd/agentplugins/project_chatgpt_fixture_test.go new file mode 100644 index 000000000..114428210 --- /dev/null +++ b/cli/cmd/agentplugins/project_chatgpt_fixture_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/cli/internal/agentpluginscli" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +type projectChatGPTBoundaries struct{ acquisition, detection, security int } + +func (spy *projectChatGPTBoundaries) Detect(context.Context) ([]domain.DetectedClient, error) { + spy.detection++ + return nil, errors.New("unexpected detection after unsupported project request") +} + +func (spy *projectChatGPTBoundaries) Evaluate(context.Context, domain.SecurityEvaluationInput) (domain.SecurityAssessment, error) { + spy.security++ + return domain.SecurityAssessment{}, errors.New("unexpected security evaluation after unsupported project request") +} + +func (spy *projectChatGPTBoundaries) rejectAcquisition() (domain.PackageSnapshot, error) { + spy.acquisition++ + return domain.PackageSnapshot{}, errors.New("unexpected acquisition after unsupported project request") +} + +func (spy *projectChatGPTBoundaries) AcquireLocal(context.Context, string) (domain.PackageSnapshot, error) { + return spy.rejectAcquisition() +} + +func (spy *projectChatGPTBoundaries) AcquireGitHub(context.Context, string, string, string) (domain.PackageSnapshot, error) { + return spy.rejectAcquisition() +} + +func (spy *projectChatGPTBoundaries) AcquireGitHubVerified(context.Context, string, string, string, string) (domain.PackageSnapshot, error) { + return spy.rejectAcquisition() +} + +func (spy *projectChatGPTBoundaries) DiscoverGitHubPackages(context.Context, string, string) ([]string, error) { + spy.acquisition++ + return nil, errors.New("unexpected discovery after unsupported project request") +} + +func projectChatGPTApp(app agentpluginscli.App, spy *projectChatGPTBoundaries) agentpluginscli.App { + app.Detector, app.SourceAcquirer, app.SecurityEvaluator = spy, spy, spy + app.DirectoryClient, app.DiscoveryClient, app.SecurityIndex = nil, nil, nil + app.DirectoryClientForDryRun, app.CommitDirectoryPreview = nil, nil + app.SourceAcquirerForDryRun, app.SecurityEvaluatorForDryRun = 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 projectChatGPTApp(selected, spy), nil + } + return app +} + +func projectChatGPTExecute(app agentpluginscli.App, project string, args ...string) (string, error) { + var output bytes.Buffer + app.Input, app.Output, app.ErrorOutput, app.Terminal = strings.NewReader(""), &output, &output, false + command := agentpluginscli.NewRoot(app) + command.SetOut(&output) + command.SetErr(&output) + args = append(args, "--scope", "project", "--format", "json") + if project != "" { + args = append(args, "--project-root", project) + } + command.SetArgs(args) + err := command.Execute() + return output.String(), err +} + +func projectChatGPTWrite(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) + } +} diff --git a/cli/cmd/agentplugins/project_chatgpt_test.go b/cli/cmd/agentplugins/project_chatgpt_test.go new file mode 100644 index 000000000..f6b15eee8 --- /dev/null +++ b/cli/cmd/agentplugins/project_chatgpt_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestProjectChatGPTCLIRefusesAllTargetedIntentsBeforeEffects(t *testing.T) { + app, user, a, b := projectCompositionFixture(t) + spy := &projectChatGPTBoundaries{} + app = projectChatGPTApp(app, spy) + source := filepath.Join(t.TempDir(), "package") + projectChatGPTWrite(t, filepath.Join(source, "plugin.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"chatgpt-scope-proof"}`) + for _, root := range []string{app.UserHome, a, b} { + projectChatGPTWrite(t, filepath.Join(root, "foreign.txt"), "preserve bytes and mode\n") + } + before := []map[string]string{securityFixtureTree(t, app.UserHome), securityFixtureTree(t, a), securityFixtureTree(t, b)} + for _, args := range [][]string{ + {"add", "acme/docs", "--target", "chatgpt"}, + {"add", source, "--no-save", "--target", "chatgpt"}, + {"add", "context7", "--target", "chatgpt", "--chatgpt-app-id", "plugin_asdk_app_0123456789abcdef0123456789abcdef"}, + {"add", source, "--no-save", "--target", "chatgpt", "--activation-complete", "--auth-complete"}, + {"add", source, "--no-save", "--target", "gemini,chatgpt"}, + {"install", source, "--no-save", "--target", "chatgpt"}, + {"update", "00000000-0000-4000-8000-000000000001", "--target", "chatgpt"}, + {"repair", "00000000-0000-4000-8000-000000000001", "--target", "chatgpt"}, + {"remove", "00000000-0000-4000-8000-000000000001", "--target", "chatgpt", "--external-uninstalled"}, + } { + output, err := projectChatGPTExecute(app, a, args...) + if err == nil || err.Error() != "chatgpt does not support project scope" || strings.Contains(output, "success") { + t.Fatalf("unsupported project request %v: %v\n%s", args, err, output) + } + } + t.Chdir(a) + if _, err := projectChatGPTExecute(app, "", "add", source, "--target", "chatgpt", "--no-save"); err == nil || err.Error() != "chatgpt does not support project scope" { + t.Fatalf("invocation-directory default bypassed project refusal: %v", err) + } + for index, root := range []string{app.UserHome, a, b} { + if !reflect.DeepEqual(before[index], securityFixtureTree(t, root)) { + t.Fatalf("unsupported project command changed user/A/B filesystem: %s", root) + } + } + projectChatGPTAssertNoEffects(t, spy, user, a) +} + +func TestProjectChatGPTSavedTargetRefusesBeforeLockAndAcquisition(t *testing.T) { + for _, args := range [][]string{{"deps", "lock"}, {"deps", "update", "docs"}, {"deps", "sync", "--yes"}, {"install", "--yes"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + app, user, a, _ := projectCompositionFixture(t) + spy := &projectChatGPTBoundaries{} + app = projectChatGPTApp(app, spy) + projectChatGPTWrite(t, filepath.Join(a, "uap.toml"), "schema_version = 1\n[dependencies.docs]\ngit = \"https://github.com/acme/docs.git\"\nref = \"main\"\n[targets]\nchatgpt = [\"docs\"]\n") + before := securityFixtureTree(t, a) + output, err := projectChatGPTExecute(app, a, args...) + if err == nil { + t.Fatalf("saved unsupported project target succeeded: %s", output) + } + if !reflect.DeepEqual(before, securityFixtureTree(t, a)) { + t.Fatal("unsupported saved target created a namespace/lock or changed intent") + } + if err.Error() != "chatgpt does not support project scope" || strings.Contains(output, "success") { + t.Fatalf("saved unsupported project target guidance: %v\n%s", err, output) + } + projectChatGPTAssertNoEffects(t, spy, user, a) + }) + } +} + +func projectChatGPTAssertNoEffects(t *testing.T, spy *projectChatGPTBoundaries, user, project string) { + t.Helper() + if spy.acquisition != 0 || spy.detection != 0 || spy.security != 0 { + t.Fatalf("unsupported project crossed host boundaries: %+v", spy) + } + for _, path := range []string{user, filepath.Join(project, ".uap"), filepath.Join(project, "uap.lock")} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("unsupported project request created %s: %v", path, err) + } + } +} diff --git a/cli/internal/agentpluginscli/deps_commands.go b/cli/internal/agentpluginscli/deps_commands.go index e983072e4..4cfa25638 100644 --- a/cli/internal/agentpluginscli/deps_commands.go +++ b/cli/internal/agentpluginscli/deps_commands.go @@ -49,6 +49,9 @@ func runDependencyLock(ctx context.Context, app App, opts *options, updateAlias if err := requireDependencyMutationReady(app); err != nil { return dependencyLockResult{}, err } + if err := app.preflightProjectDependencyTargets(); err != nil { + return dependencyLockResult{}, err + } if allowWrite && !opts.dryRun { release, lockErr := processlock.Lock{Path: app.EnvironmentLockPath}.Acquire(ctx) if lockErr != nil { @@ -56,14 +59,8 @@ func runDependencyLock(ctx context.Context, app App, opts *options, updateAlias } defer func() { _ = release() }() } - if app.Installer != nil { - view, err := app.Installer.Inspect(ctx) - if err != nil { - return dependencyLockResult{}, err - } - if view.Recovery.Required { - return dependencyLockResult{}, installer.ErrRecoveryRequired - } + if err := requireDependencyLockRecoveryClear(ctx, app); err != nil { + return dependencyLockResult{}, err } manifestPath, lockPath := app.dependencyPaths() manifestBefore, manifestReadErr := os.ReadFile(manifestPath) @@ -112,6 +109,20 @@ func publishResolvedDependencyLock(ctx context.Context, app App, manifestPath, l return nil } +func requireDependencyLockRecoveryClear(ctx context.Context, app App) error { + if app.Installer == nil { + return nil + } + view, err := app.Installer.Inspect(ctx) + if err != nil { + return err + } + if view.Recovery.Required { + return installer.ErrRecoveryRequired + } + return nil +} + func derefLock(lock *environment.Lock) environment.Lock { if lock == nil { return environment.Lock{} @@ -239,6 +250,9 @@ func initializeDependencies(cmd *cobra.Command, app App, opts *options) error { func validateDependencyTargets(app App, requirements environment.Requirements) error { for target := range requirements.Targets { + if app.scope() == domain.ScopeProject && !supportsDependencyTarget(app, target) { + return fmt.Errorf("%s does not support project scope", target) + } if definition, ok := domain.ClientDefinitionFor(domain.ClientID(target)); ok && (definition.PlansWithoutHostPresence || definition.Traits.RequiresPersonalMappingForPrepare) { return fmt.Errorf("%s is prepared manually and cannot be a saved dependency target; use agentplugins add --target %s (not saved)", definition.DisplayName, target) } diff --git a/cli/internal/agentpluginscli/project_context.go b/cli/internal/agentpluginscli/project_context.go index 1b65d3c6e..cf2971432 100644 --- a/cli/internal/agentpluginscli/project_context.go +++ b/cli/internal/agentpluginscli/project_context.go @@ -113,6 +113,20 @@ func (app App) checkProjectTargets(targetOption string) error { return nil } +// Reject unsupported project declarations before a lock creates mutable state. +// The locked operation still reads and validates the authoritative manifest. +func (app App) preflightProjectDependencyTargets() error { + if app.scope() != domain.ScopeProject { + return nil + } + manifest, _ := app.dependencyPaths() + requirements, err := readDependencyManifest(manifest) + if err != nil { + return err + } + return validateDependencyTargets(app, requirements) +} + func rejectProjectCommand(app App, command string) error { if app.scope() == domain.ScopeProject { return fmt.Errorf("%s is not supported for project scope", command) diff --git a/docs/CHATGPT_PROJECT_SCOPE.md b/docs/CHATGPT_PROJECT_SCOPE.md new file mode 100644 index 000000000..ef37ee11b --- /dev/null +++ b/docs/CHATGPT_PROJECT_SCOPE.md @@ -0,0 +1,43 @@ +# ChatGPT and project scope + +UAP does not qualify ChatGPT for native project installation. Selecting +`--scope project` with `--target chatgpt` returns: + +```text +chatgpt does not support project scope +``` + +The refusal applies to saved additions, imperative `--no-save` additions, +positional `install`, updates, repairs, removal, mixed target requests and +personal preparation options. Completion or external-uninstall attestations +cannot establish a supported project lifecycle. Omitting `--project-root` +uses the invocation directory and preserves this same refusal. + +Do not add `chatgpt` to a project `uap.toml` target table. Dependency lock, +update, sync and environment install reject that declaration before source +acquisition or project mutation. Lock/update perform an early read-only target +check before creating an environment lock, then retain the normal authoritative +manifest reread and validation inside the locked operation. + +Unsupported project requests do not create a local preparation artifact, +`uap.lock`, mutable `.uap` state or native configuration. An existing portable +manifest remains unchanged. UAP does not substitute a user profile, connect an +app, grant trust, or claim that a remote ChatGPT installation was observed. + +## Existing user preparation + +The existing user-scoped personal/manual preparation workflow remains +available under its existing validation requirements. For example, a validated +personal Context7 registration can be prepared explicitly in user scope: + +```sh +agentplugins add context7 --target chatgpt --scope user --chatgpt-app-id +``` + +Use the registration ID obtained through the existing ChatGPT setup flow. +This produces local preparation and manual next steps; it does not certify +remote app connection, account/workspace installation, authentication or native +project discovery. ChatGPT remains excluded from saved dependency targets. + +The accepted scope decision and evidence are recorded in +[the reviewed client plan](plans/project-install-e2e/client-plans.md#13-chatgpt--explicit-unsupported-project-installation). diff --git a/docs/plans/project-install-e2e/chatgpt-code-review-r1.md b/docs/plans/project-install-e2e/chatgpt-code-review-r1.md new file mode 100644 index 000000000..ad561ac70 --- /dev/null +++ b/docs/plans/project-install-e2e/chatgpt-code-review-r1.md @@ -0,0 +1,66 @@ +# ChatGPT project boundary independent implementation review R1 + +Reviewer: codex_claude, 2026-10-23. Reviewed frozen leaf +`74608d3b4340815d9603bed7030411b15818be2e` in +`work/uap-project-chatgpt`. Reviewer did not author this implementation. + +## Decision: GO + +No blocker found in the explicit unsupported project boundary. The leaf adds no +native project capability, filesystem installation layout, fake profile, +remote-registration attestation or project preparation artifact. It preserves +the existing user personal/manual preparation contract. + +## Production composition and no-effects assessment + +- Production scope selection uses the selected existing project and core's + namespace guard. Explicit ChatGPT and mixed target requests are refused by + the shared project capability gate before detector, acquisition, scanning or + lifecycle dispatch. No ProjectTargetLayout, NativeRegistryLayout or project + catalog declaration is added for ChatGPT. +- The composed fixtures rebuild the real production App through ForScope and + reapply only detector/acquirer/security spies. They exercise saved and + imperative add, no-save, personal app IDs, completion flags, positional + install, update, repair, remove, mixed targets and invocation-cwd default. + The error remains `chatgpt does not support project scope`; success output, + host-boundary calls and filesystem changes are rejected by assertions. +- The project dependency preflight reads and validates declared manifest + targets before the environment lock can create `.uap`. This is an early + refusal check only. The authoritative manifest is still reread and validated + after locking, recovery admission remains inside that lock, and existing + manifest-change checks still govern publication. User scope returns before + this new project-only preflight. +- Saved lock/update/sync/environment-install fixtures require no lock, + `.uap`, user-state namespace or acquired source. Existing manifest and + user/project-A/project-B byte-and-mode inventories are preserved. Adjacent + transfer validation also validates target eligibility before publication; + no alternative import/install route is added by this leaf. +- Direct ChatGPT activation/preflight rejects either project scope or a + nonempty project root, including inconsistent legacy labels and positive + attestation/personal preparation flags. Deactivation refuses the same + unsupported context even with external-uninstall attestation. The provider + invokes preflight before its generic attestation shortcut. +- Existing user activation still returns manual or personal-prepared outcomes + under its existing requirements. It does not become installed verification + or remotely observed authentication. Existing user external-uninstall + behavior is unchanged. + +## Independent verification + +Ran with Go 1.26.10, GOMAXPROCS=3, `-p=2 -count=1 -timeout=3m`: + +```sh +go test ./install/integrationctl/agentplugins/clients/chatgpt \ + ./cli/internal/agentpluginscli ./cli/cmd/agentplugins \ + -run '^Test(ProjectChatGPT|ChatGPT)' +``` + +Results: client PASS **0.009s**, internal CLI PASS **0.074s**, production CLI +PASS **0.522s**. `git diff --check` on the frozen leaf: PASS. Reviewed the +changed production functions and surrounding scope, saved-target, locked +manifest, sync and transfer validation paths. + +These are inert production-composition and refusal contracts. No ChatGPT +remote API, native client, MCP server, model, app registration or account/ +workspace mutation was executed. There is no native project proof to infer +from an unsupported leaf. Independent R2 and applicable CI remain separate. diff --git a/docs/plans/project-install-e2e/chatgpt-code-review-r2.md b/docs/plans/project-install-e2e/chatgpt-code-review-r2.md new file mode 100644 index 000000000..c37b52a4a --- /dev/null +++ b/docs/plans/project-install-e2e/chatgpt-code-review-r2.md @@ -0,0 +1,57 @@ +# ChatGPT project boundary independent implementation review R2 + +Reviewer: cursor_clients, 2026-10-23. Reviewed frozen checkpoint +`74608d3b4340815d9603bed7030411b15818be2e` in `work/uap-project-chatgpt`. +Read the approved ChatGPT client plan, repository governing instructions and +capability-preservation contract, implementation diff, production CLI scope and +saved dependency entrypoints, direct adapter lifecycle, and added fixtures. + +## Decision: GO for the explicit unsupported project boundary + +No correctness blocker found. This is approval of the approved negative leaf: +it does not qualify a ChatGPT project filesystem installation, remote app +connection or account/workspace operation. + +## Reviewed boundaries + +- ChatGPT does not advertise ScopeProject, ProjectTargetLayout or + NativeRegistryLayout. The implementation adds no fake project native root, + user-profile substitution or artifact-only bypass. Existing user mapping + authorization and manual/prepared lifecycle remain intact. +- Production CLI project target admission rejects explicit ChatGPT and mixed + target selections before detector, acquirer, security evaluator or mutation. + Imperative/saved add, positional install, personal-registration inputs, + activation/authentication attestation, exact-ID update/repair/remove and + invocation-directory root defaults retain the stable unsupported diagnostic. +- Saved lock/update now perform a pure manifest-target preflight before the + environment lock can create `.uap`. The operation still rereads and validates + the authoritative manifest under lock, checks pending recovery and compares + manifest bytes before publishing. The helper extraction preserves existing + installer-nil and recovery-required behavior. Sync/environment install have + the same target refusal, with project guidance taking precedence over the + user-only manual-preparation suggestion. +- The transfer preparation path also validates source agreement targets before + confirmation, lock/publication or install. Project scope does not gain an + unsupported import/install escape through that existing generic route. +- Direct activation preflight refuses either project scope or a ProjectRoot + even with inconsistent user labels, personal preparation or attestation. + Deactivation similarly refuses unsupported project completion despite an + external-uninstall assertion. The provider calls preflight before its generic + attestation shortcut, and direct adapter activation calls the same guard. +- Added E2E contracts assert zero host/acquisition/security calls, exact + user/A/B byte-and-mode preservation, and absence of mutable namespace, lock + and user-state creation. Existing intent is preserved. Public guidance keeps + unsupported project behavior and bounded user preparation distinct. + +## Independent verification + +With the existing Go 1.26.10 toolchain, GOMAXPROCS=3 and `-p=2 -count=1`: + +- Complete `clients/chatgpt` suite: PASS, 0.013s. +- Production composed CLI `./cmd/agentplugins -run '^TestProjectChatGPT'`: + PASS, 0.442s. + +These execute the negative project E2E and direct user/project adapter +contracts. No native client, MCP command, model call, remote API, trust, +registration, authentication or workspace publication was run. The passing +fixtures establish refusal/isolation, not remote product capability. diff --git a/docs/plans/project-install-e2e/chatgpt-implementation-evidence.md b/docs/plans/project-install-e2e/chatgpt-implementation-evidence.md new file mode 100644 index 000000000..15eb1324a --- /dev/null +++ b/docs/plans/project-install-e2e/chatgpt-implementation-evidence.md @@ -0,0 +1,55 @@ +# ChatGPT project boundary implementation evidence + +The all-client plan's two review rounds approved an explicit unsupported +ChatGPT project leaf. This implementation adds no native project capability, +artifact-only preparation route, project layout, native registry or remote API. +It retains the existing user personal/manual preparation path. + +## Implemented boundary + +- Production composed CLI tests exercise saved/imperative additions, personal + app registration input, activation/authentication attestation, positional + install, mixed Gemini/ChatGPT targets, exact-ID update/repair/removal and the + invocation-directory project default. Each returns the stable unsupported + reason before detector, acquirer or security evaluator calls. +- Full user/project-A/project-B byte and mode inventories remain unchanged. + The tests require no project `.uap`, lock or user state namespace to be + created. Existing user/project markers remain intact. +- Saved project declarations containing ChatGPT are refused by deps lock, + deps update, deps sync and environment install. The negative fixture exposed + lock/update creating `.uap/environment.lock` before target validation; a pure + project declaration preflight now occurs before that lock. The existing + locked manifest reread, validation and publication checks remain in place. +- Project target validation reports project scope unsupported before manual + user-only guidance, so it cannot suggest `--no-save` as a project bypass. +- Direct ChatGPT activation/preflight rejects project scope or a project + TargetContext even when personal-preparation/attestation flags are supplied. + Direct deactivation refuses unsupported project completion even with an + external-uninstall attestation. This protects adapter reuse in addition to + the shared provider/CLI scope gate. +- Capability tests retain user scope and assert absence of project scope, + ProjectTargetLayout and NativeRegistryLayout. Direct user manual/prepared + outcomes remain bounded and never become verified remote installations. + +## Verification + +Go1.26.10 focused test execution (`-p=2 -count=1 -timeout=3m`) passed production +CLI project ChatGPT contracts **0.576s**, client contracts including existing +exact manual removal **0.009s**, and existing Context7 guided/personal/manual +preparation/lifecycle regressions **0.920s**. Before the correction, the saved +lock/update cases reproduced namespace creation and sync/install exposed +misleading user-only guidance. The final tests retain those precise no-effect +and diagnostic assertions. + +After the concrete locked recovery-inspection helper extraction, a final +focused run also included existing staged snapshots, changed-lock rejection +and all-client native recovery: production CLI **0.491s**, client **0.009s**, +and internal CLI **3.653s**, all passing. The extraction retains the prior +installer-nil and recovery-required behavior inside the environment lock. + +Exact golangci-lint2.13.2 changed-code and whole-file size/architecture gates +on the affected CLI and ChatGPT client packages: **0 issues** each. No lint +baseline or original assertion was relaxed. +Independent implementation reviews and applicable cross-platform CI follow the +frozen checkpoint. These tests execute no ChatGPT remote API, native client, +MCP server, model, registration or workspace publication. diff --git a/docs/plans/project-install-e2e/client-plans.md b/docs/plans/project-install-e2e/client-plans.md index 76e87fa89..8923b075d 100644 --- a/docs/plans/project-install-e2e/client-plans.md +++ b/docs/plans/project-install-e2e/client-plans.md @@ -610,5 +610,5 @@ is replaced by explicit unsupported. R2 fixes independently verified GO in | cline | GO | Verified | GO | Root assignment required | | windsurf | GO | Verified | GO | Root assignment required | | kimi | GO (current docs) | Verified | GO | Root assignment required | -| chatgpt | GO | Verified | GO | Root assignment required | +| chatgpt | GO | Verified | GO | Explicit project refusal implemented; independent implementation review pending | | vscode | GO | Verified | GO | Root assignment required | diff --git a/install/integrationctl/agentplugins/clients/chatgpt/lifecycle.go b/install/integrationctl/agentplugins/clients/chatgpt/lifecycle.go index 28a7e3a19..17c416b45 100644 --- a/install/integrationctl/agentplugins/clients/chatgpt/lifecycle.go +++ b/install/integrationctl/agentplugins/clients/chatgpt/lifecycle.go @@ -17,6 +17,9 @@ var ( // PreflightActivation rejects a prepare request that has not presented a // validated personal mapping. Other intents have nothing extra to check. func (*Adapter) PreflightActivation(_ clients.Env, request domain.ActivationRequest) error { + if request.Plan.Scope == domain.ScopeProject || request.Plan.TargetContext.Scope == domain.ScopeProject || request.Plan.TargetContext.ProjectRoot != "" { + return fmt.Errorf("chatgpt does not support project scope") + } if request.Plan.InstallIntent != domain.InstallIntentPrepare { return nil } @@ -56,5 +59,8 @@ func (*Adapter) Activate(_ context.Context, env clients.Env, request domain.Acti // Deactivate requires the operator to uninstall in ChatGPT Plugins first. func (*Adapter) Deactivate(_ context.Context, _ clients.Env, request domain.DeactivationRequest) (domain.DeactivationOutcome, error) { + if request.TargetContext.Scope == domain.ScopeProject || request.TargetContext.ProjectRoot != "" { + return domain.DeactivationOutcome{}, fmt.Errorf("chatgpt does not support project scope") + } return shared.RequireExternalUninstall(shared.StartedDeactivation(), request.ExternalUninstalled, "uninstall the plugin in ChatGPT Plugins, then rerun remove with `--external-uninstalled` (also use the flag if it was never activated)"), nil } diff --git a/install/integrationctl/agentplugins/clients/chatgpt/project_scope_test.go b/install/integrationctl/agentplugins/clients/chatgpt/project_scope_test.go new file mode 100644 index 000000000..280f5c8c6 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/chatgpt/project_scope_test.go @@ -0,0 +1,68 @@ +package chatgpt + +import ( + "context" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +func TestProjectChatGPTDoesNotAdvertiseNativeFilesystemScope(t *testing.T) { + registry, err := clients.NewRegistry(New()) + if err != nil { + t.Fatal(err) + } + if !clients.SupportsScope(registry, domain.ClientChatGPT, domain.ScopeUser) || clients.SupportsScope(registry, domain.ClientChatGPT, domain.ScopeProject) { + t.Fatal("ChatGPT scope qualification changed its user or project boundary") + } + if _, ok := clients.As[clients.ProjectTargetLayout](registry, domain.ClientChatGPT); ok { + t.Fatal("ChatGPT exposed a fake project artifact installation layout") + } + if _, ok := clients.As[clients.NativeRegistryLayout](registry, domain.ClientChatGPT); ok { + t.Fatal("ChatGPT exposed an unobservable project native registry") + } +} + +func TestProjectChatGPTDirectLifecycleRefusesPreparationAndAttestation(t *testing.T) { + adapter := New() + for _, intent := range []domain.InstallIntent{domain.InstallIntentAutomatic, domain.InstallIntentPrepare} { + for _, target := range []domain.TargetContext{{Scope: domain.ScopeProject}, {Scope: domain.ScopeUser, ProjectRoot: t.TempDir()}} { + request := domain.ActivationRequest{Client: domain.DetectedClient{ClientID: domain.ClientChatGPT}, + Plan: domain.DeliveryPlan{ClientID: domain.ClientChatGPT, Scope: domain.ScopeProject, TargetContext: target, + InstallIntent: intent, PersonalChatGPTPreparation: true}, + Delivery: domain.StagedDelivery{ClientID: domain.ClientChatGPT}, ActivationComplete: true} + if _, err := adapter.Activate(context.Background(), clients.Env{}, request); err == nil || !strings.Contains(err.Error(), "does not support project scope") { + t.Fatalf("project direct activation/attestation = %v", err) + } + // Target context alone cannot bypass the guard through an inconsistent + // legacy plan scope or a project root with a user scope label. + request.Plan.Scope = domain.ScopeUser + if err := adapter.PreflightActivation(clients.Env{}, request); err == nil { + t.Fatal("project context bypassed direct activation preflight") + } + remove := domain.DeactivationRequest{TargetContext: target, Confirmed: true, ExternalUninstalled: true} + if outcome, err := adapter.Deactivate(context.Background(), clients.Env{}, remove); err == nil || outcome.ExternalRemovalComplete { + t.Fatalf("project external uninstall attested unsupported removal: %+v, %v", outcome, err) + } + } + } +} + +func TestProjectChatGPTLeafPreservesExistingUserManualPreparation(t *testing.T) { + for _, personal := range []bool{false, true} { + intent, activation := domain.InstallIntentAutomatic, domain.ActivationManual + if personal { + intent, activation = domain.InstallIntentPrepare, domain.ActivationPrepared + } + request := domain.ActivationRequest{Client: domain.DetectedClient{ClientID: domain.ClientChatGPT}, + Plan: domain.DeliveryPlan{ClientID: domain.ClientChatGPT, Scope: domain.ScopeUser, InstallIntent: intent, + PersonalChatGPTPreparation: personal, Verification: domain.VerificationPackageValid}, + Delivery: domain.StagedDelivery{ClientID: domain.ClientChatGPT, ActivePath: t.TempDir()}, DeclaredName: "user-preparation"} + outcome, err := New().Activate(context.Background(), clients.Env{}, request) + if err != nil || outcome.Activation != activation || outcome.ActivationAttested || outcome.Verification == domain.VerificationInstalled { + t.Fatalf("user manual/prepared boundary changed: %+v, %v", outcome, err) + } + } +}