Skip to content
Closed
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
2 changes: 1 addition & 1 deletion cli/cmd/agentplugins/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ func newManagedStager(clientRegistry *clients.Registry, paths pathpolicy.Policy,
}

func newAgentpluginsLifecycle(dataRoot string, v2Store statev2.Store, paths pathpolicy.Policy, clientRegistry *clients.Registry, stager providers.Stager, runner processadapter.OS, planner clientplanner.Planner, directoryManager dirswap.Manager, mutationLock processlock.Lock, nativeKernel nativeconfig.Kernel) usecase.Service {
transitions := opencode.NativeTransitions{PackageVerifier: stager, State: transaction.Kernel{StateStore: v2Store}, Kernel: nativeKernel}
transitions := opencode.NativeTransitions{ProjectRoot: v2Store.ProjectRoot, PackageVerifier: stager, State: transaction.Kernel{StateStore: v2Store}, Kernel: nativeKernel}
return usecase.Service{
ClientPreparation: providers.NewOpenCodeClientPreparation(clientRegistry),
StateStore: v2Store, Paths: paths, Planner: planner, Targets: planner, Stager: stager,
Expand Down
103 changes: 103 additions & 0 deletions cli/cmd/agentplugins/project_opencode_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package main

import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"

"github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain"
)

// Public saved CLI and production composition, filesystem/native transactions,
// source acquisition and immutable store. Only the detected native host version
// comes from an inert TEST process; no OpenCode/MCP/model runtime is certified.
func TestProjectOpenCodeComposedSavedLifecycle(t *testing.T) {
transport := newProjectJourneyTransport(t, "git")
journey := newProjectJourney(t, transport)
user := filepath.Join(journey.home, "opencode-user")
journey.app = configureProjectOpenCodeJourney(journey.app, projectOpenCodeDetector{root: user, executable: projectOpenCodeProbeFixture(t)})
foreign := "{ // retained OpenCode settings\n\"mcp\":{\"foreign\":{\"type\":\"remote\",\"url\":\"https://foreign.test/mcp\",\"enabled\":false}}}\n"
for _, root := range []string{user, journey.a, journey.b} {
projectJourneyWrite(t, filepath.Join(root, "opencode.jsonc"), foreign)
projectJourneyWrite(t, filepath.Join(root, ".opencode", "skills", "foreign", "SKILL.md"), "foreign OpenCode skill\n")
}
userBefore := securityFixtureTree(t, user)
for _, project := range []string{journey.a, journey.b} {
journey.run(t, project, "add", transport.endpoint, "--alias", "docs", "--target", "opencode")
projectOpenCodeOwned(t, journey, project, projectJourneySkill)
}
bBefore := securityFixtureTree(t, journey.b)
statePath := filepath.Join(journey.namespace(journey.a), "state-v2.json")
stateBefore := projectJourneyRead(t, statePath)
journey.run(t, journey.a, "deps", "sync", "--locked", "--yes")
if stateBefore != projectJourneyRead(t, statePath) {
t.Fatal("unchanged OpenCode sync rewrote saved ownership")
}
installation, binding := projectOpenCodeOwned(t, journey, journey.a, projectJourneySkill)
projectJourneyDataMarker(t, journey, journey.a, installation)
projectJourneyWrite(t, filepath.Join(binding.TargetLocator, "skills", "docs", "SKILL.md"), "drifted immutable bytes\n")
if err := os.RemoveAll(filepath.Dir(projectOpenCodeNativeSkill(journey.a))); err != nil {
t.Fatal(err)
}
projectJourneyWrite(t, filepath.Join(transport.repo, "skills", "docs", "SKILL.md"), projectJourneyAdvancedSkill)
projectJourneyGit(t, transport.repo, "add", ".")
projectJourneyGit(t, transport.repo, "commit", "-m", "advance disposable OpenCode source")
projectJourneyGit(t, transport.repo, "push", "--quiet", transport.remote, "main")
journey.run(t, journey.a, "repair", installation.InstallationID, "--target", "opencode")
projectOpenCodeOwned(t, journey, journey.a, projectJourneySkill)
projectOpenCodeRecover(t, journey)
projectOpenCodeUpdateAndRemove(t, journey, installation, binding)
if len(journey.state(t, journey.a).EnvironmentReceipts) != 0 || !reflect.DeepEqual(userBefore, securityFixtureTree(t, user)) || !reflect.DeepEqual(bBefore, securityFixtureTree(t, journey.b)) {
t.Fatal("OpenCode lifecycle retained removed ownership or changed user/project B")
}
}

func projectOpenCodeUpdateAndRemove(t *testing.T, journey projectJourney, installation domain.Installation, binding domain.ClientBinding) {
t.Helper()
journey.run(t, journey.a, "update", "docs", "--target", "opencode")
updated, selected := projectOpenCodeOwned(t, journey, journey.a, projectJourneyAdvancedSkill)
if updated.Source.TreeDigest == installation.Source.TreeDigest || updated.Source.SourceBindingID != installation.Source.SourceBindingID || selected.ClientBindingID != binding.ClientBindingID {
t.Fatal("OpenCode update lost saved source/binding authority or failed to advance")
}
status := journey.run(t, journey.a, "deps", "status")
for _, fact := range []string{`"project"`, `"opencode"`, `"activation_prepared"`} {
if !strings.Contains(status, fact) {
t.Fatalf("saved OpenCode status lost %s: %s", fact, status)
}
}
journey.run(t, journey.a, "remove", "docs", "--target", "opencode")
projectJourneyAbsent(t, binding.TargetLocator, filepath.Dir(projectOpenCodeNativeSkill(journey.a)))
config := projectJourneyRead(t, filepath.Join(journey.a, "opencode.jsonc"))
if strings.Contains(config, `"docs"`) || !strings.Contains(config, "foreign.test") || !strings.Contains(config, "retained OpenCode settings") {
t.Fatalf("OpenCode remove changed foreign native configuration: %s", config)
}
if projectJourneyRead(t, filepath.Join(journey.a, ".opencode", "skills", "foreign", "SKILL.md")) != "foreign OpenCode skill\n" {
t.Fatal("OpenCode remove changed foreign project skill")
}
for key, receipt := range installation.DataReceipts {
if updated.DataReceipts[key].Locator != receipt.Locator || projectJourneyRead(t, filepath.Join(receipt.Locator, "keep.txt")) != "retained environment data\n" {
t.Fatal("OpenCode update/remove lost retained project data authority")
}
}
after := securityFixtureTree(t, journey.a)
_, _ = journey.execute(journey.a, "remove", "docs", "--target", "opencode")
if !reflect.DeepEqual(after, securityFixtureTree(t, journey.a)) {
t.Fatal("repeat OpenCode removal changed an absent managed target")
}
}

func projectOpenCodeRecover(t *testing.T, journey projectJourney) {
t.Helper()
before := securityFixtureTree(t, filepath.Join(journey.a, ".opencode"))
config := projectJourneyRead(t, filepath.Join(journey.a, "opencode.jsonc"))
staging, active := projectJourneyInterruptDirectoryJournal(t, journey)
journey.run(t, journey.a, "deps", "recover", "--yes")
journey.inspectNoRecovery(t, journey.a)
projectJourneyAbsent(t, active)
if projectJourneyRead(t, filepath.Join(staging, "unpublished.txt")) != "caller-owned unpublished bytes\n" || !reflect.DeepEqual(before, securityFixtureTree(t, filepath.Join(journey.a, ".opencode"))) || config != projectJourneyRead(t, filepath.Join(journey.a, "opencode.jsonc")) {
t.Fatal("OpenCode project recovery changed native configuration or caller staging")
}
journey.run(t, journey.a, "deps", "recover", "--yes")
}
67 changes: 67 additions & 0 deletions cli/cmd/agentplugins/project_opencode_fixture_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package main

import (
"context"
"os/exec"
"path/filepath"
"runtime"
"testing"

"github.com/777genius/plugin-kit-ai/cli/internal/agentpluginscli"
"github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain"
)

type projectOpenCodeDetector struct{ root, executable string }

func (d projectOpenCodeDetector) Detect(context.Context) ([]domain.DetectedClient, error) {
return []domain.DetectedClient{{ClientID: domain.ClientOpenCode, ConfigRoot: d.root,
ExecutablePath: d.executable, Version: "1.18.34", Status: domain.DetectionDetected}}, nil
}

// The compiled inert TEST process implements only --version. It exercises the
// production host-preparation boundary without pretending to be native proof.
func projectOpenCodeProbeFixture(t *testing.T) string {
t.Helper()
name := "TEST-opencode-version-only"
if runtime.GOOS == "windows" {
name += ".exe"
}
path := filepath.Join(t.TempDir(), name)
command := exec.CommandContext(t.Context(), "go", "build", "-ldflags", "-X main.version=1.18.34 -X main.mode=version-only", "-o", path,
"../../../install/integrationctl/agentplugins/adapters/clientdetect/testdata/opencode_probe.go")
if body, err := command.CombinedOutput(); err != nil {
t.Fatalf("inert version-contract fixture build: %s, %v", body, err)
}
return path
}

func configureProjectOpenCodeJourney(app agentpluginscli.App, detector projectOpenCodeDetector) agentpluginscli.App {
app.Detector = detector
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 configureProjectOpenCodeJourney(selected, detector), nil
}
return app
}

func projectOpenCodeNativeSkill(project string) string {
return filepath.Join(project, ".opencode", "skills", "docs", "SKILL.md")
}

func projectOpenCodeOwned(t *testing.T, journey projectJourney, project, expected string) (domain.Installation, domain.ClientBinding) {
t.Helper()
installation, binding := journey.owned(t, project)
if binding.ClientID != string(domain.ClientOpenCode) || binding.Scope != string(domain.ScopeProject) || binding.NativeProfileRoot != project || binding.Activation != domain.ActivationPrepared || binding.Verification != domain.VerificationInstalled {
t.Fatalf("project OpenCode binding lost scoped conservative authority: %+v", binding)
}
for _, path := range []string{projectOpenCodeNativeSkill(project), filepath.Join(binding.TargetLocator, "skills", "docs", "SKILL.md")} {
if body := projectJourneyRead(t, path); body != expected {
t.Fatalf("OpenCode owned skill at %s = %q", path, body)
}
}
return installation, binding
}
41 changes: 41 additions & 0 deletions docs/OPENCODE_PROJECT_SCOPE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# OpenCode project scope

OpenCode project delivery installs MCP entries into the selected project's
`opencode.json` or `opencode.jsonc`, and skill directories into
`.opencode/skills/`. Rendered portable commands use the existing skill projection.
UAP keeps its package, data and machine receipts under that project's `.uap/`.
The detected user OpenCode profile remains separate.

```sh
agentplugins add owner/repo --alias docs --target opencode \
--scope project --project-root /path/to/project
agentplugins deps sync --locked --scope project --project-root /path/to/project
agentplugins repair <installation-id> --target opencode \
--scope project --project-root /path/to/project
agentplugins remove docs --target opencode \
--scope project --project-root /path/to/project
```

`--project-root` selects an existing directory explicitly. When omitted in
project scope, UAP uses the invocation directory. It does not search upward for
a different project. The default scope remains user.

Existing JSONC selection, comments and foreign settings are preserved. If both
config variants exist, UAP refuses the ambiguity. Exact logical MCP keys are
preserved, including names containing slashes or internal spaces; skill names
remain safe directory identifiers. Disabled foreign MCP entries reserve their
keys and are not adopted. Removal consumes only exact recorded native ownership
and leaves unrelated entries, skills and retained application data intact.

Successful file configuration reports **prepared activation with installed
verification**. This proves the selected native files and ownership. OpenCode
merges user/project/ancestor, custom-directory, inline and managed settings, and
skill discovery also considers compatible directories. These settings can
change what a fresh native session sees. UAP does not grant trust, change your
OpenCode home or overwrite higher-priority policy to force discovery. Activation
attestation cannot convert the file observation into native runtime proof.

Start OpenCode from the selected project and inspect its effective configuration
and MCP listing before relying on a component. The implementation evidence
separates local production contracts, independent review and hosted native
qualification in [the checkpoint record](plans/project-install-e2e/opencode-implementation-evidence.md).
2 changes: 1 addition & 1 deletion docs/plans/project-install-e2e/client-plans.md
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ is replaced by explicit unsupported. R2 fixes independently verified GO in
| codex | GO | Verified | GO | Root assignment required |
| cursor | GO | Verified | GO | Root assignment required |
| gemini | GO | Verified | GO | Implemented at65b8f17a; independent implementation R1/R2 GO; native discovery pending |
| opencode | GO | Verified | GO | Root assignment required |
| opencode | GO | Verified | GO | Implemented locally; implementation R1/R2 pending; public CLI/native discovery pending |
| copilot | GO | Verified | GO | Root assignment required |
| kiro | GO | Verified | GO | Root assignment required |
| grok | GO | Verified | GO | Root assignment required |
Expand Down
96 changes: 96 additions & 0 deletions docs/plans/project-install-e2e/opencode-code-review-r1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# OpenCode project adapter independent implementation review R1

Reviewer: codex_claude, 2026-10-23. Reviewed cumulative leaf
`d9f3f757..f94906f42e788791e15d198127144ad8fe2c40f2` in
`work/uap-project-opencode`. Reviewer did not author this implementation.

## Decision: interim NO GO pending selected-data authority correction

One confirmed blocker remains in active native reprojection: the
`ProjectActiveNative` data-path argument is discarded, while the native object
builder reads the stored projection's DataRoot. Validation checks that the
stored root is absolute but does not compare it with the currently selected
data authority. An intact, digest-valid package therefore cannot establish
agreement with a different data root supplied by the owning lifecycle. Root/R2
already identified this independently; the author is preparing the correction.

The fix must compare against the selected data authority without rewriting
digest-covered package bytes, preserve existing user behavior where applicable,
and reject mismatches with no package/native effects. Final R1 closure will
review the frozen fix and independently execute its negative regression.

## Other reviewed boundaries: no additional blocker found

- Project root config candidates are exact `opencode.json`/`opencode.jsonc`,
while native skills resolve separately below `.opencode/skills`. Prepared
packages and persistent data remain in selected project `.uap` authority.
Original detected user ConfigRoot stays distinct; neither package paths nor
ambient cwd select project scope.
- The internal layout travels through projection, native namespace checks,
inspect, apply, verification and remove. Project objects require exact IDs,
names, absolute clean paths and containment. MCP logical keys retain their
exact slash/space spelling instead of becoming filenames. Foreign same-name
disabled entries and aliases retain closed refusal.
- The scoped domain declaration uses core's native-config lifecycle. Existing
exact-entry kernel ownership, conditional writes, exclusive skill swaps and
receipts remain the mutation machinery; no second configuration engine or
alternative plugin registry lifecycle is added.
- Durable dialect transition discovery/recovery derives split native layouts
from persisted binding scope and root. Every production recorder constructor
now receives selected ProjectRoot. Missing/mismatching selected context
refuses before reconciliation. Old/target state, object receipts, skill
intents and journal root checks remain bound to the same selected layout;
user-only helper wrappers preserve their original layout.
- Successful project configuration reports prepared plus installed, with
effective policy, native discovery, permissions and MCP initialization
unobserved. Project preflight rejects ActivationComplete before provider
attestation shortcuts and on direct adapter dispatch. Native prepared status
is also retained through durable transition target-state publication.
- The production public saved CLI fixture uses real TLS Git acquisition,
standard loading, immutable store, planner, staging, installer and native
transactions. Its clearly named inert TEST executable handles only version
contract probing. The fixture is not represented as real OpenCode discovery.
- The saved journey checks A/B installs, exact unchanged locked state, old-pin
repair after moving source, interrupted directory recovery, update with
retained source/binding/data authority, prepared status, exact removal and
repeat-removal no-effects. User/B inventories, foreign disabled settings,
comments, skills and retained persistent data are checked separately.

## Independent verification at the reviewed checkpoint

Go 1.26.10, GOMAXPROCS=3, `-p=2 -count=1 -timeout=3m`, selection
`^Test(ProjectOpenCode|NativeTransition)`:

- OpenCode client PASS **0.479s**;
- providers PASS **0.040s**;
- production public CLI PASS **3.544s**.

These include the authored scoped contracts, durable transition tests and
composed saved project journey. They do not cover the missing selected-data
negative until the correction lands. No genuine OpenCode client, model,
configured MCP server, trust action or account operation was executed.

Genuine pinned hosted effective-config, skill discovery, marker handshake/tool
result and restart observations remain separate pending native gates. File
receipts and the inert version probe cannot supply those observations.

## Final R1 closure: GO at 90fcc0af

Independently reviewed corrective commit
`90fcc0af34008a4416758c0c9d83678e72323d5d`. The active-project entrypoint now checks cancellation, validates
scope/layout and exact selected active root, reads the existing immutable
projection, and requires its exact package root. For projected stdio servers,
the selected data locator must be absolute, already clean and exactly equal to
the stored DataRoot. An empty, relative, other-project or changed receipt
returns ErrProjectionRefreshRequired without rewriting package bytes. Legacy
user reprojection retains its earlier behavior. Remote-only/skill-only plans do
not invent a data requirement.

The added regression checks the accepted exact projection against the original
objects; exercises empty, relative, project-B and another selected data root;
refuses another active package authority; and compares complete project,
user and project-B inventories after the read-only calls. Independently reran
`^TestProjectOpenCode` with Go1.26.10/GOMAXPROCS3/-p2/count1: client **PASS
0.135s**, providers **PASS 0.030s**. The selected-data blocker is closed and no
additional blocker was found. The genuine hosted native boundary stated above
is unchanged.
Loading
Loading