Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions cli/cmd/agentplugins/project_chatgpt_fixture_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
82 changes: 82 additions & 0 deletions cli/cmd/agentplugins/project_chatgpt_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
30 changes: 22 additions & 8 deletions cli/internal/agentpluginscli/deps_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,18 @@ 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 {
return dependencyLockResult{}, lockErr
}
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)
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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 <source> --target %s (not saved)", definition.DisplayName, target)
}
Expand Down
14 changes: 14 additions & 0 deletions cli/internal/agentpluginscli/project_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions docs/CHATGPT_PROJECT_SCOPE.md
Original file line number Diff line number Diff line change
@@ -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 <your-registration-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).
66 changes: 66 additions & 0 deletions docs/plans/project-install-e2e/chatgpt-code-review-r1.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading