From e62967eef1bcd15bd520aa9fc53b74d3a5efaefd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D0=B8=D1=8F?= Date: Fri, 23 Oct 2026 22:15:13 +0300 Subject: [PATCH] feat(opencode): install project plugins with isolated config and lifecycle --- cli/cmd/agentplugins/compose.go | 2 +- .../agentplugins/project_opencode_e2e_test.go | 103 +++++++++ .../project_opencode_fixture_test.go | 67 ++++++ docs/OPENCODE_PROJECT_SCOPE.md | 41 ++++ .../plans/project-install-e2e/client-plans.md | 2 +- .../opencode-code-review-r1.md | 96 +++++++++ .../opencode-code-review-r2.md | 95 ++++++++ .../opencode-implementation-evidence.md | 172 +++++++++++++++ .../agentplugins/clients/opencode/activate.go | 33 ++- .../agentplugins/clients/opencode/identity.go | 44 +++- .../clients/opencode/native_apply.go | 49 +++-- .../clients/opencode/native_mcp.go | 63 ++++-- .../clients/opencode/native_project.go | 12 +- .../clients/opencode/native_skills.go | 17 +- .../clients/opencode/native_transition.go | 19 +- .../opencode/native_transition_journal.go | 14 +- .../opencode/native_transition_recovery.go | 41 ++-- .../native_transition_recovery_test.go | 66 ++++-- .../opencode/native_transition_scope.go | 73 +++++++ .../opencode/native_transition_validation.go | 39 +++- .../agentplugins/clients/opencode/plan.go | 10 +- .../agentplugins/clients/opencode/project.go | 10 +- .../clients/opencode/project_active.go | 44 ++++ .../clients/opencode/project_status.go | 37 ++++ .../opencode/project_transition_test.go | 80 +++++++ .../agentplugins/clients/opencode/scope.go | 114 ++++++++++ .../agentplugins/domain/clients.go | 4 +- .../agentplugins/installer/engine.go | 2 +- .../agentplugins/installer/inspect.go | 2 +- .../installer/opencode_recovery.go | 4 +- .../golden/compat_invalid_components.json | 3 +- .../golden/compat_remote_mcp_and_app.json | 3 +- .../testdata/golden/compat_skills_only.json | 3 +- .../testdata/golden/compat_stdio_mcp.json | 3 +- .../providers/opencode_namespace.go | 22 +- .../providers/project_opencode_alias_test.go | 43 ++++ .../providers/project_opencode_data_test.go | 51 +++++ .../providers/project_opencode_keys_test.go | 29 +++ .../providers/project_opencode_stager_test.go | 96 +++++++++ .../providers/project_opencode_test.go | 204 ++++++++++++++++++ 40 files changed, 1707 insertions(+), 105 deletions(-) create mode 100644 cli/cmd/agentplugins/project_opencode_e2e_test.go create mode 100644 cli/cmd/agentplugins/project_opencode_fixture_test.go create mode 100644 docs/OPENCODE_PROJECT_SCOPE.md create mode 100644 docs/plans/project-install-e2e/opencode-code-review-r1.md create mode 100644 docs/plans/project-install-e2e/opencode-code-review-r2.md create mode 100644 docs/plans/project-install-e2e/opencode-implementation-evidence.md create mode 100644 install/integrationctl/agentplugins/clients/opencode/native_transition_scope.go create mode 100644 install/integrationctl/agentplugins/clients/opencode/project_active.go create mode 100644 install/integrationctl/agentplugins/clients/opencode/project_status.go create mode 100644 install/integrationctl/agentplugins/clients/opencode/project_transition_test.go create mode 100644 install/integrationctl/agentplugins/clients/opencode/scope.go create mode 100644 install/integrationctl/agentplugins/providers/project_opencode_alias_test.go create mode 100644 install/integrationctl/agentplugins/providers/project_opencode_data_test.go create mode 100644 install/integrationctl/agentplugins/providers/project_opencode_keys_test.go create mode 100644 install/integrationctl/agentplugins/providers/project_opencode_stager_test.go create mode 100644 install/integrationctl/agentplugins/providers/project_opencode_test.go diff --git a/cli/cmd/agentplugins/compose.go b/cli/cmd/agentplugins/compose.go index 327ba072b..295341bff 100644 --- a/cli/cmd/agentplugins/compose.go +++ b/cli/cmd/agentplugins/compose.go @@ -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, diff --git a/cli/cmd/agentplugins/project_opencode_e2e_test.go b/cli/cmd/agentplugins/project_opencode_e2e_test.go new file mode 100644 index 000000000..8148a845a --- /dev/null +++ b/cli/cmd/agentplugins/project_opencode_e2e_test.go @@ -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") +} diff --git a/cli/cmd/agentplugins/project_opencode_fixture_test.go b/cli/cmd/agentplugins/project_opencode_fixture_test.go new file mode 100644 index 000000000..242d049b1 --- /dev/null +++ b/cli/cmd/agentplugins/project_opencode_fixture_test.go @@ -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 +} diff --git a/docs/OPENCODE_PROJECT_SCOPE.md b/docs/OPENCODE_PROJECT_SCOPE.md new file mode 100644 index 000000000..40bf9875c --- /dev/null +++ b/docs/OPENCODE_PROJECT_SCOPE.md @@ -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 --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). diff --git a/docs/plans/project-install-e2e/client-plans.md b/docs/plans/project-install-e2e/client-plans.md index 76e87fa89..455d206dd 100644 --- a/docs/plans/project-install-e2e/client-plans.md +++ b/docs/plans/project-install-e2e/client-plans.md @@ -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 | diff --git a/docs/plans/project-install-e2e/opencode-code-review-r1.md b/docs/plans/project-install-e2e/opencode-code-review-r1.md new file mode 100644 index 000000000..b1d24bca7 --- /dev/null +++ b/docs/plans/project-install-e2e/opencode-code-review-r1.md @@ -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. diff --git a/docs/plans/project-install-e2e/opencode-code-review-r2.md b/docs/plans/project-install-e2e/opencode-code-review-r2.md new file mode 100644 index 000000000..1c1c7febb --- /dev/null +++ b/docs/plans/project-install-e2e/opencode-code-review-r2.md @@ -0,0 +1,95 @@ +# OpenCode project adapter independent implementation review R2 + +Reviewer: cursor_clients, 2026-10-23. Initial reviewed checkpoint +`f94906f42e788791e15d198127144ad8fe2c40f2` in `work/uap-project-opencode`, +including production adapter `79cc15cf`, qualification fixes `69f97713` and the +public composed saved lifecycle. Governing instructions, original two-GO +client plan and author implementation evidence were read. + +## Initial decision: NO GO pending selected-data authority correction + +`clients/opencode/project.go` ProjectActiveNative ignores its selected +data-path argument and derives MCP ownership from the old package's stored +OpenCodeProjection.DataRoot. Native projection validation only requires that +stored data path be absolute; it does not compare it with the independently +selected DataReceipt.Locator supplied by core. Thus a read-only reprojection +can silently acknowledge old-locator desired receipts when the selected data +authority differs. The corresponding Cursor adapter review found the same +class of issue. This is not a request to rewrite immutable package bytes. + +Required correction: on the project branch, read and validate the existing +projection against selected native/config/package authority and compare the +stdio data locator with the selected data-path argument. A mismatch must +refuse or require refreshed projection. Add a correct-data positive case and +wrong-data refusal with unchanged immutable bytes. Author/core acknowledged +and is implementing this narrow correction; no reviewer-owned production +edit was made. + +## Boundaries reviewed without another blocker so far + +- Resolved project layout keeps root `opencode.json[c]` separate from + `.opencode/skills`, preserves the original detected user ConfigRoot and + validates explicit target context/native root through projection, registry, + apply, verify and removal. Containment refuses native aliases. +- Native ownership validates project object IDs/names/exact paths. Skills + remain leaf paths; MCP keys retain native slash/space semantics and are not + converted into filenames. Unowned/disabled foreign MCP entries reserve their + keys and cannot be adopted. JSONC preservation and exact receipt/conditional + kernel behavior remain in use. +- Existing durable dialect transition schema retains binding scope and native + root authority. Project pending/reconciliation selection requires an equal + explicit ProjectRoot, rejects missing/different project contexts before + mutation, and derives private skill transaction roots from the stored split + layout. Current state and recorded old-state layouts must agree. Project + committed state remains prepared plus installed verification. +- Activation preflight precedes generic attestation admission and direct + activation reuse. Exact configured bytes do not become effective native + config/discovery/permissions/MCP proof. User lifecycle and legacy user helper + layouts remain separate. +- Production recorder constructions pass the selected ProjectRoot in CLI and + installer service/observation/recovery. Namespace preflight derives the root + from neutral validated context without importing a concrete client adapter. +- The public composed journey uses real TLS Git with an explicitly inert + version-only TEST process. It covers saved A/B isolation, locked no-op, + exact-old-pin repair after source advance, interrupted directory recovery, + moving-source update, conservative status, retained data and exact/repeated + removal. Those are fixture contracts; genuine native discovery/runtime and + the generic Git/ZIP transfer proof remain separate evidence. + +## Independent verification and fix follow-up + +After coordinated capacity recovery, independently inspected frozen correction +`90fcc0af34008a4416758c0c9d83678e72323d5d`. Project active reprojection now +validates the scope-selected split native layout, exact active root and +contained package path, original config candidates and package root. A stdio +selection requires the caller's nonempty clean absolute data locator to equal +the immutable stored DataRoot. Mismatch returns the existing projection-refresh +error instead of rewriting or acknowledging old package bindings. Existing +user reprojection behavior remains intact. + +The new regression supplies matching package/data authority and requires +identical desired objects. Empty/relative/different-project/different-receipt +data and a mismatched active package root refuse. It checks unchanged package, +native, user and project-B byte inventories after the read-only refusals. + +Independent Go 1.26.10 execution with GOMAXPROCS=3, `-p=2 -count=1`: + +- `./clients/opencode ./providers -run '^TestProjectOpenCode|^TestNativeTransition'`: + client PASS, 0.964s; providers PASS, 0.143s. This includes the project durable + crash/reopen boundaries, selected-context refusal and new data contract. +- Production public CLI `./cmd/agentplugins -run '^TestProjectOpenCodeComposedSavedLifecycle$'`: + PASS, 3.526s. + +The compiled crash/version helpers are explicitly inert TEST processes. No +genuine OpenCode process, authored MCP command, package payload, model call, +login, trust or account action was run by this review. + +## Final decision: GO at corrected checkpoint 90fcc0af + +The selected-data blocker is closed, with no remaining implementation blocker +found in the reviewed scope. GO for configured project components, scoped +durable recovery and the composed saved lifecycle. Effective native config, +real skills/MCP discovery and harmless runtime markers, precedence and fresh +native restart/removal remain genuine hosted evidence gates. The passing +fixture journey and this review do not establish those facts or release +readiness. diff --git a/docs/plans/project-install-e2e/opencode-implementation-evidence.md b/docs/plans/project-install-e2e/opencode-implementation-evidence.md new file mode 100644 index 000000000..d59e7d05a --- /dev/null +++ b/docs/plans/project-install-e2e/opencode-implementation-evidence.md @@ -0,0 +1,172 @@ +# OpenCode project implementation evidence + +Status: scoped adapter, catalog and public composed lifecycle checkpoint, +2026-10-23. +The original all-client plan and its two independent reviews remain +authoritative. Both independent implementation reviews are GO at corrected +checkpoint `90fcc0af`; hosted native qualification remains a separate pending +gate. + +## Native layout and authority + +The accepted native paths are root `opencode.json` or `opencode.jsonc` for MCP +configuration and `.opencode/skills//SKILL.md` for skills. The original +detected user `ConfigRoot` is preserved. The internal resolved layout carries +both native locations through projection, inspection, apply, verification, +removal and the existing dialect transaction/recovery code. Legacy user helper +entrypoints continue to select their original `/skills` layout. +No package path, changed ambient cwd or fake user profile selects project scope. + +The existing namespace preflight validates the explicit selected target context +and uses its exact project config candidates, without reaching into a concrete +adapter from the generic provider. A disabled same-name server in the selected project is a +collision; the distinct preserved user config is not mistaken for the project +registry. Exact project object IDs, names, absolute paths and containment are +checked before native writes. + +The public current documentation was checked on 2026-10-23: + +- [Configuration](https://opencode.ai/docs/config/) documents JSON/JSONC, + root project config, upward discovery to the Git boundary and merging of + user, project, custom-directory, inline and managed settings. +- [Skills](https://opencode.ai/docs/skills/) documents `.opencode/skills`, + compatible `.claude`/`.agents` paths and their ancestor discovery. +- [MCP](https://opencode.ai/docs/mcp-servers/) describes native local/remote + configuration; transport/dialect eligibility still comes from the existing + immutable prepared host authority, rather than guessing from config content. + +The existing hosted native matrix pins OpenCode 1.18.34 with per-platform +artifact integrity. This checkpoint does not change that pin or execute the +hosted runner locally. + +## Existing durable transaction + +No new journal schema or native mutation engine was added. Version1 private +OpenCode records already bind complete old/target state, including each +ClientBinding.Scope and NativeProfileRoot. Recovery resolves the split layout +from that binding authority and requires the selected ProjectRoot to agree. +A project recorder with missing or different context refuses discovery and +reconciliation before native mutation. All production recorder constructions +receive the selected root, including CLI lifecycle and installer observation, +service and recovery. + +The existing exclusive skill moves, receipt-bound MCP dialect transition, +state disposition, uncertainty, retained backups and committed-cleanup behavior +remain in use. The legacy skill kind spelling is retained as a receipt codec +identifier; scope and the exact native path determine project ownership. + +Successful project file configuration is conservatively prepared activation +with installed verification. Generic activation attestation cannot establish +native discovery and is refused before the provider shortcut and on direct +adapter reuse. Effective config, permissions, native skill discovery and MCP +initialization remain unobserved. User activation behavior is preserved. + +## Executed contract evidence + +Final complete OpenCode package: PASS1.035s. After adding scoped contracts, +planner/stager qualification and the namespace correction, the focused run passed: + +```sh +go test -p=1 ./install/integrationctl/agentplugins/clients/opencode \ + ./install/integrationctl/agentplugins/providers \ + ./install/integrationctl/agentplugins/domain \ + ./install/integrationctl/agentplugins/planner \ + -run '^TestProject|^TestNativeTransition|^TestCompatibilityGolden|^TestPlanGolden' \ + -count=1 -timeout=2m +``` + +Final focused results: OpenCode PASS1.197s, providers PASS0.181s, domain +PASS0.004s and planner PASS0.016s. These are production provider, +native-kernel, filesystem and compiled inert TEST-process contracts, not real +OpenCode runtime evidence. They cover: + +- root JSONC comments/foreign disabled entries with skills in `.opencode`, + repeated automatic/verify-only configuration and same-pin missing native + skill/MCP repair; +- exact repeat removal, untouched user/project-B inventories and refusal of + tampered IDs/names/paths, wrong selected roots and activation attestation; +- selected-project namespace collision checks, exact slash/space MCP keys + without filename normalization, and contained native alias refusal; +- production loader/planner/command-renderer/stager/activator composition, + rendered command skill ownership and read-only active native reprojection; +- six actual abrupt TEST-process crash boundaries for both existing user and + project v1-to-v2 transitions: prepared record, backup, skill publication, + native config, state and cleanup, followed by reopened idempotent recovery; +- selected project context refusal before recovery, with native bytes/state + unchanged and durable pending authority retained. + +The installer and production CLI composition packages compiled successfully at +the new context seams. The focused regex had no matching tests there, so this +is not a claim of installer/CLI lifecycle execution at this checkpoint. + +## Scope and gate assessment + +The OpenCode catalog row adds only its qualified project native-config policy. +The four compatibility golden changes are exactly OpenCode's Scopes array from +[user] to [user, project]; a deep comparison checked that all other values are +unchanged. User planning goldens pass unchanged. + +Exact golangci-lint2.13.2 changed-code and whole-file size/complexity/architecture +gates both report zero issues. The initial concrete-adapter import violation +was removed from the generic namespace provider. One complexity finding was +fixed by extracting unchanged managed-context validation. No baseline, +architecture exception, hosted guard or native artifact pin was changed. + +## Remaining checkpoint work + +- Hosted native effective-config/skill/MCP discovery and harmless marker proof, + with A/B/user precedence, fresh restart and removal observations. Higher + priority inline/managed/custom directory and ancestor behavior must be + observed by the real pinned host; file presence cannot establish activation. + +## Public composed saved lifecycle + +The production public CLI composition now has a dedicated OpenCode saved +project journey. Real TLS Git acquisition, standard loading/planning/staging, +immutable storage, installer, native kernel and receipt/state publication are +used throughout. The native version-contract boundary is the existing compiled +inert TEST process, limited to `--version`; it supplies no real OpenCode native +discovery or runtime evidence. Production host preparation and exact executable +revalidation still execute against those fixture bytes. + +The test installs saved dependencies into projects A/B and preserves the +distinct user config. Unchanged locked sync must retain exact state bytes. +Managed package drift and a missing native skill are repaired from the old +immutable pin even after the Git source advances. Durable interrupted directory +recovery and repeat recovery preserve committed native config and caller-owned +staging. Moving-source update must preserve source/binding/data authority; +saved status reports project/prepared. Removal consumes exact owned skills and +MCP keys, retains foreign disabled configuration/comments/skills and persistent +data, and repeat removal has no further project effects. User and all project-B +files remain byte-identical. + +Final dedicated public composition contract: **PASS 3.345s**, Go 1.26.10, +GOMAXPROCS=3, `-p=2`, count1. Exact golangci-lint2.13.2 changed-code and whole-file +size/architecture gates on the affected public CLI package both report +**0 issues**. No adapter production code, native guard or lint policy changed +for this proof. The generic source-core Git/ZIP cold-restore/transfer suite +remains separate; this is not a transport-by-client cross-product claim. + +## Independent R2 correction: active projection data authority + +R2 identified that project ProjectActiveNative ignored the selected dataPath +supplied by core after validating the persisted data receipt. The existing +stored projection could therefore retain its old DataRoot while presenting +desired receipts for a different selected locator. + +The project-only active check now validates matching target scope/layout, +exact active package root, containment, native config roots and stored +PackageRoot before deriving receipts. For stdio, the selected data locator must +be nonempty, clean and absolute, and equal the stored immutable DataRoot. A +mismatch returns ErrProjectionRefreshRequired without rewriting package bytes. +The existing user projection path is preserved. + +The focused regression includes a correct selected locator plus missing, +relative, another-project and different-receipt locators, and a mismatching +active package root. All failures must leave package/native/user/project-B +inventories unchanged. Project provider contracts pass **0.022s**; the public +saved project lifecycle passes **3.395s** with its real stdio data journey. Both +exact golangci-lint2.13.2 changed-code and whole-file size/architecture gates +report **0 issues**. Both independent reviewers inspected this correction and +closed GO at `90fcc0af`; their reports are recorded alongside this evidence. +No native host or model execution was added. diff --git a/install/integrationctl/agentplugins/clients/opencode/activate.go b/install/integrationctl/agentplugins/clients/opencode/activate.go index ad3fa28e8..ae4f04225 100644 --- a/install/integrationctl/agentplugins/clients/opencode/activate.go +++ b/install/integrationctl/agentplugins/clients/opencode/activate.go @@ -17,8 +17,18 @@ func (*Adapter) AutomaticallyActivates(_ clients.Env, request domain.ActivationR func automaticallyActivatesOpenCode(request domain.ActivationRequest) bool { // Removing the final declarations still reconciles owned native objects. // Keep empty first installs manual and preserve non-native plan restrictions. - cleanup := request.Client.ConfigRoot != "" && len(request.Plan.Components) == 0 && len(OpenCodeObjects(request.PreviousNativeObjects)) > 0 - return request.Plan.InstallIntent == domain.InstallIntentAutomatic && (shared.HasNativeConfigRoot(request) || cleanup) + root := request.Client.ConfigRoot + native := shared.HasNativeConfigRoot(request) + if request.Plan.Scope == domain.ScopeProject { + layout, err := openCodeActivationLayout(request) + if err != nil { + return false + } + root = layout.Root + native = root != "" && shared.OnlyNativeComponents(request.Plan.Components) + } + cleanup := root != "" && len(request.Plan.Components) == 0 && len(OpenCodeObjects(request.PreviousNativeObjects)) > 0 + return request.Plan.InstallIntent == domain.InstallIntentAutomatic && (native || cleanup) } // VerifierAvailable reports that an exact native configuration listing can be @@ -29,7 +39,7 @@ func (*Adapter) VerifierAvailable(_ domain.DetectedClient, plan domain.DeliveryP // Activate installs or verifies managed OpenCode skills and MCP servers // through the native config kernel. -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.VerifyOnly && request.Client.OpenCodeHost != nil { if err := request.Client.OpenCodeHost.ValidateNative(false, nil); err != nil { return domain.ActivationOutcome{}, err @@ -38,6 +48,13 @@ func (*Adapter) Activate(ctx context.Context, env clients.Env, request domain.Ac if err := shared.ActivationIdentityMismatch(request); err != nil { return domain.ActivationOutcome{}, err } + layout, err := openCodeActivationLayout(request) + if err != nil { + return domain.ActivationOutcome{}, err + } + if err := adapter.PreflightActivation(env, request); err != nil { + return domain.ActivationOutcome{}, err + } automatic := automaticallyActivatesOpenCode(request) outcome, err := shared.CompleteNativeConfigActivation(ctx, request, shared.NativeConfigActivation{ Automatic: automatic, @@ -46,7 +63,7 @@ func (*Adapter) Activate(ctx context.Context, env clients.Env, request domain.Ac RetryAction: "retry the managed OpenCode native installation", CompletedAction: "restart OpenCode to load the installed plugin", Verify: func() error { - return VerifyOpenCodeNativeObjects(request.Client.ConfigRoot, request.Delivery.ActivePath, request.Delivery.NativeObjects, env.NativeConfig, false) + return verifyOpenCodeNativeLayout(layout, request.Delivery.ActivePath, request.Delivery.NativeObjects, env.NativeConfig, false) }, Activate: func(ctx context.Context, request domain.ActivationRequest) error { if crossOpenCodeDialect(request.PreviousNativeObjects, request.Delivery.NativeObjects) { @@ -55,12 +72,18 @@ func (*Adapter) Activate(ctx context.Context, env clients.Env, request domain.Ac return ActivateOpenCodeNativeWithKernel(ctx, request, env.NativeConfig) }, }) - return outcome, err + if err != nil { + return outcome, err + } + return openCodeProjectStatus(request, outcome), nil } // Deactivate removes managed OpenCode native objects. func (*Adapter) Deactivate(ctx context.Context, env clients.Env, request domain.DeactivationRequest) (domain.DeactivationOutcome, error) { outcome := shared.StartedDeactivation() + if _, err := openCodeTargetLayout(request.Client, request.TargetContext, request.NativeRegistryRoot); err != nil { + return outcome, err + } if !request.Confirmed { outcome.UserActions = append(outcome.UserActions, "agentplugins will remove its managed OpenCode skills and MCP entries automatically") return outcome, nil diff --git a/install/integrationctl/agentplugins/clients/opencode/identity.go b/install/integrationctl/agentplugins/clients/opencode/identity.go index 1666721f5..b6da07c1b 100644 --- a/install/integrationctl/agentplugins/clients/opencode/identity.go +++ b/install/integrationctl/agentplugins/clients/opencode/identity.go @@ -2,6 +2,7 @@ package opencode import ( "context" + "fmt" "os" "path/filepath" "strings" @@ -27,10 +28,16 @@ func InspectOpenCodeRegistry(plan domain.DeliveryPlan, managed *domain.ClientBin if managed == nil && !hasPlannedOpenCodeNative(plan) { return clients.RegistryClear, nil } - root := strings.TrimSpace(plan.NativeRegistryRoot) - if root == "" { + if strings.TrimSpace(plan.NativeRegistryRoot) == "" { return clients.RegistryIndeterminate, nil } + layout, err := openCodePlanLayout(plan) + if err != nil { + return clients.RegistryIndeterminate, err + } + if err := validateOpenCodeManagedContext(layout, managed); err != nil { + return clients.RegistryIndeterminate, err + } codec, err := observedOpenCodeCodec(plan, managed) if err != nil { return clients.RegistryIndeterminate, err @@ -40,7 +47,7 @@ func InspectOpenCodeRegistry(plan domain.DeliveryPlan, managed *domain.ClientBin if component.Support == domain.SupportUnsupported { continue } - exists, owned, err := inspectOpenCodeComponent(root, managed, component, kernel, codec) + exists, owned, err := inspectOpenCodeComponent(layout, managed, component, kernel, codec) if err != nil { return clients.RegistryIndeterminate, err } @@ -84,22 +91,23 @@ func observedOpenCodeCodec(plan domain.DeliveryPlan, managed *domain.ClientBindi return nativeconfig.CodecOpenCode, nil } -func inspectOpenCodeComponent(root string, managed *domain.ClientBinding, component domain.ComponentDecision, kernel nativeconfig.Kernel, codec nativeconfig.Codec) (bool, bool, error) { +func inspectOpenCodeComponent(layout openCodeNativeLayout, managed *domain.ClientBinding, component domain.ComponentDecision, kernel nativeconfig.Kernel, codec nativeconfig.Codec) (bool, bool, error) { switch component.Kind { case domain.ComponentSkill: - return inspectOpenCodeSkillComponent(root, managed, component.Name) + return inspectOpenCodeSkillComponent(layout, managed, component.Name) case domain.ComponentMCPServer: - return inspectOpenCodeMCPComponent(root, managed, component.Name, kernel, codec) + return inspectOpenCodeMCPComponent(layout, managed, component.Name, kernel, codec) default: return false, false, nil } } -func inspectOpenCodeSkillComponent(root string, managed *domain.ClientBinding, name string) (bool, bool, error) { +func inspectOpenCodeSkillComponent(layout openCodeNativeLayout, managed *domain.ClientBinding, name string) (bool, bool, error) { if err := pathpolicy.ValidateLeafID(name); err != nil { return false, false, err } - path := filepath.Join(root, "skills", name) + root := layout.Root + path := filepath.Join(layout.Skills, name) if err := pathpolicy.RequireContainedChild(root, path); err != nil { return false, false, err } @@ -111,7 +119,8 @@ func inspectOpenCodeSkillComponent(root string, managed *domain.ClientBinding, n return err == nil, owned, nil } -func inspectOpenCodeMCPComponent(root string, managed *domain.ClientBinding, name string, kernel nativeconfig.Kernel, codec nativeconfig.Codec) (bool, bool, error) { +func inspectOpenCodeMCPComponent(layout openCodeNativeLayout, managed *domain.ClientBinding, name string, kernel nativeconfig.Kernel, codec nativeconfig.Codec) (bool, bool, error) { + root := layout.Root paths := nativeconfig.Paths{JSON: filepath.Join(root, "opencode.json"), JSONC: filepath.Join(root, "opencode.jsonc")} if err := pathpolicy.RequireContainedChild(root, paths.JSON); err != nil { return false, false, err @@ -127,7 +136,7 @@ func inspectOpenCodeMCPComponent(root string, managed *domain.ClientBinding, nam return false, false, err } if mcp && object.LogicalName == name { - if err := validateOpenCodeObject(root, OpenCodeProjection{}, object); err != nil { + if err := validateOpenCodeObject(layout, OpenCodeProjection{}, object); err != nil { return false, false, err } codec = stored @@ -150,3 +159,18 @@ func managedOpenCodeObjectExists(objects []domain.NativeObjectOwnership, kind, n } return false } + +func validateOpenCodeManagedContext(layout openCodeNativeLayout, managed *domain.ClientBinding) error { + if managed == nil || layout.Scope != domain.ScopeProject { + return nil + } + if managed.Scope != string(domain.ScopeProject) || managed.NativeProfileRoot != layout.Root { + return fmt.Errorf("opencode ownership differs from the selected project") + } + for _, object := range OpenCodeObjects(managed.NativeObjects) { + if err := validateOpenCodeObject(layout, OpenCodeProjection{}, object); err != nil { + return err + } + } + return nil +} diff --git a/install/integrationctl/agentplugins/clients/opencode/native_apply.go b/install/integrationctl/agentplugins/clients/opencode/native_apply.go index d81a8b8da..c7c49993b 100644 --- a/install/integrationctl/agentplugins/clients/opencode/native_apply.go +++ b/install/integrationctl/agentplugins/clients/opencode/native_apply.go @@ -18,14 +18,22 @@ func ActivateOpenCodeNativeWithKernel(ctx context.Context, request domain.Activa if err := ctx.Err(); err != nil { return err } - return applyOpenCodeNativeWithKernel(request.Client.ConfigRoot, request.Delivery.ActivePath, request.PreviousNativeObjects, request.Delivery.NativeObjects, kernel) + layout, err := openCodeActivationLayout(request) + if err != nil { + return err + } + return applyOpenCodeNativeLayoutWithKernel(layout, request.Delivery.ActivePath, request.PreviousNativeObjects, request.Delivery.NativeObjects, kernel) } func DeactivateOpenCodeNativeWithKernel(ctx context.Context, request domain.DeactivationRequest, kernel nativeconfig.Kernel) error { if err := ctx.Err(); err != nil { return err } - return applyOpenCodeNativeWithKernel(request.Client.ConfigRoot, "", request.NativeObjects, nil, kernel) + layout, err := openCodeTargetLayout(request.Client, request.TargetContext, request.NativeRegistryRoot) + if err != nil { + return err + } + return applyOpenCodeNativeLayoutWithKernel(layout, "", request.NativeObjects, nil, kernel) } func ApplyOpenCodeNative(configRoot, activePath string, previous, desired []domain.NativeObjectOwnership) error { @@ -33,7 +41,11 @@ func ApplyOpenCodeNative(configRoot, activePath string, previous, desired []doma } func applyOpenCodeNativeWithKernel(configRoot, activePath string, previous, desired []domain.NativeObjectOwnership, kernel nativeconfig.Kernel) error { - return applyOpenCodeNativeWithKernelAndOps(configRoot, activePath, previous, desired, kernel, shared.RenameDirectoryExclusive, os.RemoveAll) + return applyOpenCodeNativeLayoutWithKernel(userOpenCodeLayout(configRoot), activePath, previous, desired, kernel) +} + +func applyOpenCodeNativeLayoutWithKernel(layout openCodeNativeLayout, activePath string, previous, desired []domain.NativeObjectOwnership, kernel nativeconfig.Kernel) error { + return applyOpenCodeNativeLayoutWithOps(layout, activePath, previous, desired, kernel, shared.RenameDirectoryExclusive, os.RemoveAll) } func ApplyOpenCodeNativeWithRename(configRoot, activePath string, previous, desired []domain.NativeObjectOwnership, rename openCodeRenameFunc) (resultErr error) { @@ -45,7 +57,11 @@ func ApplyOpenCodeNativeWithOps(configRoot, activePath string, previous, desired } func applyOpenCodeNativeWithKernelAndOps(configRoot, activePath string, previous, desired []domain.NativeObjectOwnership, kernel nativeconfig.Kernel, rename openCodeRenameFunc, removeAll func(string) error) (resultErr error) { - prepared, err := prepareOpenCodeNativeApply(configRoot, activePath, previous, desired, kernel, rename, removeAll) + return applyOpenCodeNativeLayoutWithOps(userOpenCodeLayout(configRoot), activePath, previous, desired, kernel, rename, removeAll) +} + +func applyOpenCodeNativeLayoutWithOps(layout openCodeNativeLayout, activePath string, previous, desired []domain.NativeObjectOwnership, kernel nativeconfig.Kernel, rename openCodeRenameFunc, removeAll func(string) error) (resultErr error) { + prepared, err := prepareOpenCodeNativeApplyLayout(layout, activePath, previous, desired, kernel, rename, removeAll) if err != nil { return &shared.NativeEffectError{Effect: domain.NativeEffectUnchanged, Err: err} } @@ -53,7 +69,7 @@ func applyOpenCodeNativeWithKernelAndOps(configRoot, activePath string, previous if err != nil { return &shared.NativeEffectError{Effect: domain.NativeEffectUnchanged, Err: err} } - skills, err := installOpenCodeSkillsWithOps(configRoot, activePath, prepared.previous, prepared.desired, prepared.rename, prepared.removeAll) + skills, err := installOpenCodeSkillsWithLayout(layout, activePath, prepared.previous, prepared.desired, prepared.rename, prepared.removeAll) if err != nil { return err } @@ -77,6 +93,7 @@ func knownOpenCodePreWriteError(err error) bool { } type openCodeNativeApply struct { + layout openCodeNativeLayout rename openCodeRenameFunc removeAll func(string) error kernel nativeconfig.Kernel @@ -86,6 +103,14 @@ type openCodeNativeApply struct { } func prepareOpenCodeNativeApply(configRoot, activePath string, previous, desired []domain.NativeObjectOwnership, kernel nativeconfig.Kernel, rename openCodeRenameFunc, removeAll func(string) error) (*openCodeNativeApply, error) { + return prepareOpenCodeNativeApplyLayout(userOpenCodeLayout(configRoot), activePath, previous, desired, kernel, rename, removeAll) +} + +func prepareOpenCodeNativeApplyLayout(layout openCodeNativeLayout, activePath string, previous, desired []domain.NativeObjectOwnership, kernel nativeconfig.Kernel, rename openCodeRenameFunc, removeAll func(string) error) (*openCodeNativeApply, error) { + configRoot := layout.Root + if err := layout.validate(); err != nil { + return nil, err + } if err := kernel.RequireFileIO(); err != nil { return nil, err } @@ -106,20 +131,20 @@ func prepareOpenCodeNativeApply(configRoot, activePath string, previous, desired if err != nil { return nil, err } - if err := validateOpenCodeProjection(configRoot, activePath, projection); err != nil { + if err := validateOpenCodeProjection(layout, activePath, projection); err != nil { return nil, err } } - if err := preflightOpenCodeNativeApply(configRoot, activePath, projection, previous, desired); err != nil { + if err := preflightOpenCodeNativeApply(layout, activePath, projection, previous, desired); err != nil { return nil, err } if err := confirmOpenCodeConfigSelection(configRoot, projection, previous, desired); err != nil { return nil, err } - return &openCodeNativeApply{rename: rename, removeAll: removeAll, kernel: kernel, projection: projection, previous: previous, desired: desired}, nil + return &openCodeNativeApply{layout: layout, rename: rename, removeAll: removeAll, kernel: kernel, projection: projection, previous: previous, desired: desired}, nil } -func preflightOpenCodeNativeApply(configRoot, activePath string, projection OpenCodeProjection, previous, desired []domain.NativeObjectOwnership) error { +func preflightOpenCodeNativeApply(layout openCodeNativeLayout, activePath string, projection OpenCodeProjection, previous, desired []domain.NativeObjectOwnership) error { if crossOpenCodeDialect(previous, desired) { candidate := &openCodeNativeApply{projection: projection, previous: previous, desired: desired} if _, err := openCodeTransitionRequest(candidate); err != nil { @@ -127,15 +152,15 @@ func preflightOpenCodeNativeApply(configRoot, activePath string, projection Open } for _, objects := range [][]domain.NativeObjectOwnership{previous, desired} { for _, object := range objects { - if err := validateOpenCodeObject(configRoot, projection, object); err != nil { + if err := validateOpenCodeObject(layout, projection, object); err != nil { return err } } } - if err := preflightDesiredOpenCodeObjects(configRoot, projection, shared.ObjectMap(previous), shared.ObjectMap(desired)); err != nil { + if err := preflightDesiredOpenCodeObjects(layout, projection, shared.ObjectMap(previous), shared.ObjectMap(desired)); err != nil { return err } - } else if err := preflightOpenCodeObjects(configRoot, activePath, projection, previous, desired); err != nil { + } else if err := preflightOpenCodeObjects(layout, activePath, projection, previous, desired); err != nil { return err } return nil diff --git a/install/integrationctl/agentplugins/clients/opencode/native_mcp.go b/install/integrationctl/agentplugins/clients/opencode/native_mcp.go index 14dc18c09..54b79eefd 100644 --- a/install/integrationctl/agentplugins/clients/opencode/native_mcp.go +++ b/install/integrationctl/agentplugins/clients/opencode/native_mcp.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "sort" + "strings" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" @@ -15,6 +16,14 @@ import ( ) func VerifyOpenCodeNativeObjects(configRoot, activePath string, objects []domain.NativeObjectOwnership, kernel nativeconfig.Kernel, allowMissing bool) error { + return verifyOpenCodeNativeLayout(userOpenCodeLayout(configRoot), activePath, objects, kernel, allowMissing) +} + +func verifyOpenCodeNativeLayout(layout openCodeNativeLayout, activePath string, objects []domain.NativeObjectOwnership, kernel nativeconfig.Kernel, allowMissing bool) error { + configRoot := layout.Root + if err := layout.validate(); err != nil { + return err + } if err := kernel.RequireFileIO(); err != nil { return err } @@ -25,20 +34,20 @@ func VerifyOpenCodeNativeObjects(configRoot, activePath string, objects []domain if err != nil { return err } - if err := validateOpenCodeProjection(configRoot, activePath, projection); err != nil { + if err := validateOpenCodeProjection(layout, activePath, projection); err != nil { return err } } for _, object := range OpenCodeObjects(objects) { - if err := verifyOpenCodeObject(configRoot, projection, object, kernel, allowMissing); err != nil { + if err := verifyOpenCodeObject(layout, projection, object, kernel, allowMissing); err != nil { return err } } return nil } -func verifyOpenCodeObject(configRoot string, projection OpenCodeProjection, object domain.NativeObjectOwnership, kernel nativeconfig.Kernel, allowMissing bool) error { - if err := validateOpenCodeObject(configRoot, projection, object); err != nil { +func verifyOpenCodeObject(layout openCodeNativeLayout, projection OpenCodeProjection, object domain.NativeObjectOwnership, kernel nativeconfig.Kernel, allowMissing bool) error { + if err := validateOpenCodeObject(layout, projection, object); err != nil { return err } if object.Kind == openCodeSkillKind { @@ -76,7 +85,8 @@ func verifyOpenCodeMCP(projection OpenCodeProjection, object domain.NativeObject return nil } -func validateOpenCodeProjection(configRoot, activePath string, projection OpenCodeProjection) error { +func validateOpenCodeProjection(layout openCodeNativeLayout, activePath string, projection OpenCodeProjection) error { + configRoot := layout.Root if !shared.SameCleanPath(projection.ConfigJSON, filepath.Join(configRoot, "opencode.json")) || !shared.SameCleanPath(projection.ConfigJSONC, filepath.Join(configRoot, "opencode.jsonc")) || !shared.SameCleanPath(projection.PackageRoot, activePath) { @@ -243,24 +253,47 @@ func openCodeMCPDesiredRequests(kernel nativeconfig.Kernel, paths nativeconfig.P return result, nil } -func validateOpenCodeObject(configRoot string, projection OpenCodeProjection, object domain.NativeObjectOwnership) error { +func validateOpenCodeObject(layout openCodeNativeLayout, projection OpenCodeProjection, object domain.NativeObjectOwnership) error { if object.LogicalName == "" || object.ManagedDigest == "" { return fmt.Errorf("OpenCode native object is incomplete") } if _, _, err := nativeconfig.OpenCodeCodecForKind(object.Kind); err != nil { return err } - expected := expectedOpenCodeObjectPath(configRoot, projection, object) + if layout.Scope == domain.ScopeProject { + if err := validateProjectOpenCodeObjectIdentity(object); err != nil { + return err + } + } + expected := expectedOpenCodeObjectPath(layout, projection, object) if expected == "" { return fmt.Errorf("unsupported OpenCode native object kind %q", object.Kind) } if !shared.SameCleanPath(expected, object.Path) { return fmt.Errorf("OpenCode native object %q has an untrusted path", object.LogicalName) } - return pathpolicy.RequireContainedChild(configRoot, object.Path) + return pathpolicy.RequireContainedChild(layout.Root, object.Path) +} + +func validateProjectOpenCodeObjectIdentity(object domain.NativeObjectOwnership) error { + prefix := "opencode-mcp:" + if object.Kind == openCodeSkillKind { + if object.LogicalName != strings.TrimSpace(object.LogicalName) { + return fmt.Errorf("opencode project skill name is padded") + } + if err := pathpolicy.ValidateLeafID(object.LogicalName); err != nil { + return err + } + prefix = "opencode-skill:" + } + if object.ObjectID != prefix+object.LogicalName || !filepath.IsAbs(object.Path) || filepath.Clean(object.Path) != object.Path { + return fmt.Errorf("opencode project object identity is not exact") + } + return nil } -func expectedOpenCodeObjectPath(configRoot string, projection OpenCodeProjection, object domain.NativeObjectOwnership) string { +func expectedOpenCodeObjectPath(layout openCodeNativeLayout, projection OpenCodeProjection, object domain.NativeObjectOwnership) string { + configRoot := layout.Root switch object.Kind { case OpenCodeMCPObjectKind, OpenCodeV2MCPObjectKind: if projection.ConfigPath != "" { @@ -271,32 +304,32 @@ func expectedOpenCodeObjectPath(configRoot string, projection OpenCodeProjection } return "" case openCodeSkillKind: - return filepath.Join(configRoot, "skills", object.LogicalName) + return filepath.Join(layout.Skills, object.LogicalName) default: return "" } } -func preflightOpenCodeObjects(configRoot, activePath string, projection OpenCodeProjection, previous, desired []domain.NativeObjectOwnership) error { +func preflightOpenCodeObjects(layout openCodeNativeLayout, activePath string, projection OpenCodeProjection, previous, desired []domain.NativeObjectOwnership) error { if err := preflightOpenCodeCodecs(projection, previous, desired); err != nil { return err } previousByID, desiredByID := shared.ObjectMap(previous), shared.ObjectMap(desired) for _, object := range previous { - if err := validateOpenCodeObject(configRoot, projection, object); err != nil { + if err := validateOpenCodeObject(layout, projection, object); err != nil { return err } } - if err := preflightDesiredOpenCodeObjects(configRoot, projection, previousByID, desiredByID); err != nil { + if err := preflightDesiredOpenCodeObjects(layout, projection, previousByID, desiredByID); err != nil { return err } _ = activePath return nil } -func preflightDesiredOpenCodeObjects(configRoot string, projection OpenCodeProjection, previousByID, desiredByID map[string]domain.NativeObjectOwnership) error { +func preflightDesiredOpenCodeObjects(layout openCodeNativeLayout, projection OpenCodeProjection, previousByID, desiredByID map[string]domain.NativeObjectOwnership) error { for id, object := range desiredByID { - if err := validateOpenCodeObject(configRoot, projection, object); err != nil { + if err := validateOpenCodeObject(layout, projection, object); err != nil { return err } if prior, replacing := previousByID[id]; replacing { diff --git a/install/integrationctl/agentplugins/clients/opencode/native_project.go b/install/integrationctl/agentplugins/clients/opencode/native_project.go index adaca6657..42518b599 100644 --- a/install/integrationctl/agentplugins/clients/opencode/native_project.go +++ b/install/integrationctl/agentplugins/clients/opencode/native_project.go @@ -26,7 +26,11 @@ func ProjectOpenCodeNative(root string, envelope domain.PackageEnvelope, plan do if empty { return rejectOpenCodeProjectionCollision(root) } - configRoot := strings.TrimSpace(plan.NativeRegistryRoot) + layout, err := openCodePlanLayout(plan) + if err != nil { + return err + } + configRoot := layout.Root if configRoot == "" || !filepath.IsAbs(configRoot) { return fmt.Errorf("OpenCode config root is unavailable") } @@ -327,7 +331,11 @@ func openCodeSkillOwnership(stagingRoot string, envelope domain.PackageEnvelope, if err != nil { return domain.NativeObjectOwnership{}, err } - target := filepath.Join(plan.NativeRegistryRoot, "skills", component.Name) + layout, err := openCodePlanLayout(plan) + if err != nil { + return domain.NativeObjectOwnership{}, err + } + target := filepath.Join(layout.Skills, component.Name) if err := pathpolicy.RequireContainedChild(plan.NativeRegistryRoot, target); err != nil { return domain.NativeObjectOwnership{}, err } diff --git a/install/integrationctl/agentplugins/clients/opencode/native_skills.go b/install/integrationctl/agentplugins/clients/opencode/native_skills.go index f190c1bbc..194df4c41 100644 --- a/install/integrationctl/agentplugins/clients/opencode/native_skills.go +++ b/install/integrationctl/agentplugins/clients/opencode/native_skills.go @@ -33,7 +33,7 @@ func renameOpenCodeDirectoryNoReplace(oldPath, newPath string, rename openCodeRe return rename(oldPath, newPath) } -func installOpenCodeSkillsWithOps(configRoot, activePath string, previous, desired []domain.NativeObjectOwnership, rename openCodeRenameFunc, removeAll func(string) error) (*openCodeSkillTxn, error) { +func installOpenCodeSkillsWithLayout(layout openCodeNativeLayout, activePath string, previous, desired []domain.NativeObjectOwnership, rename openCodeRenameFunc, removeAll func(string) error) (*openCodeSkillTxn, error) { if rename == nil { return nil, fmt.Errorf("OpenCode rename operation is unavailable") } @@ -44,7 +44,7 @@ func installOpenCodeSkillsWithOps(configRoot, activePath string, previous, desir if !containsOpenCodeSkill(previous) && !containsOpenCodeSkill(desired) { return txn, nil } - if err := txn.createRoot(configRoot); err != nil { + if err := txn.createLayoutRoot(layout); err != nil { return nil, err } if err := txn.stageAndSwap(activePath, previous, desired); err != nil { @@ -54,7 +54,11 @@ func installOpenCodeSkillsWithOps(configRoot, activePath string, previous, desir } func (txn *openCodeSkillTxn) createRoot(configRoot string) error { - skillsRoot := filepath.Join(configRoot, "skills") + return txn.createLayoutRoot(userOpenCodeLayout(configRoot)) +} + +func (txn *openCodeSkillTxn) createLayoutRoot(layout openCodeNativeLayout) error { + configRoot, skillsRoot := layout.Root, layout.Skills if err := pathpolicy.RequireContainedChild(configRoot, skillsRoot); err != nil { return err } @@ -67,7 +71,12 @@ func (txn *openCodeSkillTxn) createRoot(configRoot string) error { } txn.root = root if txn.durable { - if err := atomicfile.SyncDirectory(configRoot); err != nil { + if layout.Scope == domain.ScopeProject { + if err := atomicfile.SyncDirectory(configRoot); err != nil { + return err + } + } + if err := atomicfile.SyncDirectory(filepath.Dir(skillsRoot)); err != nil { return err } return atomicfile.SyncDirectory(skillsRoot) diff --git a/install/integrationctl/agentplugins/clients/opencode/native_transition.go b/install/integrationctl/agentplugins/clients/opencode/native_transition.go index 6a56fd211..e9976a8ca 100644 --- a/install/integrationctl/agentplugins/clients/opencode/native_transition.go +++ b/install/integrationctl/agentplugins/clients/opencode/native_transition.go @@ -6,7 +6,6 @@ import ( "fmt" "io/fs" "os" - "path/filepath" "runtime" "sort" @@ -135,7 +134,11 @@ func applyOpenCodeTransition(ctx context.Context, request domain.ActivationReque if recorder == nil || request.NativeAttempt.OperationID == "" { return unchanged(fmt.Errorf("durable native transition authority is required")) } - p, err := prepareOpenCodeNativeApply(request.Client.ConfigRoot, request.Delivery.ActivePath, request.PreviousNativeObjects, request.Delivery.NativeObjects, kernel, shared.RenameDirectoryExclusive, os.RemoveAll) + layout, err := openCodeActivationLayout(request) + if err != nil { + return unchanged(err) + } + p, err := prepareOpenCodeNativeApplyLayout(layout, request.Delivery.ActivePath, request.PreviousNativeObjects, request.Delivery.NativeObjects, kernel, shared.RenameDirectoryExclusive, os.RemoveAll) if err != nil { return unchanged(err) } @@ -155,7 +158,7 @@ func applyOpenCodeTransition(ctx context.Context, request domain.ActivationReque if err := recorder.PersistPrepared(ctx, request.NativeAttempt, txn.root, transitionDTO(prepared), request.PreviousNativeObjects, request.Delivery.NativeObjects); err != nil { return err } - return commitOpenCodeTransitionSkills(request.Client.ConfigRoot, txn, p, staged) + return commitOpenCodeTransitionSkillsLayout(p.layout, txn, p, staged) } receipts, applyErr := kernel.ApplyDialectTransition(req) if !persisted { @@ -170,7 +173,7 @@ func applyOpenCodeTransition(ctx context.Context, request domain.ActivationReque func stageOpenCodeTransitionSkills(request domain.ActivationRequest, p *openCodeNativeApply) (*openCodeSkillTxn, map[string]string, error) { txn := &openCodeSkillTxn{durable: true, backups: map[string]openCodeBackup{}, installed: map[string]domain.NativeObjectOwnership{}, rename: p.rename, removeAll: p.removeAll} - if err := txn.createRoot(request.Client.ConfigRoot); err != nil { + if err := txn.createLayoutRoot(p.layout); err != nil { return nil, nil, err } staged, err := txn.stageDesired(request.Delivery.ActivePath, shared.ObjectMap(p.desired)) @@ -185,10 +188,14 @@ func stageOpenCodeTransitionSkills(request domain.ActivationRequest, p *openCode } func commitOpenCodeTransitionSkills(configRoot string, txn *openCodeSkillTxn, p *openCodeNativeApply, staged map[string]string) error { + return commitOpenCodeTransitionSkillsLayout(userOpenCodeLayout(configRoot), txn, p, staged) +} + +func commitOpenCodeTransitionSkillsLayout(layout openCodeNativeLayout, txn *openCodeSkillTxn, p *openCodeNativeApply, staged map[string]string) error { if err := txn.backupPrevious(shared.ObjectMap(p.previous)); err != nil { return err } - if err := atomicfile.SyncDirectory(filepath.Join(configRoot, "skills")); err != nil { + if err := atomicfile.SyncDirectory(layout.Skills); err != nil { return err } if err := atomicfile.SyncDirectory(txn.root); err != nil { @@ -197,7 +204,7 @@ func commitOpenCodeTransitionSkills(configRoot string, txn *openCodeSkillTxn, p if err := txn.installStaged(staged, shared.ObjectMap(p.desired)); err != nil { return err } - if err := atomicfile.SyncDirectory(filepath.Join(configRoot, "skills")); err != nil { + if err := atomicfile.SyncDirectory(layout.Skills); err != nil { return err } return atomicfile.SyncDirectory(txn.root) diff --git a/install/integrationctl/agentplugins/clients/opencode/native_transition_journal.go b/install/integrationctl/agentplugins/clients/opencode/native_transition_journal.go index bb045c039..340adf9d4 100644 --- a/install/integrationctl/agentplugins/clients/opencode/native_transition_journal.go +++ b/install/integrationctl/agentplugins/clients/opencode/native_transition_journal.go @@ -42,6 +42,9 @@ type transitionRecord struct { // NativeTransitions consumes only stored roots and receipt/state authority. It // never resolves a host and never acquires the outer operation lock itself. type NativeTransitions struct { + // ProjectRoot binds discovery and reconciliation to the selected namespace. + // Empty preserves user-only recovery, including legacy user receipts. + ProjectRoot string `json:"-"` PackageVerifier interface { Verify(context.Context, string, string) error } @@ -159,6 +162,9 @@ func (t NativeTransitions) prepareTransitionAuthority(ctx context.Context, id do if !reflect.DeepEqual(OpenCodeObjects(binding.NativeObjects), OpenCodeObjects(previous)) { return before, binding, fmt.Errorf("native source ownership changed") } + if _, err := t.selectedBindingLayout(binding); err != nil { + return before, binding, err + } return before, binding, nil } @@ -180,6 +186,9 @@ func transitionTargetState(before domain.StateFileV2, id domain.NativeAttemptIde binding.NativeObjects = objects binding.NativeActivationAttempt = "" binding.Activation = domain.ActivationActive + if binding.Scope == string(domain.ScopeProject) { + binding.Activation = domain.ActivationPrepared + } binding.Verification = domain.VerificationInstalled binding.Materialization = domain.MaterializationMaterialized binding.Policy = domain.PolicyAllowed @@ -294,7 +303,7 @@ func readTransitionFile(root, path string, limit int64) ([]byte, error) { } func readTransitionRecord(root string) (transitionRecord, error) { var r transitionRecord - if err := validateTransitionRoot(filepath.Dir(filepath.Dir(root)), root); err != nil { + if err := validatePrivateTransitionRoot(root); err != nil { return r, err } path := filepath.Join(root, transitionRecordFile) @@ -331,6 +340,9 @@ func readTransitionRecord(root string) (transitionRecord, error) { } func storedTransitionRoot(b domain.ClientBinding) string { + if b.Scope == string(domain.ScopeProject) { + return b.NativeProfileRoot + } root := b.NativeProfileRoot for _, o := range OpenCodeObjects(b.NativeObjects) { codec, mcp, err := nativeconfig.OpenCodeCodecForKind(o.Kind) diff --git a/install/integrationctl/agentplugins/clients/opencode/native_transition_recovery.go b/install/integrationctl/agentplugins/clients/opencode/native_transition_recovery.go index ecd154416..23f4ead5e 100644 --- a/install/integrationctl/agentplugins/clients/opencode/native_transition_recovery.go +++ b/install/integrationctl/agentplugins/clients/opencode/native_transition_recovery.go @@ -26,10 +26,13 @@ func (t NativeTransitions) Pending() ([]PendingNativeTransition, error) { if err != nil { return nil, err } - roots, attempts := pendingTransitionAuthority(state) + roots, attempts, err := t.pendingTransitionAuthority(state) + if err != nil { + return nil, err + } var pending []PendingNativeTransition - for root := range roots { - if err := appendPendingTransitionRoot(root, attempts, &pending); err != nil { + for _, layout := range roots { + if err := appendPendingTransitionRoot(layout, attempts, &pending); err != nil { return nil, err } } @@ -45,27 +48,34 @@ func (t NativeTransitions) Pending() ([]PendingNativeTransition, error) { return pending, nil } -func pendingTransitionAuthority(state domain.StateFileV2) (map[string]bool, map[string]PendingNativeTransition) { - roots := map[string]bool{} +func (t NativeTransitions) pendingTransitionAuthority(state domain.StateFileV2) (map[string]openCodeNativeLayout, map[string]PendingNativeTransition, error) { + roots := map[string]openCodeNativeLayout{} attempts := map[string]PendingNativeTransition{} for _, installation := range state.Installations { for _, b := range installation.Clients { if b.ClientID != "opencode" { continue } + if (b.Scope == string(domain.ScopeProject) || t.ProjectRoot != "") && b.NativeProfileRoot == "" { + return nil, nil, fmt.Errorf("opencode project binding has no recorded native root") + } if root := storedTransitionRoot(b); root != "" { - roots[root] = true + layout, err := t.selectedBindingLayout(b) + if err != nil { + return nil, nil, err + } + roots[root] = layout } if b.NativeActivationAttempt != "" { attempts[b.NativeActivationAttempt] = PendingNativeTransition{OperationID: b.NativeActivationAttempt, InstallationID: installation.InstallationID, BindingID: b.ClientBindingID, TargetPath: b.TargetLocator, Phase: "native_attempt"} } } } - return roots, attempts + return roots, attempts, nil } -func appendPendingTransitionRoot(root string, attempts map[string]PendingNativeTransition, pending *[]PendingNativeTransition) error { - skills := filepath.Join(root, "skills") +func appendPendingTransitionRoot(layout openCodeNativeLayout, attempts map[string]PendingNativeTransition, pending *[]PendingNativeTransition) error { + root, skills := layout.Root, layout.Skills if err := pathpolicy.RequireContainedChild(root, skills); err != nil { return err } @@ -80,7 +90,7 @@ func appendPendingTransitionRoot(root string, attempts map[string]PendingNativeT if !strings.HasPrefix(entry.Name(), ".agentplugins-native-") { continue } - item, err := pendingTransitionEntry(root, skills, entry.Name()) + item, err := pendingTransitionEntry(layout, entry.Name()) if err != nil { return err } @@ -96,8 +106,9 @@ func appendPendingTransitionRoot(root string, attempts map[string]PendingNativeT return nil } -func pendingTransitionEntry(root, skills, name string) (*PendingNativeTransition, error) { - dir := filepath.Join(skills, name) +func pendingTransitionEntry(layout openCodeNativeLayout, name string) (*PendingNativeTransition, error) { + root := layout.Root + dir := filepath.Join(layout.Skills, name) if err := pathpolicy.RequireContainedChild(root, dir); err != nil { return nil, err } @@ -110,7 +121,8 @@ func pendingTransitionEntry(root, skills, name string) (*PendingNativeTransition if err != nil { return nil, err } - if r.Identity.NativeRoot != root { + recordLayout, layoutErr := recordOpenCodeLayout(r) + if layoutErr != nil || recordLayout != layout || r.Identity.NativeRoot != root { return nil, fmt.Errorf("transition root identity mismatch") } _, b, err := transitionBinding(r.OldState, r.Identity) @@ -152,6 +164,9 @@ func (t NativeTransitions) Reconcile(ctx context.Context, root string) (domain.N if err != nil { return domain.NativeEffectUncertain, err } + if err := t.validateRecordContext(r); err != nil { + return domain.NativeEffectUncertain, err + } q := transitionReconciler{transitions: t, ctx: ctx, record: r, effect: domain.NativeEffectUncertain} observation, reconcileErr := t.Kernel.ReconcileDialectTransition(nativeconfig.TransitionRecoveryRequest{ Paths: transitionPaths(r.Identity.NativeRoot), Prepared: nativePrepared(r.Native), diff --git a/install/integrationctl/agentplugins/clients/opencode/native_transition_recovery_test.go b/install/integrationctl/agentplugins/clients/opencode/native_transition_recovery_test.go index 08b8e7679..5de521e55 100644 --- a/install/integrationctl/agentplugins/clients/opencode/native_transition_recovery_test.go +++ b/install/integrationctl/agentplugins/clients/opencode/native_transition_recovery_test.go @@ -37,9 +37,19 @@ func transitionTestWrite(t *testing.T, path string, body []byte) { } } func providerTransitionFixture(t *testing.T) transitionFixtureData { + return providerTransitionFixtureScope(t, domain.ScopeUser) +} +func providerTransitionFixtureScope(t *testing.T, scope domain.InstallScope) transitionFixtureData { t.Helper() root := t.TempDir() config, active := filepath.Join(root, "config"), filepath.Join(root, "package") + user, project := config, "" + layout := userOpenCodeLayout(config) + if scope == domain.ScopeProject { + config, project = filepath.Join(root, "project"), filepath.Join(root, "project") + active = filepath.Join(project, ".uap", "managed", "package") + layout = projectOpenCodeLayout(project) + } paths := transitionPaths(config) transitionTestWrite(t, paths.JSON, []byte("{ // keep user trivia\n\"theme\":\"fixture\",\"mcp\":{}}")) // JSONC keeps exact user comments, whitespace and original permissions. @@ -69,7 +79,7 @@ func providerTransitionFixture(t *testing.T) transitionFixtureData { next := old next.Kind = OpenCodeV2MCPObjectKind next.ManagedDigest = desired.Digest - oldSkill := domain.NativeObjectOwnership{ObjectID: "opencode-skill:skill", Kind: openCodeSkillKind, LogicalName: "skill", Path: filepath.Join(config, "skills", "skill"), SourceRelative: "skills/skill", ProtectionClass: "managed"} + oldSkill := domain.NativeObjectOwnership{ObjectID: "opencode-skill:skill", Kind: openCodeSkillKind, LogicalName: "skill", Path: filepath.Join(layout.Skills, "skill"), SourceRelative: "skills/skill", ProtectionClass: "managed"} transitionTestWrite(t, filepath.Join(oldSkill.Path, "SKILL.md"), []byte("source skill\n")) oldSkill.ManagedDigest, err = shared.DigestSkillDirectory(oldSkill.Path) if err != nil { @@ -81,8 +91,8 @@ func providerTransitionFixture(t *testing.T) transitionFixtureData { if err != nil { t.Fatal(err) } - id := domain.NativeAttemptIdentity{OperationID: "TEST-attempt", InstallationID: "TEST-installation", BindingID: domain.ComputeClientBindingID("TEST-installation", "opencode", "user", active), NativeRoot: config} - b := domain.ClientBinding{ClientBindingID: id.BindingID, ClientID: "opencode", Scope: "user", TargetLocator: active, PhysicalArtifact: domain.ComputePhysicalArtifactID("demo", id.InstallationID), Materialization: domain.MaterializationMaterialized, Activation: domain.ActivationPrepared, Authentication: domain.AuthenticationNotRequired, Verification: domain.VerificationPackageValid, Policy: domain.PolicyAllowed, NativeActivationAttempt: id.OperationID, NativeObjects: []domain.NativeObjectOwnership{old, oldSkill}, PackageRevision: &domain.ClientPackageRevision{TreeDigest: "sha256:tree", ManifestDigest: "sha256:manifest"}} + id := domain.NativeAttemptIdentity{OperationID: "TEST-attempt", InstallationID: "TEST-installation", BindingID: domain.ComputeClientBindingID("TEST-installation", "opencode", string(scope), active), NativeRoot: config} + b := domain.ClientBinding{ClientBindingID: id.BindingID, ClientID: "opencode", Scope: string(scope), NativeProfileRoot: config, TargetLocator: active, PhysicalArtifact: domain.ComputePhysicalArtifactID("demo", id.InstallationID), Materialization: domain.MaterializationMaterialized, Activation: domain.ActivationPrepared, Authentication: domain.AuthenticationNotRequired, Verification: domain.VerificationPackageValid, Policy: domain.PolicyAllowed, NativeActivationAttempt: id.OperationID, NativeObjects: []domain.NativeObjectOwnership{old, oldSkill}, PackageRevision: &domain.ClientPackageRevision{TreeDigest: "sha256:tree", ManifestDigest: "sha256:manifest"}} artifact, err := (packagesnapshot.Builder{}).Build(context.Background(), active) if err != nil { t.Fatal(err) @@ -95,27 +105,39 @@ func providerTransitionFixture(t *testing.T) transitionFixtureData { b.NativeObjects = append(b.NativeObjects, packageObject) state := domain.StateFileV2{SchemaVersion: domain.StateSchemaVersion, Installations: []domain.Installation{{InstallationID: id.InstallationID, DeclaredName: "demo", Source: domain.SourceBinding{SourceBindingID: "src_demo", RequestedSource: "demo", CanonicalSource: "https://fixture.invalid/demo", ResolvedRevision: "abc", TreeDigest: "sha256:tree"}, Package: domain.PackageBinding{LoaderKind: domain.LoaderKindAgentPlugins, FormatID: domain.FormatIDAgentPluginsV1, SchemaURI: domain.PluginSchemaV1, DeclaredName: "demo", ManifestDigest: "sha256:manifest"}, Clients: map[string]domain.ClientBinding{id.BindingID: b}}}} store := statev2.Store{Path: filepath.Join(root, "state.json")} + if project != "" { + store.Path, store.ProjectRoot = filepath.Join(project, ".uap", "state.json"), project + } if err := store.Save(state); err != nil { t.Fatal(err) } - return transitionFixtureData{request: domain.ActivationRequest{NativeAttempt: id, Client: domain.DetectedClient{ClientID: domain.ClientOpenCode, ConfigRoot: config}, Delivery: domain.StagedDelivery{ActivePath: active, NativeObjects: []domain.NativeObjectOwnership{next, newSkill, packageObject}}, PreviousNativeObjects: b.NativeObjects}, store: store, source: source} + return transitionFixtureData{request: domain.ActivationRequest{NativeAttempt: id, Client: domain.DetectedClient{ClientID: domain.ClientOpenCode, ConfigRoot: user}, Plan: domain.DeliveryPlan{Scope: scope, NativeRegistryRoot: config, TargetContext: domain.TargetContext{Scope: scope, ProjectRoot: project, UserConfigRoot: user}}, Delivery: domain.StagedDelivery{ActivePath: active, NativeObjects: []domain.NativeObjectOwnership{next, newSkill, packageObject}}, PreviousNativeObjects: b.NativeObjects}, store: store, source: source} } func freshTransitions(f transitionFixtureData) NativeTransitions { - return NativeTransitions{PackageVerifier: transitionTestPackageVerifier{}, State: transaction.Kernel{StateStore: statev2.Store{Path: f.store.Path}}, Kernel: nativeconfig.New()} + return NativeTransitions{ProjectRoot: f.store.ProjectRoot, PackageVerifier: transitionTestPackageVerifier{}, State: transaction.Kernel{StateStore: f.store}, Kernel: nativeconfig.New()} } // Reopened recovery must infer bytes/state, never trust a phase. Each crash // occurs after real persisted preimage/rename/write/state/cleanup effects. func TestNativeTransitionReopensEveryDurableBoundary(t *testing.T) { + testTransitionDurableBoundaries(t, domain.ScopeUser) +} + +func TestProjectOpenCodeTransitionReopensEveryDurableBoundary(t *testing.T) { + testTransitionDurableBoundaries(t, domain.ScopeProject) +} + +func testTransitionDurableBoundaries(t *testing.T, scope domain.InstallScope) { + t.Helper() for _, crash := range []string{"prepared", "backup", "skill", "native", "state", "cleanup"} { t.Run(crash, func(t *testing.T) { - f := providerTransitionFixture(t) + f := providerTransitionFixtureScope(t, scope) t0 := freshTransitions(f) executable, err := os.Executable() if err != nil { t.Fatal(err) } - wire := transitionCrashWire{Identity: f.request.NativeAttempt, ActivePath: f.request.Delivery.ActivePath, Previous: f.request.PreviousNativeObjects, Desired: f.request.Delivery.NativeObjects, StatePath: f.store.Path} + wire := transitionCrashWire{Identity: f.request.NativeAttempt, ActivePath: f.request.Delivery.ActivePath, Previous: f.request.PreviousNativeObjects, Desired: f.request.Delivery.NativeObjects, StatePath: f.store.Path, ProjectRoot: f.store.ProjectRoot, UserRoot: f.request.Client.ConfigRoot} body, _ := json.Marshal(wire) fixture := filepath.Join(filepath.Dir(f.store.Path), "TEST-child.json") transitionTestWrite(t, fixture, body) @@ -160,15 +182,19 @@ func TestNativeTransitionReopensEveryDurableBoundary(t *testing.T) { if codec != want { t.Fatalf("confirmed receipt codec %s, want %s", codec, want) } - if err := VerifyOpenCodeNativeObjects(f.request.Client.ConfigRoot, "", b.NativeObjects, nativeconfig.New(), false); err != nil { + layout, err := openCodeActivationLayout(f.request) + if err != nil { + t.Fatal(err) + } + if err := verifyOpenCodeNativeLayout(layout, "", b.NativeObjects, nativeconfig.New(), false); err != nil { t.Fatal(err) } - content, err := os.ReadFile(filepath.Join(f.request.Client.ConfigRoot, "skills", "skill", "SKILL.md")) + content, err := os.ReadFile(filepath.Join(layout.Skills, "skill", "SKILL.md")) if err != nil || string(content) != skill { t.Fatal("wrong recovered skill", err) } if want == nativeconfig.CodecOpenCode { - body, _ := os.ReadFile(transitionPaths(f.request.Client.ConfigRoot).JSONC) + body, _ := os.ReadFile(transitionPaths(layout.Root).JSONC) if !bytes.Equal(body, f.source) { t.Fatal("source recovery changed exact foreign bytes") } @@ -181,9 +207,9 @@ func TestNativeTransitionReopensEveryDurableBoundary(t *testing.T) { } type transitionCrashWire struct { - Identity domain.NativeAttemptIdentity - ActivePath, StatePath string - Previous, Desired []domain.NativeObjectOwnership + Identity domain.NativeAttemptIdentity + ActivePath, StatePath, ProjectRoot, UserRoot string + Previous, Desired []domain.NativeObjectOwnership } // This inert compiled TEST child is always exercised by the parent matrix; @@ -203,9 +229,17 @@ func TestNativeTransitionCrashChild(t *testing.T) { } crash := args[1] request := domain.ActivationRequest{NativeAttempt: wire.Identity, Client: domain.DetectedClient{ClientID: domain.ClientOpenCode, ConfigRoot: wire.Identity.NativeRoot}, Delivery: domain.StagedDelivery{ActivePath: wire.ActivePath, NativeObjects: wire.Desired}, PreviousNativeObjects: wire.Previous} - f := transitionFixtureData{request: request, store: statev2.Store{Path: wire.StatePath}} + if wire.ProjectRoot != "" { + request.Client.ConfigRoot = wire.UserRoot + request.Plan = domain.DeliveryPlan{Scope: domain.ScopeProject, NativeRegistryRoot: wire.ProjectRoot, TargetContext: domain.TargetContext{Scope: domain.ScopeProject, ProjectRoot: wire.ProjectRoot, UserConfigRoot: wire.UserRoot}} + } + layout, err := openCodeActivationLayout(request) + if err != nil { + t.Fatal(err) + } + f := transitionFixtureData{request: request, store: statev2.Store{Path: wire.StatePath, ProjectRoot: wire.ProjectRoot}} transitions := freshTransitions(f) - p, err := prepareOpenCodeNativeApply(request.Client.ConfigRoot, request.Delivery.ActivePath, request.PreviousNativeObjects, request.Delivery.NativeObjects, nativeconfig.New(), shared.RenameDirectoryExclusive, os.RemoveAll) + p, err := prepareOpenCodeNativeApplyLayout(layout, request.Delivery.ActivePath, request.PreviousNativeObjects, request.Delivery.NativeObjects, nativeconfig.New(), shared.RenameDirectoryExclusive, os.RemoveAll) if err != nil { t.Fatal(err) } @@ -214,7 +248,7 @@ func TestNativeTransitionCrashChild(t *testing.T) { t.Fatal(err) } txn := &openCodeSkillTxn{durable: true, rename: shared.RenameDirectoryExclusive, removeAll: os.RemoveAll, backups: map[string]openCodeBackup{}, installed: map[string]domain.NativeObjectOwnership{}} - if err := txn.createRoot(request.Client.ConfigRoot); err != nil { + if err := txn.createLayoutRoot(layout); err != nil { t.Fatal(err) } staged, err := txn.stageDesired(request.Delivery.ActivePath, shared.ObjectMap(p.desired)) diff --git a/install/integrationctl/agentplugins/clients/opencode/native_transition_scope.go b/install/integrationctl/agentplugins/clients/opencode/native_transition_scope.go new file mode 100644 index 000000000..14fa31575 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/opencode/native_transition_scope.go @@ -0,0 +1,73 @@ +package opencode + +import ( + "fmt" + "path/filepath" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +// Project scope is stored binding authority, never a heuristic over package +// paths. Legacy user bindings retain the existing receipt-root fallback. +func transitionBindingLayout(binding domain.ClientBinding) (openCodeNativeLayout, error) { + root := storedTransitionRoot(binding) + if root == "" || !filepath.IsAbs(root) || filepath.Clean(root) != root { + return openCodeNativeLayout{}, fmt.Errorf("opencode stored native root is unavailable") + } + layout := userOpenCodeLayout(root) + if binding.Scope == string(domain.ScopeProject) { + if _, err := domain.NewTargetContext(domain.ScopeProject, root, ""); err != nil { + return openCodeNativeLayout{}, err + } + layout = projectOpenCodeLayout(root) + } else if binding.Scope != "" && binding.Scope != string(domain.ScopeUser) { + return openCodeNativeLayout{}, fmt.Errorf("unsupported opencode binding scope") + } + return layout, layout.validate() +} + +func recordOpenCodeLayout(record transitionRecord) (openCodeNativeLayout, error) { + _, binding, err := transitionBinding(record.OldState, record.Identity) + if err != nil { + return openCodeNativeLayout{}, err + } + return transitionBindingLayout(binding) +} + +func (t NativeTransitions) selectedBindingLayout(binding domain.ClientBinding) (openCodeNativeLayout, error) { + layout, err := transitionBindingLayout(binding) + if err != nil { + return layout, err + } + if layout.Scope == domain.ScopeProject { + if t.ProjectRoot == "" || t.ProjectRoot != layout.Root { + return layout, fmt.Errorf("opencode transition differs from the selected project") + } + } else if t.ProjectRoot != "" { + return layout, fmt.Errorf("opencode user transition is outside the selected project") + } + return layout, nil +} + +func (t NativeTransitions) validateRecordContext(record transitionRecord) error { + current, err := t.State.Load() + if err != nil { + return err + } + _, binding, err := transitionBinding(current, record.Identity) + if err != nil { + return err + } + selected, err := t.selectedBindingLayout(binding) + if err != nil { + return err + } + bound, err := recordOpenCodeLayout(record) + if err != nil { + return err + } + if selected != bound { + return fmt.Errorf("opencode transition scope or native layout changed") + } + return nil +} diff --git a/install/integrationctl/agentplugins/clients/opencode/native_transition_validation.go b/install/integrationctl/agentplugins/clients/opencode/native_transition_validation.go index 8c4c90c54..35ec2a820 100644 --- a/install/integrationctl/agentplugins/clients/opencode/native_transition_validation.go +++ b/install/integrationctl/agentplugins/clients/opencode/native_transition_validation.go @@ -56,14 +56,33 @@ func validateTransitionEnvelope(record transitionRecord, root string) error { return err } } - return validateTransitionRoot(record.Identity.NativeRoot, root) + layout, err := recordOpenCodeLayout(record) + if err != nil { + return err + } + return validateTransitionLayoutRoot(layout, root) } -func validateTransitionRoot(nativeRoot, root string) error { - if !filepath.IsAbs(nativeRoot) || filepath.Clean(nativeRoot) != nativeRoot || filepath.Dir(root) != filepath.Join(nativeRoot, "skills") || !strings.HasPrefix(filepath.Base(root), ".agentplugins-native-") { +func validateTransitionLayoutRoot(layout openCodeNativeLayout, root string) error { + if !filepath.IsAbs(layout.Root) || filepath.Clean(layout.Root) != layout.Root || filepath.Dir(root) != layout.Skills { return fmt.Errorf("invalid transition root") } - if err := pathpolicy.RequireContainedChild(nativeRoot, root); err != nil { + if err := layout.validate(); err != nil { + return err + } + if err := pathpolicy.RequireContainedChild(layout.Root, root); err != nil { + return err + } + return validatePrivateTransitionRoot(root) +} + +// Before decoding the bounded private record, validate only its local container. +// The decoded binding subsequently supplies the exact user/project native layout. +func validatePrivateTransitionRoot(root string) error { + if !filepath.IsAbs(root) || filepath.Clean(root) != root || !strings.HasPrefix(filepath.Base(root), ".agentplugins-native-") { + return fmt.Errorf("invalid transition root") + } + if err := pathpolicy.RequireContainedChild(filepath.Dir(root), root); err != nil { return err } info, err := os.Lstat(root) @@ -145,6 +164,10 @@ func validateTransitionReceipts(record transitionRecord, old, target domain.Clie } func validateTransitionReceiptSide(record transitionRecord, side transitionReceiptSide) error { + layout, err := recordOpenCodeLayout(record) + if err != nil { + return err + } objects := shared.ObjectMap(OpenCodeObjects(side.objects)) mcpCount := 0 for _, object := range objects { @@ -152,7 +175,7 @@ func validateTransitionReceiptSide(record transitionRecord, side transitionRecei if err != nil { return err } - if err := validateOpenCodeObject(record.Identity.NativeRoot, OpenCodeProjection{}, object); err != nil { + if err := validateOpenCodeObject(layout, OpenCodeProjection{}, object); err != nil { return err } if mcp { @@ -220,10 +243,14 @@ func transitionSkillIntentCount(old, next map[string]domain.NativeObjectOwnershi } func validateTransitionSkillIntent(record transitionRecord, index int, skill transitionSkill) error { + layout, err := recordOpenCodeLayout(record) + if err != nil { + return err + } if err := pathpolicy.ValidateLeafID(skill.Name); err != nil { return err } - if skill.ID != "opencode-skill:"+skill.Name || index > 0 && record.Skills[index-1].ID >= skill.ID || skill.Target != filepath.Join(record.Identity.NativeRoot, "skills", skill.Name) || skill.Backup != filepath.Join(record.Root, "old-"+skill.Name) || skill.Old == nil && skill.New == nil || skill.Old == nil && skill.OldExists { + if skill.ID != "opencode-skill:"+skill.Name || index > 0 && record.Skills[index-1].ID >= skill.ID || skill.Target != filepath.Join(layout.Skills, skill.Name) || skill.Backup != filepath.Join(record.Root, "old-"+skill.Name) || skill.Old == nil && skill.New == nil || skill.Old == nil && skill.OldExists { return fmt.Errorf("invalid skill intent") } return nil diff --git a/install/integrationctl/agentplugins/clients/opencode/plan.go b/install/integrationctl/agentplugins/clients/opencode/plan.go index 8ab7ab3d4..374fcd7ce 100644 --- a/install/integrationctl/agentplugins/clients/opencode/plan.go +++ b/install/integrationctl/agentplugins/clients/opencode/plan.go @@ -38,7 +38,15 @@ func (*Adapter) RefinePlan(_ context.Context, in clients.PlanInput, plan *domain } plan.OpenCodeHost = in.Client.OpenCodeHost } - shared.PromoteNativeReady(plan, in.Client.ConfigRoot, shared.OnlyNativeComponents(plan.Components)) + root := in.Client.ConfigRoot + if in.TargetContext.Scope == domain.ScopeProject { + layout, err := openCodeTargetLayout(in.Client, in.TargetContext, plan.NativeRegistryRoot) + if err != nil { + return err + } + root = layout.Root + } + shared.PromoteNativeReady(plan, root, shared.OnlyNativeComponents(plan.Components)) plan.UserActions = shared.AppendUnique(plan.UserActions, "agentplugins will install the package's skills and MCP servers; restart OpenCode when complete") return nil } diff --git a/install/integrationctl/agentplugins/clients/opencode/project.go b/install/integrationctl/agentplugins/clients/opencode/project.go index 6c92a0d9c..793e0f269 100644 --- a/install/integrationctl/agentplugins/clients/opencode/project.go +++ b/install/integrationctl/agentplugins/clients/opencode/project.go @@ -10,7 +10,15 @@ import ( var _ clients.Projector = (*Adapter)(nil) var _ clients.ActiveNativeProjector = (*Adapter)(nil) -func (*Adapter) ProjectActiveNative(_ context.Context, root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan, _ string) ([]domain.NativeObjectOwnership, error) { +func (*Adapter) ProjectActiveNative(ctx context.Context, root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan, dataPath string) ([]domain.NativeObjectOwnership, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if plan.Scope == domain.ScopeProject || plan.TargetContext.Scope == domain.ScopeProject { + if err := validateOpenCodeProjectActiveProjection(root, envelope, plan, dataPath); err != nil { + return nil, err + } + } return BuildOpenCodeNativeObjects(root, envelope, plan) } diff --git a/install/integrationctl/agentplugins/clients/opencode/project_active.go b/install/integrationctl/agentplugins/clients/opencode/project_active.go new file mode 100644 index 000000000..c708fda24 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/opencode/project_active.go @@ -0,0 +1,44 @@ +package opencode + +import ( + "fmt" + "path/filepath" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +// Existing immutable projection bytes cannot silently retain a different data +// locator from the selected receipt. Reprojection reads and refuses; it does +// not rewrite the digest-covered package to adopt a new authority. +func validateOpenCodeProjectActiveProjection(root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan, dataPath string) error { + layout, err := openCodePlanLayout(plan) + if err != nil { + return err + } + if root != plan.ActivePath { + return fmt.Errorf("%w: OpenCode active root differs from selected package", domain.ErrProjectionRefreshRequired) + } + if err := pathpolicy.RequireContainedChild(layout.Root, root); err != nil { + return err + } + if empty, err := emptyOpenCodeNativePlan(envelope, plan); err != nil || empty { + return err + } + projection, err := ReadOpenCodeProjection(root) + if err != nil { + return err + } + if err := validateOpenCodeProjection(layout, root, projection); err != nil { + return err + } + if projection.PackageRoot != plan.ActivePath { + return fmt.Errorf("%w: OpenCode projection package root differs from selected package", domain.ErrProjectionRefreshRequired) + } + for _, server := range projection.MCPServers { + if server.Type == "stdio" && (dataPath == "" || !filepath.IsAbs(dataPath) || filepath.Clean(dataPath) != dataPath || projection.DataRoot != dataPath) { + return fmt.Errorf("%w: OpenCode projection data root differs from selected receipt", domain.ErrProjectionRefreshRequired) + } + } + return nil +} diff --git a/install/integrationctl/agentplugins/clients/opencode/project_status.go b/install/integrationctl/agentplugins/clients/opencode/project_status.go new file mode 100644 index 000000000..c49b48daf --- /dev/null +++ b/install/integrationctl/agentplugins/clients/opencode/project_status.go @@ -0,0 +1,37 @@ +package opencode + +import ( + "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) + +// The provider calls this before its generic attestation shortcut. A native +// config receipt proves exact installed bytes, not the effective session config. +func (*Adapter) PreflightActivation(_ clients.Env, request domain.ActivationRequest) error { + if request.Plan.Scope != domain.ScopeProject && request.Plan.TargetContext.Scope != domain.ScopeProject { + return nil + } + if _, err := openCodeActivationLayout(request); err != nil { + return err + } + if request.ActivationComplete { + return fmt.Errorf("opencode project discovery cannot be established by activation attestation; inspect effective config, skills and MCP servers in a fresh session in the selected project") + } + return nil +} + +func openCodeProjectStatus(request domain.ActivationRequest, outcome domain.ActivationOutcome) domain.ActivationOutcome { + if request.Plan.Scope != domain.ScopeProject || outcome.Verification != domain.VerificationInstalled { + return outcome + } + outcome.Activation = domain.ActivationPrepared + outcome.ActivationAttested = false + outcome.UserActions = shared.AppendUnique(outcome.UserActions, "OpenCode project configuration is installed; effective config, native skill discovery, permissions and MCP initialization remain unobserved; start OpenCode in the selected project and inspect debug config, debug skill and mcp list") + outcome.UserActions = shared.AppendUnique(outcome.UserActions, "OpenCode merges ancestor, user, custom-directory, inline and managed configuration; preserved higher-priority settings or same-name skills can shadow the managed project components") + return outcome +} diff --git a/install/integrationctl/agentplugins/clients/opencode/project_transition_test.go b/install/integrationctl/agentplugins/clients/opencode/project_transition_test.go new file mode 100644 index 000000000..70f5ae844 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/opencode/project_transition_test.go @@ -0,0 +1,80 @@ +package opencode + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + + "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" +) + +func projectPreparedTransition(t *testing.T) (transitionFixtureData, string) { + t.Helper() + f := providerTransitionFixtureScope(t, domain.ScopeProject) + layout, err := openCodeActivationLayout(f.request) + if err != nil { + t.Fatal(err) + } + p, err := prepareOpenCodeNativeApplyLayout(layout, f.request.Delivery.ActivePath, f.request.PreviousNativeObjects, f.request.Delivery.NativeObjects, nativeconfig.New(), shared.RenameDirectoryExclusive, os.RemoveAll) + if err != nil { + t.Fatal(err) + } + req, err := openCodeTransitionRequest(p) + if err != nil { + t.Fatal(err) + } + txn, _, err := stageOpenCodeTransitionSkills(f.request, p) + if err != nil { + t.Fatal(err) + } + stop := errors.New("TEST stop after durable preparation") + req.PersistPrepared = func(prepared nativeconfig.PreparedTransition) error { + if err := freshTransitions(f).PersistPrepared(context.Background(), f.request.NativeAttempt, txn.root, transitionDTO(prepared), f.request.PreviousNativeObjects, f.request.Delivery.NativeObjects); err != nil { + return err + } + return stop + } + if _, err := nativeconfig.New().ApplyDialectTransition(req); !errors.Is(err, stop) { + t.Fatalf("preparation fence: %v", err) + } + return f, txn.root +} + +func TestProjectOpenCodeTransitionRequiresSelectedContextBeforeRecovery(t *testing.T) { + f, root := projectPreparedTransition(t) + stateBefore, err := os.ReadFile(f.store.Path) + if err != nil { + t.Fatal(err) + } + configBefore, err := os.ReadFile(transitionPaths(f.store.ProjectRoot).JSONC) + if err != nil { + t.Fatal(err) + } + for _, selected := range []string{"", filepath.Join(t.TempDir(), "B")} { + transitions := freshTransitions(f) + transitions.ProjectRoot = selected + if _, err := transitions.Pending(); err == nil { + t.Fatalf("discovery accepted mismatched project %q", selected) + } + if _, err := transitions.Reconcile(context.Background(), root); err == nil { + t.Fatalf("recovery accepted mismatched project %q", selected) + } + } + stateAfter, _ := os.ReadFile(f.store.Path) + configAfter, _ := os.ReadFile(transitionPaths(f.store.ProjectRoot).JSONC) + if !bytes.Equal(stateBefore, stateAfter) || !bytes.Equal(configBefore, configAfter) { + t.Fatal("refused recovery changed state or native config") + } + pending, err := freshTransitions(f).Pending() + if err != nil || len(pending) != 1 || pending[0].Root != root { + t.Fatalf("selected project lost pending transition: %+v, %v", pending, err) + } + if err := freshTransitions(f).Recover(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/install/integrationctl/agentplugins/clients/opencode/scope.go b/install/integrationctl/agentplugins/clients/opencode/scope.go new file mode 100644 index 000000000..9a25bde0c --- /dev/null +++ b/install/integrationctl/agentplugins/clients/opencode/scope.go @@ -0,0 +1,114 @@ +package opencode + +import ( + "fmt" + "path/filepath" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +var ( + _ clients.ProjectTargetLayout = (*Adapter)(nil) + _ clients.NativeRegistryLayout = (*Adapter)(nil) +) + +// OpenCode's project config and skills have distinct native roots. This layout +// is operational authority; it is never inferred from a staged package. +type openCodeNativeLayout struct { + Root, Skills string + Scope domain.InstallScope +} + +func userOpenCodeLayout(root string) openCodeNativeLayout { + return openCodeNativeLayout{Root: root, Skills: filepath.Join(root, "skills"), Scope: domain.ScopeUser} +} + +func projectOpenCodeLayout(root string) openCodeNativeLayout { + return openCodeNativeLayout{Root: root, Skills: filepath.Join(root, ".opencode", "skills"), Scope: domain.ScopeProject} +} + +func (layout openCodeNativeLayout) validate() error { + if layout.Scope != domain.ScopeProject { + // Legacy user callers retain their existing per-object preconditions. + return nil + } + if _, err := domain.NewTargetContext(domain.ScopeProject, layout.Root, ""); err != nil { + return err + } + for _, path := range []string{layout.Skills, filepath.Join(layout.Root, "opencode.json"), filepath.Join(layout.Root, "opencode.jsonc")} { + if err := pathpolicy.RequireContainedChild(layout.Root, path); err != nil { + return err + } + } + return nil +} + +func (*Adapter) NativeRegistry(in clients.PlanInput) (string, string) { + if in.TargetContext.Scope == domain.ScopeProject { + return in.TargetContext.ProjectRoot, in.Client.ExecutablePath + } + return in.Client.ConfigRoot, in.Client.ExecutablePath +} + +func (*Adapter) ProjectTargetRoot(client domain.DetectedClient, mode domain.PackageMode, managedRoot string, target domain.TargetContext) (string, string, error) { + if target.Scope != domain.ScopeProject { + return "", "", fmt.Errorf("opencode project layout requires project scope") + } + if _, err := openCodeTargetLayout(client, target, ""); err != nil { + return "", "", err + } + if err := pathpolicy.RequireContainedChild(target.ProjectRoot, managedRoot); err != nil { + return "", "", fmt.Errorf("opencode prepared package must stay in the selected project: %w", err) + } + return shared.ManagedTargetRoot(client, mode, managedRoot) +} + +func openCodeTargetLayout(client domain.DetectedClient, target domain.TargetContext, planned string) (openCodeNativeLayout, error) { + validated, err := domain.NewTargetContext(target.Scope, target.ProjectRoot, target.UserConfigRoot) + if err != nil { + return openCodeNativeLayout{}, err + } + if validated.UserConfigRoot != "" && validated.UserConfigRoot != client.ConfigRoot { + return openCodeNativeLayout{}, fmt.Errorf("opencode detected user profile differs from the selected context") + } + layout := userOpenCodeLayout(client.ConfigRoot) + if validated.Scope == domain.ScopeProject { + layout = projectOpenCodeLayout(validated.ProjectRoot) + } + if planned != "" && planned != layout.Root { + return openCodeNativeLayout{}, fmt.Errorf("opencode native root differs from the selected context") + } + if layout.Root == "" && layout.Scope != domain.ScopeProject { + return layout, nil + } + return layout, layout.validate() +} + +func openCodePlanLayout(plan domain.DeliveryPlan) (openCodeNativeLayout, error) { + if (plan.Scope == domain.ScopeProject) != (plan.TargetContext.Scope == domain.ScopeProject) { + return openCodeNativeLayout{}, fmt.Errorf("opencode plan scope differs from its target context") + } + if plan.Scope == domain.ScopeProject { + target, err := domain.NewTargetContext(plan.TargetContext.Scope, plan.TargetContext.ProjectRoot, plan.TargetContext.UserConfigRoot) + if err != nil { + return openCodeNativeLayout{}, err + } + if plan.NativeRegistryRoot != target.ProjectRoot { + return openCodeNativeLayout{}, fmt.Errorf("opencode native root differs from the selected project") + } + layout := projectOpenCodeLayout(target.ProjectRoot) + return layout, layout.validate() + } + layout := userOpenCodeLayout(plan.NativeRegistryRoot) + return layout, layout.validate() +} + +func openCodeActivationLayout(request domain.ActivationRequest) (openCodeNativeLayout, error) { + if (request.Plan.Scope == domain.ScopeProject) != (request.Plan.TargetContext.Scope == domain.ScopeProject) { + return openCodeNativeLayout{}, fmt.Errorf("opencode plan scope differs from its target context") + } + return openCodeTargetLayout(request.Client, request.Plan.TargetContext, request.Plan.NativeRegistryRoot) +} diff --git a/install/integrationctl/agentplugins/domain/clients.go b/install/integrationctl/agentplugins/domain/clients.go index eaed3b52d..aecdba9f3 100644 --- a/install/integrationctl/agentplugins/domain/clients.go +++ b/install/integrationctl/agentplugins/domain/clients.go @@ -136,11 +136,11 @@ var clientDefinitions = []ClientDefinition{ LifecycleKind: LifecycleNativeConfig, BindsNativeProfileRoot: true, }), LifecycleNativeConfig), - withActivation(clientDefinition(ClientOpenCode, "OpenCode", "opencode", "managed", "prepared", false, PackagePrepared, SupportPrepared, SupportPrepared, SupportUnsupported, SupportUnsupported, SupportUnsupported, ClientTraits{ + withProjectScope(withActivation(clientDefinition(ClientOpenCode, "OpenCode", "opencode", "managed", "prepared", false, PackagePrepared, SupportPrepared, SupportPrepared, SupportUnsupported, SupportUnsupported, SupportUnsupported, ClientTraits{ InstallIntents: []InstallIntent{InstallIntentAutomatic}, LifecycleKind: LifecycleNativeConfig, ReportsMCPToolNamespaceCollision: true, - }), ActivationAutomatic), + }), ActivationAutomatic), LifecycleNativeConfig), withActivation(clientDefinition(ClientCline, "Cline", "cline", "managed", "native", false, PackageNative, SupportNative, SupportNative, SupportNative, SupportUnsupported, SupportUnsupported, ClientTraits{ InstallIntents: []InstallIntent{InstallIntentAutomatic}, LifecycleKind: LifecycleNativeConfig, diff --git a/install/integrationctl/agentplugins/installer/engine.go b/install/integrationctl/agentplugins/installer/engine.go index 8974e09dd..faf297859 100644 --- a/install/integrationctl/agentplugins/installer/engine.go +++ b/install/integrationctl/agentplugins/installer/engine.go @@ -131,7 +131,7 @@ func (e *Engine) lifecycle(helper *managedstdio.Source, facts BindingFacts, dete projectArgs: e.cfg.ProjectArgs, facts: facts, } - transitions := opencode.NativeTransitions{PackageVerifier: stager, State: transaction.Kernel{StateStore: e.store}, Kernel: nativeKernel} + transitions := opencode.NativeTransitions{ProjectRoot: e.cfg.ProjectRoot, PackageVerifier: stager, State: transaction.Kernel{StateStore: e.store}, Kernel: nativeKernel} inner := providers.Activator{OpenCodeTransitions: transitions, Runner: e.cfg.Runner, Registry: e.cfg.Registry, NativeConfig: &nativeKernel} var observer usecase.NativeIdentityObserver if e.cfg.EnableNativeObserver && e.cfg.Runner != nil { diff --git a/install/integrationctl/agentplugins/installer/inspect.go b/install/integrationctl/agentplugins/installer/inspect.go index 764b5bcb8..9e52d7b8d 100644 --- a/install/integrationctl/agentplugins/installer/inspect.go +++ b/install/integrationctl/agentplugins/installer/inspect.go @@ -508,7 +508,7 @@ func populateInspectedBinding(inspected *InspectedBinding, binding domain.Client } func (e *Engine) appendPendingTransitions(out *Inspection) error { - pending, err := (opencode.NativeTransitions{State: transaction.Kernel{StateStore: e.store}}).Pending() + pending, err := (opencode.NativeTransitions{ProjectRoot: e.cfg.ProjectRoot, State: transaction.Kernel{StateStore: e.store}}).Pending() if err != nil { return err } diff --git a/install/integrationctl/agentplugins/installer/opencode_recovery.go b/install/integrationctl/agentplugins/installer/opencode_recovery.go index 59e283428..7f7f2e6f9 100644 --- a/install/integrationctl/agentplugins/installer/opencode_recovery.go +++ b/install/integrationctl/agentplugins/installer/opencode_recovery.go @@ -16,7 +16,7 @@ import ( // OpenCode recorder owns native classification, state publication and cleanup; // this guard permits only its current journal and binding to advance. func (e *Engine) recoverObservedOpenCode(ctx context.Context, svc usecase.Service, observed RecoveryObservation) error { - t := opencode.NativeTransitions{PackageVerifier: svc.Stager, State: transaction.Kernel{StateStore: e.store}, Kernel: nativeconfig.New()} + t := opencode.NativeTransitions{ProjectRoot: e.cfg.ProjectRoot, PackageVerifier: svc.Stager, State: transaction.Kernel{StateStore: e.store}, Kernel: nativeconfig.New()} pending, err := t.Pending() if err != nil { return err @@ -61,7 +61,7 @@ func (s *openCodeRecoveryState) confirm(completed bool) error { if err != nil { return fmt.Errorf("%w: %w", ErrPlanChanged, err) } - pending, err := (opencode.NativeTransitions{State: transaction.Kernel{StateStore: s.engine.store}}).Pending() + pending, err := (opencode.NativeTransitions{ProjectRoot: s.engine.cfg.ProjectRoot, State: transaction.Kernel{StateStore: s.engine.store}}).Pending() if err != nil { return fmt.Errorf("%w: %w", ErrPlanChanged, 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 29d3cdfc2..5c73d049d 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json @@ -423,7 +423,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 9c6afc8df..879349b72 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 @@ -643,7 +643,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 1352c239c..19fbb7d0e 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json @@ -383,7 +383,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 d1c06431c..1d11e09ac 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json @@ -457,7 +457,8 @@ }, "PackageMode": "prepared_package", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "prepared" }, diff --git a/install/integrationctl/agentplugins/providers/opencode_namespace.go b/install/integrationctl/agentplugins/providers/opencode_namespace.go index 2b92a9a7c..bf42c251c 100644 --- a/install/integrationctl/agentplugins/providers/opencode_namespace.go +++ b/install/integrationctl/agentplugins/providers/opencode_namespace.go @@ -24,7 +24,10 @@ func (guard OpenCodeNamespacePreflight) CheckMCPNamespace(ctx context.Context, c if !domain.ClientTraitsFor(client.ClientID).ReportsMCPToolNamespaceCollision || len(domain.SelectedMCPNames(plan)) == 0 { return nil } - root := strings.TrimSpace(client.ConfigRoot) + root, err := openCodeNamespaceRoot(client, plan) + if err != nil { + return err + } if !filepath.IsAbs(root) { return fmt.Errorf("OpenCode user config root is unavailable") } @@ -53,6 +56,23 @@ func (guard OpenCodeNamespacePreflight) CheckMCPNamespace(ctx context.Context, c return err } +func openCodeNamespaceRoot(client domain.DetectedClient, plan domain.DeliveryPlan) (string, error) { + if plan.Scope != domain.ScopeProject && plan.TargetContext.Scope != domain.ScopeProject { + return strings.TrimSpace(client.ConfigRoot), nil + } + if plan.Scope != domain.ScopeProject || plan.TargetContext.Scope != domain.ScopeProject { + return "", fmt.Errorf("OpenCode namespace scope differs from its target context") + } + target, err := domain.NewTargetContext(plan.TargetContext.Scope, plan.TargetContext.ProjectRoot, plan.TargetContext.UserConfigRoot) + if err != nil { + return "", err + } + if plan.NativeRegistryRoot != target.ProjectRoot || target.UserConfigRoot != "" && target.UserConfigRoot != client.ConfigRoot { + return "", fmt.Errorf("OpenCode namespace differs from the selected project and user context") + } + return target.ProjectRoot, nil +} + func (guard OpenCodeNamespacePreflight) inspectOwnedOpenCodeMCP(paths nativeconfig.Paths, managed *domain.ClientBinding) ([]string, nativeconfig.Codec, []nativeconfig.Receipt, error) { previous := []string{} var source nativeconfig.Codec diff --git a/install/integrationctl/agentplugins/providers/project_opencode_alias_test.go b/install/integrationctl/agentplugins/providers/project_opencode_alias_test.go new file mode 100644 index 000000000..c8dd44d67 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_opencode_alias_test.go @@ -0,0 +1,43 @@ +package providers_test + +import ( + "context" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/opencode" +) + +func TestProjectOpenCodeRejectsContainedNativeAliases(t *testing.T) { + for _, kind := range []string{"skills", "config"} { + t.Run(kind, func(t *testing.T) { + request, _, _ := projectOpenCodeRequest(t) + project := request.Plan.TargetContext.ProjectRoot + alias, target := filepath.Join(project, ".opencode"), filepath.Join(project, "other-native") + if err := os.Mkdir(target, 0700); err != nil { + t.Fatal(err) + } + if kind == "config" { + alias, target = filepath.Join(project, "opencode.jsonc"), filepath.Join(target, "opencode.jsonc") + projectGeminiWrite(t, target, `{"mcp":{}}`) + if err := os.Remove(alias); err != nil { + t.Fatal(err) + } + } + before := projectGeminiInventory(t, filepath.Join(project, "other-native")) + if err := os.Symlink(target, alias); err != nil { + t.Skipf("native symlink fixture unavailable: %v", err) + } + if _, err := opencode.New().Activate(context.Background(), clients.Env{NativeConfig: nativeconfig.New()}, request); err == nil { + t.Fatal("project accepted a contained native alias") + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, filepath.Join(project, "other-native"))) { + t.Fatal("refused native alias changed its contained target") + } + }) + } +} diff --git a/install/integrationctl/agentplugins/providers/project_opencode_data_test.go b/install/integrationctl/agentplugins/providers/project_opencode_data_test.go new file mode 100644 index 000000000..d27a3f674 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_opencode_data_test.go @@ -0,0 +1,51 @@ +package providers_test + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/opencode" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +func TestProjectOpenCodeReadOnlyProjectionRequiresSelectedDataAuthority(t *testing.T) { + request, other, user := projectOpenCodeRequest(t) + plan := request.Plan + plan.Components = []domain.ComponentDecision{{Kind: domain.ComponentMCPServer, Name: "docs", Support: domain.SupportNative}} + envelope := domain.PackageEnvelope{MCP: domain.MCPComponent{Servers: map[string]domain.MCPServer{ + "docs": {Name: "docs", Type: "stdio", Decoded: map[string]any{"command": "go", "args": []any{"version"}, "env": map[string]any{"DATA": "${PLUGIN_DATA}"}}}, + }}} + data := filepath.Join(plan.TargetContext.ProjectRoot, ".uap", "plugin-data", "selected") + // Rebuild this test-owned package as stdio before taking its immutable + // baseline; Project refuses authored use of its private projection path. + if err := os.Remove(filepath.Join(plan.ActivePath, opencode.OpenCodeProjectionFile)); err != nil { + t.Fatal(err) + } + adapter := opencode.New() + want, err := adapter.Project(t.Context(), clients.ProjectionInput{StagingPath: plan.ActivePath, Envelope: envelope, Plan: plan, PluginDataPath: data}) + if err != nil { + t.Fatal(err) + } + before, beforeUser, beforeOther := projectGeminiInventory(t, plan.TargetContext.ProjectRoot), projectGeminiInventory(t, user), projectGeminiInventory(t, other) + got, err := adapter.ProjectActiveNative(t.Context(), plan.ActivePath, envelope, plan, data) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("exact selected data projection = %+v, %v", got, err) + } + for _, changed := range []string{"", "relative-data", filepath.Join(other, ".uap", "plugin-data", "selected"), data + "-other"} { + if got, err := adapter.ProjectActiveNative(t.Context(), plan.ActivePath, envelope, plan, changed); !errors.Is(err, domain.ErrProjectionRefreshRequired) || len(got) != 0 { + t.Fatalf("changed data authority %q accepted: %+v, %v", changed, got, err) + } + } + changed := plan + changed.ActivePath += "-other" + if _, err := adapter.ProjectActiveNative(t.Context(), plan.ActivePath, envelope, changed, data); !errors.Is(err, domain.ErrProjectionRefreshRequired) { + t.Fatalf("changed active package authority accepted: %v", err) + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, plan.TargetContext.ProjectRoot)) || !reflect.DeepEqual(beforeUser, projectGeminiInventory(t, user)) || !reflect.DeepEqual(beforeOther, projectGeminiInventory(t, other)) { + t.Fatal("read-only projection mutated package/native/user/project-B bytes") + } +} diff --git a/install/integrationctl/agentplugins/providers/project_opencode_keys_test.go b/install/integrationctl/agentplugins/providers/project_opencode_keys_test.go new file mode 100644 index 000000000..e3a18974e --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_opencode_keys_test.go @@ -0,0 +1,29 @@ +package providers_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +func TestProjectOpenCodePreservesExactMCPLogicalKeys(t *testing.T) { + request, _, _ := projectOpenCodeRequestWithNames(t, []string{"api/server", "other api server"}) + activator := projectOpenCodeActivator(t, nativeconfig.New()) + outcome, err := activator.Activate(context.Background(), request) + if err != nil || outcome.Verification != domain.VerificationInstalled { + t.Fatalf("exact project logical keys: %+v, %v", outcome, err) + } + body, err := os.ReadFile(filepath.Join(request.Plan.NativeRegistryRoot, "opencode.jsonc")) + if err != nil || !strings.Contains(string(body), `"api/server"`) || !strings.Contains(string(body), `"other api server"`) { + t.Fatalf("project normalized logical keys: %s, %v", body, err) + } + remove := domain.DeactivationRequest{Client: request.Client, TargetContext: request.Plan.TargetContext, NativeRegistryRoot: request.Plan.NativeRegistryRoot, Confirmed: true, NativeObjects: outcome.NativeObjects} + if result, err := activator.Deactivate(context.Background(), remove); err != nil || !result.ExternalRemovalComplete { + t.Fatalf("exact project-key removal: %+v, %v", result, err) + } +} diff --git a/install/integrationctl/agentplugins/providers/project_opencode_stager_test.go b/install/integrationctl/agentplugins/providers/project_opencode_stager_test.go new file mode 100644 index 000000000..b8909ba2f --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_opencode_stager_test.go @@ -0,0 +1,96 @@ +package providers_test + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "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/opencode" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/commandext" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/planner" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/providers" +) + +// This uses the production loader, planner, renderer, stager and activator. +// It proves an inert contract journey, not OpenCode session/model discovery. +func TestProjectOpenCodePlannerStagerRendersCommandsAndOwnsExactNativeComponents(t *testing.T) { + request, projectB, user := projectOpenCodeRequest(t) + beforeUser, beforeB := projectGeminiInventory(t, user), projectGeminiInventory(t, projectB) + source := filepath.Join(t.TempDir(), "source") + projectGeminiWrite(t, filepath.Join(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(source, "skills", "docs", "SKILL.md"), "---\nname: docs\ndescription: Project docs\n---\nInert docs.\n") + projectGeminiWrite(t, filepath.Join(source, filepath.FromSlash(commandext.RelativeDirectory), "echo.md"), "---\ndescription: Echo the marker\ndisable-model-invocation: false\n---\nUAP-PROJECT-INERT\n") + projectGeminiWrite(t, filepath.Join(source, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"docs":{"type":"streamable-http","url":"https://managed.test/mcp"}}}`) + schemas, err := specregistry.New() + if err != nil { + t.Fatal(err) + } + envelope, err := (loader.Loader{Registry: schemas}).Load(context.Background(), domain.LoadInput{SnapshotRoot: source}) + if err != nil { + t.Fatal(err) + } + registry, err := clients.NewRegistry(opencode.New()) + if err != nil { + t.Fatal(err) + } + project := request.Plan.TargetContext.ProjectRoot + planning := planner.Planner{ProjectRoot: project, ManagedRoot: filepath.Join(project, ".uap", "managed"), Paths: pathpolicy.Policy{}, Registry: registry} + plan, err := planning.Plan(context.Background(), domain.PlanRequest{Envelope: envelope, Client: request.Client, Scope: domain.ScopeProject, ProjectRoot: project, PhysicalArtifactID: "demo-0123456789ab"}) + if err != nil || plan.Status != domain.PlanReady || plan.Activation != domain.ActivationPrepared || plan.NativeRegistryRoot != project { + t.Fatalf("scoped OpenCode plan = %+v, %v", plan, err) + } + stager := providers.Stager{Registry: registry, Paths: pathpolicy.Policy{}, PluginDataRoot: filepath.Join(project, ".uap", "data")} + delivery, err := stager.Stage(context.Background(), envelope, plan, "project-opencode-command", domain.CompatibilityHints{}) + if err != nil { + t.Fatal(err) + } + if len(opencode.OpenCodeObjects(delivery.NativeObjects)) != 3 { + t.Fatalf("staged skill/command/MCP objects = %+v", delivery.NativeObjects) + } + if err := os.MkdirAll(filepath.Dir(plan.ActivePath), 0700); err != nil { + t.Fatal(err) + } + if err := os.Rename(delivery.StagingPath, plan.ActivePath); err != nil { + t.Fatal(err) + } + delivery.StagingPath = "" + request.Plan, request.Delivery, request.DeclaredName = plan, delivery, "demo" + activator := projectOpenCodeActivator(t, nativeconfig.New()) + outcome, err := activator.Activate(context.Background(), request) + if err != nil || outcome.Activation != domain.ActivationPrepared || outcome.Verification != domain.VerificationInstalled { + t.Fatalf("staged project activation = %+v, %v", outcome, err) + } + body, err := os.ReadFile(filepath.Join(project, ".opencode", "skills", "demo-echo", "SKILL.md")) + if err != nil || !strings.Contains(string(body), "UAP-PROJECT-INERT") { + t.Fatalf("rendered command skill = %q, %v", body, err) + } + activeObjects, err := stager.ProjectActiveNative(context.Background(), envelope, plan, delivery.ArtifactDigest, "") + if err != nil || !reflect.DeepEqual(activeObjects, opencode.OpenCodeObjects(delivery.NativeObjects)) { + t.Fatalf("read-only native objects changed = %+v, %v", activeObjects, err) + } + remove := domain.DeactivationRequest{Client: request.Client, TargetContext: plan.TargetContext, NativeRegistryRoot: plan.NativeRegistryRoot, Confirmed: true, NativeObjects: outcome.NativeObjects} + if out, err := activator.Deactivate(context.Background(), remove); err != nil || !out.ExternalRemovalComplete { + t.Fatalf("project cleanup = %+v, %v", out, err) + } + if !reflect.DeepEqual(beforeUser, projectGeminiInventory(t, user)) || !reflect.DeepEqual(beforeB, projectGeminiInventory(t, projectB)) { + t.Fatal("stager journey changed another scope") + } +} + +func projectOpenCodeActivator(t *testing.T, kernel nativeconfig.Kernel) providers.Activator { + t.Helper() + registry, err := clients.NewRegistry(opencode.New()) + if err != nil { + t.Fatal(err) + } + return providers.Activator{Registry: registry, NativeConfig: &kernel} +} diff --git a/install/integrationctl/agentplugins/providers/project_opencode_test.go b/install/integrationctl/agentplugins/providers/project_opencode_test.go new file mode 100644 index 000000000..c77168380 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_opencode_test.go @@ -0,0 +1,204 @@ +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" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/contracttest" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/opencode" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/providers" +) + +func projectOpenCodeRequest(t *testing.T) (domain.ActivationRequest, string, string) { + return projectOpenCodeRequestWithNames(t, []string{"docs"}) +} + +func projectOpenCodeRequestWithNames(t *testing.T, names []string) (domain.ActivationRequest, string, string) { + t.Helper() + base := t.TempDir() + user, projectA, projectB := filepath.Join(base, "user", "opencode"), filepath.Join(base, "A"), filepath.Join(base, "B") + foreign := "{ // preserve foreign settings\n\"mcp\":{\"foreign\":{\"type\":\"remote\",\"url\":\"https://foreign.test/mcp\",\"enabled\":false}},\"theme\":\"foreign\"}\n" + for _, root := range []string{user, projectA, projectB} { + projectGeminiWrite(t, filepath.Join(root, "opencode.jsonc"), foreign) + } + client := domain.DetectedClient{ClientID: domain.ClientOpenCode, ConfigRoot: user, Status: domain.DetectionDetected, OpenCodeHost: contracttest.OpenCodeV1Host{}} + target, err := domain.NewTargetContext(domain.ScopeProject, projectA, user) + if err != nil { + t.Fatal(err) + } + active := filepath.Join(projectA, ".uap", "managed", "clients", "opencode", "archive-demo") + projectGeminiWrite(t, filepath.Join(active, "skills", "docs", "SKILL.md"), "---\nname: docs\ndescription: Inert project fixture\n---\nProject skill.\n") + plan := domain.DeliveryPlan{ClientID: domain.ClientOpenCode, Scope: domain.ScopeProject, TargetContext: target, InstallIntent: domain.InstallIntentAutomatic, + NativeRegistryRoot: projectA, ActivePath: active, OpenCodeHost: client.OpenCodeHost, Components: []domain.ComponentDecision{ + {Kind: domain.ComponentSkill, Name: "docs", Support: domain.SupportNative}, + }} + envelope := domain.PackageEnvelope{Skills: map[string]domain.Skill{"docs": {Name: "docs", RelativePath: "skills/docs/SKILL.md"}}, + MCP: domain.MCPComponent{Servers: map[string]domain.MCPServer{}}} + for _, name := range names { + plan.Components = append(plan.Components, domain.ComponentDecision{Kind: domain.ComponentMCPServer, Name: name, Support: domain.SupportNative}) + envelope.MCP.Servers[name] = domain.MCPServer{Name: name, Type: "streamable-http", Decoded: map[string]any{"url": "https://managed.test/mcp"}} + } + objects, err := opencode.New().Project(context.Background(), clients.ProjectionInput{StagingPath: active, Envelope: envelope, Plan: plan, PluginDataPath: filepath.Join(projectA, ".uap", "plugin-data", "demo")}) + if err != nil { + t.Fatal(err) + } + return domain.ActivationRequest{Client: client, Plan: plan, DeclaredName: "archive-demo", Delivery: domain.StagedDelivery{ + ClientID: domain.ClientOpenCode, ActivePath: active, OwnedBase: filepath.Dir(active), NativeObjects: objects}}, projectB, user +} + +func TestProjectOpenCodeSplitNativeLifecycleKeepsUserAndOtherProject(t *testing.T) { + request, projectB, user := projectOpenCodeRequest(t) + userBefore, bBefore := projectGeminiInventory(t, user), projectGeminiInventory(t, projectB) + registry, err := clients.NewRegistry(opencode.New()) + if err != nil { + t.Fatal(err) + } + kernel := nativeconfig.New() + activator := providers.Activator{Registry: registry, NativeConfig: &kernel} + outcome, err := activator.Activate(context.Background(), request) + if err != nil || outcome.Verification != domain.VerificationInstalled || outcome.Activation != domain.ActivationPrepared || len(outcome.NativeObjects) != 2 { + t.Fatalf("project native activation: %+v, %v", outcome, err) + } + root := request.Plan.NativeRegistryRoot + if _, err := os.Stat(filepath.Join(root, ".opencode", "skills", "docs", "SKILL.md")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "skills")); !os.IsNotExist(err) { + t.Fatalf("project used user skill layout: %v", err) + } + before, err := os.ReadFile(filepath.Join(root, "opencode.jsonc")) + if err != nil || !strings.Contains(string(before), "preserve foreign settings") || !strings.Contains(string(before), "managed.test") || !strings.Contains(string(before), "foreign.test") { + t.Fatalf("native config lost exact foreign entries/comments: %s, %v", before, err) + } + request.PreviousNativeObjects = outcome.NativeObjects + for _, verifyOnly := range []bool{false, true} { + request.VerifyOnly = verifyOnly + result, err := activator.Activate(context.Background(), request) + if err != nil || result.Activation != domain.ActivationPrepared || result.Verification != domain.VerificationInstalled { + t.Fatalf("repeat project native activation: %+v, %v", result, err) + } + } + // Repair the exact same receipts after both selected native leaves disappear. + if err := os.RemoveAll(filepath.Join(root, ".opencode", "skills", "docs")); err != nil { + t.Fatal(err) + } + for _, object := range outcome.NativeObjects { + if object.ObjectID != "opencode-mcp:docs" { + continue + } + owned := nativeconfig.Receipt{Version: "1", Path: object.Path, Codec: nativeconfig.CodecOpenCode, Name: "docs", Digest: object.ManagedDigest} + if _, err := kernel.Apply(nativeconfig.Request{Paths: nativeconfig.Paths{JSON: filepath.Join(root, "opencode.json"), JSONC: filepath.Join(root, "opencode.jsonc")}, Codec: nativeconfig.CodecOpenCode, Action: nativeconfig.ActionRemove, Name: "docs", Owned: &owned}); err != nil { + t.Fatal(err) + } + } + request.VerifyOnly = false + if result, err := activator.Activate(context.Background(), request); err != nil || result.Verification != domain.VerificationInstalled || result.Activation != domain.ActivationPrepared { + t.Fatalf("same-pin native repair: %+v, %v", result, err) + } + remove := domain.DeactivationRequest{Client: request.Client, TargetContext: request.Plan.TargetContext, NativeRegistryRoot: root, Confirmed: true, NativeObjects: outcome.NativeObjects} + for range 2 { + result, err := activator.Deactivate(context.Background(), remove) + if err != nil || !result.ExternalRemovalComplete { + t.Fatalf("scoped removal: %+v, %v", result, err) + } + } + if _, err := os.Stat(filepath.Join(root, ".opencode", "skills", "docs")); !os.IsNotExist(err) { + t.Fatalf("managed skill survived remove: %v", err) + } + after, err := os.ReadFile(filepath.Join(root, "opencode.jsonc")) + if err != nil || strings.Contains(string(after), "managed.test") || !strings.Contains(string(after), "foreign.test") { + t.Fatalf("remove lost foreign native entry: %s, %v", after, err) + } + if !reflect.DeepEqual(userBefore, projectGeminiInventory(t, user)) || !reflect.DeepEqual(bBefore, projectGeminiInventory(t, projectB)) { + t.Fatal("project lifecycle mutated user or B") + } +} + +func TestProjectOpenCodeRejectsTamperedOwnershipBeforeNativeWrites(t *testing.T) { + for _, fault := range []string{"user-skill", "wrong-id", "padded-name", "relative-config"} { + t.Run(fault, func(t *testing.T) { + request, _, user := projectOpenCodeRequest(t) + before := projectGeminiInventory(t, filepath.Dir(request.Plan.TargetContext.ProjectRoot)) + for i := range request.Delivery.NativeObjects { + object := &request.Delivery.NativeObjects[i] + switch fault { + case "user-skill": + if object.ObjectID == "opencode-skill:docs" { + object.Path = filepath.Join(user, "skills", "docs") + } + case "wrong-id": + object.ObjectID = "foreign:" + object.LogicalName + case "padded-name": + object.LogicalName = " docs " + case "relative-config": + if object.ObjectID == "opencode-mcp:docs" { + object.Path = "opencode.jsonc" + } + } + } + if _, err := opencode.New().Activate(context.Background(), clients.Env{NativeConfig: nativeconfig.New()}, request); err == nil { + t.Fatal("tampered ownership reached native mutation") + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, filepath.Dir(request.Plan.TargetContext.ProjectRoot))) { + t.Fatal("refused ownership changed files") + } + }) + } +} + +func TestProjectOpenCodeRefusesScopeMismatchAndAttestationBeforeEffects(t *testing.T) { + for _, fault := range []string{"user-registry", "user-context", "missing-scope", "attestation"} { + t.Run(fault, func(t *testing.T) { + request, _, _ := projectOpenCodeRequest(t) + root := request.Plan.TargetContext.ProjectRoot + before := projectGeminiInventory(t, filepath.Dir(root)) + switch fault { + case "user-registry": + request.Plan.NativeRegistryRoot = request.Client.ConfigRoot + case "user-context": + request.Plan.TargetContext.UserConfigRoot = filepath.Join(request.Client.ConfigRoot, "other") + case "missing-scope": + request.Plan.Scope = domain.ScopeUser + case "attestation": + request.ActivationComplete = true + } + registry, err := clients.NewRegistry(opencode.New()) + if err != nil { + t.Fatal(err) + } + kernel := nativeconfig.New() + activator := providers.Activator{Registry: registry, NativeConfig: &kernel} + if _, err := activator.Activate(context.Background(), request); err == nil { + t.Fatal("provider accepted invalid scoped authority") + } + if _, err := opencode.New().Activate(context.Background(), clients.Env{NativeConfig: kernel}, request); err == nil { + t.Fatal("direct adapter reuse accepted invalid scoped authority") + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, filepath.Dir(root))) { + t.Fatal("refused project operation changed files") + } + }) + } +} + +func TestProjectOpenCodeNamespacePreflightUsesSelectedProject(t *testing.T) { + request, _, user := projectOpenCodeRequest(t) + guard := providers.OpenCodeNamespacePreflight{Kernel: nativeconfig.New()} + // A same-name user entry is a distinct configured scope and is preserved. + projectGeminiWrite(t, filepath.Join(user, "opencode.jsonc"), `{"mcp":{"docs":{"type":"remote","url":"https://user.test/mcp","enabled":false}}}`) + if err := guard.CheckMCPNamespace(context.Background(), request.Client, request.Plan, nil); err != nil { + t.Fatalf("project preflight used user config: %v", err) + } + // A disabled same-name entry in the selected project reserves that exact key. + projectGeminiWrite(t, filepath.Join(request.Plan.NativeRegistryRoot, "opencode.jsonc"), `{"mcp":{"docs":{"type":"remote","url":"https://project.test/mcp","enabled":false}}}`) + if err := guard.CheckMCPNamespace(context.Background(), request.Client, request.Plan, nil); err == nil { + t.Fatal("project preflight ignored its disabled native collision") + } +}