From b04ac5e0e22abb21ae20ef386a42abb31604f612 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:14:36 +0300 Subject: [PATCH 1/2] feat(claude): install native plugins in the selected project --- .../workflows/agentplugins-native-clients.yml | 7 +- .../claude-code-review-r1.md | 96 ++++++++++ .../claude-code-review-r2.md | 92 ++++++++++ .../claude-implementation-evidence.md | 160 +++++++++++++++++ .../agentplugins/clients/claude/identity.go | 4 +- .../agentplugins/clients/claude/lifecycle.go | 9 +- .../agentplugins/clients/claude/list.go | 40 ++++- .../agentplugins/clients/claude/plan.go | 12 +- .../agentplugins/clients/claude/probe.go | 14 +- .../agentplugins/clients/claude/project.go | 4 + .../clients/claude/project_deactivate.go | 20 +++ .../clients/claude/project_identity.go | 17 ++ .../clients/claude/project_list_test.go | 79 +++++++++ .../clients/claude/project_probe.go | 36 ++++ .../agentplugins/clients/claude/scope.go | 72 ++++++++ .../agentplugins/domain/clients.go | 4 +- .../installer/project_claude_fixture_test.go | 164 ++++++++++++++++++ .../project_claude_lifecycle_test.go | 135 ++++++++++++++ .../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/project_claude_fixture_test.go | 141 +++++++++++++++ .../project_claude_lifecycle_test.go | 112 ++++++++++++ ...tplugins_claude_project_native_e2e_test.go | 41 +++++ ...gins_claude_project_native_fixture_test.go | 147 ++++++++++++++++ scripts/run-native-client-matrix.py | 27 ++- scripts/test_native_client_matrix.py | 19 ++ 28 files changed, 1442 insertions(+), 22 deletions(-) create mode 100644 docs/plans/project-install-e2e/claude-code-review-r1.md create mode 100644 docs/plans/project-install-e2e/claude-code-review-r2.md create mode 100644 docs/plans/project-install-e2e/claude-implementation-evidence.md create mode 100644 install/integrationctl/agentplugins/clients/claude/project_deactivate.go create mode 100644 install/integrationctl/agentplugins/clients/claude/project_identity.go create mode 100644 install/integrationctl/agentplugins/clients/claude/project_list_test.go create mode 100644 install/integrationctl/agentplugins/clients/claude/project_probe.go create mode 100644 install/integrationctl/agentplugins/clients/claude/scope.go create mode 100644 install/integrationctl/agentplugins/installer/project_claude_fixture_test.go create mode 100644 install/integrationctl/agentplugins/installer/project_claude_lifecycle_test.go create mode 100644 install/integrationctl/agentplugins/providers/project_claude_fixture_test.go create mode 100644 install/integrationctl/agentplugins/providers/project_claude_lifecycle_test.go create mode 100644 repotests/agentplugins_claude_project_native_e2e_test.go create mode 100644 repotests/agentplugins_claude_project_native_fixture_test.go diff --git a/.github/workflows/agentplugins-native-clients.yml b/.github/workflows/agentplugins-native-clients.yml index 1e2da7ad1..8ba15cdca 100644 --- a/.github/workflows/agentplugins-native-clients.yml +++ b/.github/workflows/agentplugins-native-clients.yml @@ -69,10 +69,15 @@ jobs: AGENTPLUGINS_NATIVE_DISPOSABLE_HOSTED: '1' shell: bash run: | + project_args=() + if [[ '${{ matrix.client }}' == 'claude' ]]; then + project_args+=(--project-components) + fi python scripts/run-native-client-matrix.py \ --client '${{ matrix.client }}' \ --target '${{ matrix.platform.target }}' \ - --output '${{ runner.temp }}/native-client-proof' + --output '${{ runner.temp }}/native-client-proof' \ + "${project_args[@]}" - name: Upload native evidence including failed stages if: ${{ always() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 diff --git a/docs/plans/project-install-e2e/claude-code-review-r1.md b/docs/plans/project-install-e2e/claude-code-review-r1.md new file mode 100644 index 000000000..b7baf1ce8 --- /dev/null +++ b/docs/plans/project-install-e2e/claude-code-review-r1.md @@ -0,0 +1,96 @@ +# Claude project implementation independent review R1 + +Reviewer: core_clients, 2026-10-23. Reviewed initial adapter `3505f0ab`, +source-only native gate `c47d3393`, scoped SDK/data/restart contracts `40f108c4`, +and frozen corrective `32dd485e789b57c7825c6c4fb2128263901be78f` in +`work/uap-project-claude`. The original two client-plan GO results remain +separate. No production or test file was edited by this reviewer. + +## Decision: GO at 32dd485e + +One exact native identity finding is independently verified closed. No +remaining implementation blocker was found in the scoped directory/listing +lane or the authored source-only native gate. This is an implementation review, +not a completed hosted native project qualification. + +## R1-1: lexical native paths could conceal a different physical project + +At `40f108c4`, project listing accepted filepath.Clean(installPath) equal to the +expected managed path. A native spelling such as skills/alias/../artifact can +lexically normalize to project A's expected path while physically resolving to +project B/artifact if alias points at B/sub. The optional projectPath check also +normalized its input instead of demanding exact selected spelling. + +Closed in `32dd485e`. Project entries now require literal installPath equality +to the validated expected path and literal optional projectPath equality to +the selected project. The user-only parser behavior is unchanged. Regressions +include nonclean install/project paths and a real symlink traversal fixture +that proves physical resolution differs despite lexical normalization. The +authored native project fixture also rejects nonclean discovered install paths. +The correction performs no filesystem mutation or trust action. + +## Reviewed scope and native evidence boundaries + +- Validated original user/project TargetContext selects exact project + `.claude` authority and `.claude/skills/` delivery. + Layout, plan precondition, projection, native registry listing, activation + and deactivation revalidate that selected authority. Package staging remains + beside the watched skills root, preventing precommit staging from appearing + installed. No user-profile surrogate or package-derived project scope exists. +- Every project listing/removal probe uses fixed selected project cwd and the + original user config root in CLAUDE_CONFIG_DIR. The bounded environment and + existing timeout/runner supervision remain intact. The user-only list helper + remains user-scoped. Project operations do not invoke user uninstall or write + native settings/trust/account state. +- The existing package projector, standard loader, rendered command skills, + managed stdio launcher, directory transaction, immutable package receipts and + persistent data contract are reused. Core's declared project CLI lifecycle + matches the real directory/listing lane; it does not borrow an invented + direct-config engine. +- Activated/installed status requires an actual exact enabled project + name@skills-dir listing at the managed path. Wrong scope/path, disabled, + duplicate, malformed, load-error and mismatching optional-project rows do + not establish installation. Missing or failed observations do not become + success. Project attestation is rejected unconditionally before the generic + provider shortcut, including prepared component selections and direct reuse. +- Deactivation requires exact selected-project listing before core removes the + owned directory. Recognized absent evidence cannot silently remove an + allegedly active binding. No global plugin or whole shared settings document + is treated as owned. +- Public SDK fixture journeys exercise user/A/B install, unchanged update, + exact supplied-pin repair after drift despite a moved source, selected update, + status and exact removal. Distinct stdio data receipts retain marker bytes. + Postcommit listing failure leaves incomplete/nonactive ownership; a fresh + installer repairs the recorded pin and reobserves exact identity. Fixture + runners are explicitly not native client evidence. +- The native gate uses the genuine pinned artifact and actual UAP project + operations, fresh plugin list/details, command-byte checks and project-B + absence checks. Its separate opt-in reaches the unchanged disposable guard + before client launch/acquisition. Exact required-test accounting rejects skips + or omitted tests; released/draft/prepared inputs cannot select project proof. + Claude/Codex selector rows must be unioned on integration as documented. + +## Independent execution + +Go 1.26.10, GOMAXPROCS=3, `-p=2`, bounded RAM toolchain/cache at final SHA: + +- Project listing/client contracts: **PASS 0.007s**. +- Composed production provider project contracts: **PASS 0.042s**. +- Public installer user/project-A/project-B lifecycle/data/restart contract: + **PASS 0.177s**. +- Inert native discovered-skill parser contract: **PASS 0.008s**. +- Native runner Python safety/evidence suite: **56 tests PASS 0.455s**. + +An initial SDK run used this reviewer's overly restricted PATH without the +already installed Node runtime; stdio readiness was withheld and the test's +required data assertion correctly failed. Retaining the installed Node path +resolved that harness issue without source, policy or assertion changes. +The existing full client permission-denial suite requires unprivileged execution +as documented by the author; this review used the focused project contracts. + +No native Claude process, configured MCP command, model, account mutation, +login, auto-trust or hosted qualification ran in this review. Local official +binary probes previously aborted with Bun134 and remain crash evidence. The +authored native gate and historical user native results do not qualify new +project discovery, skill execution, MCP initialization/tool use, native restart +or release readiness until genuine hosted results are collected. diff --git a/docs/plans/project-install-e2e/claude-code-review-r2.md b/docs/plans/project-install-e2e/claude-code-review-r2.md new file mode 100644 index 000000000..e1dc48f3c --- /dev/null +++ b/docs/plans/project-install-e2e/claude-code-review-r2.md @@ -0,0 +1,92 @@ +# Claude project adapter independent implementation review R2 + +Reviewer: cursor_clients, 2026-10-23. Reviewed the final adapter and source-only +native gate stack through `40f108c43b7dcbd8d5e57c39799072ece048fbc3`, plus the +R1 corrective frozen checkpoint `32dd485e789b57c7825c6c4fb2128263901be78f`, in +`work/uap-project-claude`. Read repository governing/capability-preservation +instructions, the approved two-GO Claude client plan, production adapter, +public installer and provider fixtures, native gate and matrix selection. + +## Decision: GO at corrected checkpoint 32dd485e + +No remaining implementation blocker found. The original frozen adapter's +project listing path normalization weakness was already identified by the +independent R1 reviewer. This R2 independently checked its final correction: +project `installPath` and optional `projectPath` require literal equality to +the validated selected paths; nonclean traversal spellings cannot manufacture +identity. Legacy user-only listing normalization remains unchanged. The +actual symlink-plus-parent traversal regression demonstrates that the rejected +spelling can resolve into another directory even when filepath.Clean makes it +look equal to the desired managed path. + +## Reviewed boundaries + +- Native project layout is exactly `.claude/skills/`. + TargetContext, original detected user ConfigRoot, `.claude` authority, + target anchor/root and active child layout are revalidated. Staging remains + beside the watched skills directory. Core supplies transactional package + ownership, project state/data and physical root authority. +- All project registry, activation and removal listing commands preserve the + original `CLAUDE_CONFIG_DIR` and set Dir to immutable selected ProjectRoot. + They do not substitute project configuration for a user profile, depend on + ambient cwd, invoke marketplace installation or perform user uninstall. + Existing bounded environment and supervised process timeout remain in use. +- Scoped native listing requires the exact expected `name@skills-dir`, exact + selected project scope/path, enabled status, no duplicate identity, no load + errors and consistent optional projectPath. Wrong user/path identities are + collisions, malformed/unrecognized observations remain unknown, and exact + negative evidence cannot be upgraded by attestation. +- Provider activation preflight runs before generic attestation shortcuts, + and direct adapter activation invokes the same guard. Project attestation + is refused even when selected components would otherwise take a prepared + path. Recognized exact native plugin listing may establish installed plugin + activation under the existing lifecycle; it does not establish MCP + initialization, skill execution or model/task completion. +- Project removal uses the same scope/cwd/config roots and verifies exact + managed native identity before core removes the owned directory. It never + treats a shared settings file or another scope as owned. User behavior and + the existing user-only listing entrypoint retain their prior contract. +- Production loader/planner/renderer/stager/provider contracts cover rendered + commands, wrong/disabled/unknown listings, copied/changed scope/root/user + authority, attestation refusal and user/project-B isolation. +- Public SDK tests install all three scopes with separate stdio data locators, + retain data through update/repair/removal, repair from exact supplied pinned + bytes after source advance, and preserve user/B binding and package bytes. + A native listing failure injected after commit produces an incomplete, + non-active owned binding; a fresh installer instance repairs the selected + pin and reobserves exact project identity. These are production lifecycle + fixture contracts with an inert runner/launcher, not native Claude proof. +- The new source-only native test runs production UAP project operations and + genuine pinned read-only plugin list/details processes at project cwd, + checks project-B absence, exact rendered commands and foreign hashes, and + emits separate project evidence. The unchanged disposable guard runs first. + There is no session/model/trust/MCP initialization/login request. Required + test accounting refuses skips/omissions, and released/draft/prepared inputs + refuse this source-only selection. + +## Independent verification + +Using Go 1.26.10, GOMAXPROCS=3, `-p=2 -count=1`, independently ran +`./clients/claude ./providers ./installer -run '^TestProjectClaude|^TestProjectListing'` +at the clean corrected checkpoint: + +- Scoped client listing contracts: PASS, 0.008s. +- Composed project provider contracts: PASS, 0.118s. +- Public installer three-scope/data/restart lifecycle: PASS, 0.243s. +- `python scripts/test_native_client_matrix.py`: PASS, 17 tests. + +These are reviewer execution results. No native Claude process, package +payload, MCP command, model request, login or trust action was executed. +Existing complete client permission-denial tests were not rerun as task root; +the author's separately recorded unprivileged full-suite run is not relabeled +as reviewer execution. + +## Integration and native qualification boundary + +The shared native matrix delta must retain the union of independently authored +Codex and Claude project selections/workflow conditions on integration. The +author evidence explicitly records that requirement. Native fixture authorship, +parser/compile checks, historical user runtime evidence and local Bun exit134 +probe failures do not qualify the new project runtime. Genuine hosted project +listing/discovery, MCP/skill execution and fresh-session task evidence remain +separate gates. This review makes no release-readiness claim. diff --git a/docs/plans/project-install-e2e/claude-implementation-evidence.md b/docs/plans/project-install-e2e/claude-implementation-evidence.md new file mode 100644 index 000000000..cd52a4324 --- /dev/null +++ b/docs/plans/project-install-e2e/claude-implementation-evidence.md @@ -0,0 +1,160 @@ +# Claude project skills-directory plugin evidence + +Pre-code checkpoint, 2026-10-23. The all-client plan and both independent plan +review/fix rounds select one native skills-directory plugin at the selected +project's `.claude/skills/`. This extends the existing +projection/listing lifecycle; marketplace installation is not an alternate +fallback. Existing user behavior and `CLAUDE_CONFIG_DIR` authority stay intact. + +## Pinned evidence inspected before code + +- Official Linux x64 package `@anthropic-ai/claude-code-linux-x64` **2.1.263**: + https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64/-/claude-code-linux-x64-2.1.263.tgz +- Pinned archive integrity: + `sha512-0IrvpLd/0FP0acQw59T4Cvx/r4nwAXKBrW0WyhIXymzYWurPCLztB+Icu9MkeewAUI+p3PTXsSfmilv/n6XlAQ==`. +- Acquired binary SHA256 + `26d020351e8112f4006790f3cfce43b4c9df0c1bb1d0e542364d64151b81d5ba`. +- Local read-only version, plugin-help and plugin-list-help probes in the + restricted disposable namespace all aborted with Bun exit **134**. Their + logs are crash evidence, not successful CLI help or native project proof. + No private host `/proc`, guard bypass, login, trust or model call was used. +- Existing committed hosted user evidence is indexed by + `docs/evidence/client-compatibility-linux-amd64-0.1.53.json`: exact pinned + version, plugin-list observations and user lifecycle/runtime records. That + historical user evidence does not qualify this new project lane. +- Current official plugin CLI and installation documentation were opened before + implementation: + https://code.claude.com/docs/en/plugins/cli-reference and + https://code.claude.com/docs/en/plugins/install . The CLI reference documents + skills-directory list entries with id, scope, enabled and installPath, and + project/user skills-directory sources. This is the inspected listing schema; + a fixture runner is not claimed as native CLI help or execution. + +## Implementation boundaries + +The adapter will validate core's TargetContext, select exact `.claude` native +authority and `.claude/skills` watched layout, retain the original detected user +configuration environment, and run every project listing at the immutable +selected project cwd. Staging remains outside the watched skills directory. +Existing package projection, managed stdio launcher, command rendering, +transaction, receipt and ownership machinery are reused. + +Listing accepts only the expected project `name@skills-dir` identity, enabled +at the exact managed path; another scope/path, disabled or unknown listing +cannot establish installation. Project activation attestation cannot override +recognized negative or unavailable native discovery evidence. Removal validates +the same selected scope/root and exact native identity before the shared owned +directory removal. It never invokes a user uninstall or edits native settings. + +Contract/fixture evidence and new genuine hosted project native observations +will be recorded separately. The local Bun failure remains a pending native +probe boundary; no model task, account mutation, auto-trust, scanner upload or +credential creation is authorized to remove that boundary. + +## Initial implementation checkpoint + +The adapter now selects the exact project skills-directory plugin, validates +original user config/project/native root authority and fixed project cwd, and +retains the user-only listing entry point. Project listing additionally refuses +load errors and a mismatching optional projectPath. Exact enabled native listing +can establish installed activation; MCP initialization, skill execution and model +use remain separate observations. Project attestation is refused before generic +provider shortcuts, and project removal probes the same scope/cwd/config roots. + +Production loader/planner/rendering/staging/provider contracts passed (0.074s) +with an inert command runner. Public installer contracts passed (0.251s) across +user/project-A/project-B install, unchanged update, exact supplied-pin repair +after drift, moving local source update, status and exact directory removal. +The public SDK requires its host to supply exact pinned bytes for repair; it +does not silently follow moved source. User/B state and native package bytes +remain unchanged by the selected project journey. These are fixture contracts, +not native-client evidence. + +Full domain/planner packages passed (0.027s/0.044s). Existing Claude client +permission fixtures intentionally require an unprivileged process: under task +root, mode-bit denial checks fail because root can read those directories. The +compiled full client suite passed as uid 65534 without source/test changes or +skips. Existing user `TestClaude` provider contracts passed (0.166s). + +The first exact-code lint found only capitalization/import-shadow issues, which +were corrected. Full-file size/architecture lint passed on the initial adapter +and provider scope without suppressions. The expanded public-installer lint +checkpoint encountered a task-host cache write ENOSPC; its final rerun and both +independent implementation reviews remain to be recorded. The actual native +project fixture gate is still pending; local help/version crash evidence and +historical hosted user evidence must not be relabeled as project discovery. + +## Authored source-only native project gate + +`TestAgentpluginsClaudeNativeProjectPluginLifecycle` adds a separate source-only +opt-in behind the unchanged disposable-host guard. It uses genuine pinned +`plugin list --json` and `plugin details` commands at selected project cwd after +actual UAP add, unchanged update, command-rule update, repair and removal. The +listing must be project-scoped, enabled and directly under selected +`.claude/skills`; fresh project-B processes must not see the fixture plugin. +Installed rendered command bytes are checked and foreign user/cache/A/B files +retain their hashes. No session, model request, trust edit, MCP initialization, +login or credential operation is added. A dedicated +`claude-project-plugin-evidence.json` records the separate native facts and +unobserved runtime boundaries. + +The native matrix's explicit `--project-components` source-only selection also +requires this test for Claude. Released, draft, prepared and prepare-only inputs +refuse that selection; required-test accounting rejects missing/skipped tests. +The Python matrix contracts pass (17 tests). The authored Go native fixture has +not run: its first compile attempt was blocked before package setup by the full +task-host Go cache. This remains an authored gate, not native discovery evidence. +On integration, the shared selector/workflow delta must retain the union of the +independently authored Codex and Claude project rows and source conditions. + +## Final author verification after bounded resource recovery + +The completed Codex research binary intermediate was removed only after its +preserved binary SHA256 was reverified; all pinned provenance and actual help +outputs remain intact, and hosted jobs acquire independent official artifacts. +That recovered bounded memory-backed cache headroom without broad cache pruning +or deleting unrelated work. The guarded Claude native fixture now compiles; +the selected inert `TestClaudeDiscoveredSkills` parser contract passes (0.008s). +No native project client was executed by that compile/parser run. + +The public SDK fixture now includes stdio projection with an inert trusted +launcher and explicit persistent-data references. User/A/B each receive separate +data roots; update, exact-pin repair and removal retain all marker bytes and +original data authority. Its postcommit fault seam makes native listing +unavailable only after the package/binding commit: the outcome stays incomplete +and its owned binding does not manufacture active status. A fresh public +installer instance then repairs the recorded project pin and reobserves exact +project identity. The complete scoped SDK journey passes (0.171s). + +The final full Claude client suite, including strict project scope/path/load +errors, passes as uid 65534 with a short isolated temporary root; this preserves +both permission-denial and Unix-socket path-limit fixture behavior. Exact +changed-code lint and full-file size/architecture gates pass for the adapter, +domain, providers and installer packages, and both gates pass for the root native +fixtures. No new suppression rules were introduced. Genuine hosted project +listing/discovery results and both independent final adapter reviews remain +separate pending gates. + +## Independent R1 correction: exact native listing spelling + +R1 identified that cleaning a native-reported project path before comparison +could admit `alias/../artifact` even when resolving a symlink would reach a +different project's artifact. Project listing now requires literal installPath +equality and exact optional projectPath spelling; legacy user parsing retains +its prior normalization contract. Regressions cover both nonclean fields and a +real symlink traversal whose lexical clean path names A but physical resolution +reaches B. The authored native gate also refuses nonclean reported paths. +Focused client/provider/public-SDK contracts pass (0.007s/0.061s/0.253s), and +changed-code plus full-file client lint gates report zero issues. + +## Final independent implementation review closure + +The final adapter checkpoint `32dd485e789b57c7825c6c4fb2128263901be78f` +received independent R1 and R2 **GO** decisions. Both reviewers independently +rechecked the exact native path correction and the composed client, provider +and public SDK contracts; their reports are committed beside this evidence. +The R1 reviewer also ran the native runner's complete Python safety/evidence +suite (56 PASS), while R2 reran the selected native matrix contracts (17 PASS). +These implementation reviews close the earlier pending adapter-review gate. +Genuine hosted project observations remain pending and must be reported apart +from fixture execution, authored gate code and historical user evidence. diff --git a/install/integrationctl/agentplugins/clients/claude/identity.go b/install/integrationctl/agentplugins/clients/claude/identity.go index ac22fa538..8c79e2c04 100644 --- a/install/integrationctl/agentplugins/clients/claude/identity.go +++ b/install/integrationctl/agentplugins/clients/claude/identity.go @@ -39,7 +39,7 @@ func (*Adapter) InspectNativeRegistry(ctx context.Context, env clients.Env, clie if configRoot == "" && strings.TrimSpace(plan.TargetRoot) != "" { configRoot = filepath.Dir(filepath.Clean(plan.TargetRoot)) } - command, err := ClaudeListCommand(plan.NativeRegistryExecutable, configRoot, plan.ActivePath) + command, err := claudeRegistryListCommand(plan, client, configRoot) if err != nil { return clients.RegistryIndeterminate, err } @@ -53,7 +53,7 @@ func (*Adapter) InspectNativeRegistry(ctx context.Context, env clients.Env, clie if result.ExitCode != 0 { return clients.RegistryIndeterminate, fmt.Errorf("the Claude Code plugin registry command failed with exit code %d", result.ExitCode) } - switch PluginStatusFromList(result.Stdout, plan.DeclaredName, plan.ActivePath) { + switch PluginStatusFromListForScope(result.Stdout, plan.DeclaredName, plan.ActivePath, claudeListingScope(plan.Scope)) { case StatusInstalled: if managed == nil { return clients.RegistryCollision, nil diff --git a/install/integrationctl/agentplugins/clients/claude/lifecycle.go b/install/integrationctl/agentplugins/clients/claude/lifecycle.go index dba685622..7504b563f 100644 --- a/install/integrationctl/agentplugins/clients/claude/lifecycle.go +++ b/install/integrationctl/agentplugins/clients/claude/lifecycle.go @@ -30,6 +30,9 @@ func (*Adapter) VerifierAvailable(_ domain.DetectedClient, _ domain.DeliveryPlan // PreflightActivation rejects a request whose Claude path layout cannot be // probed through the trusted CLI. func (*Adapter) PreflightActivation(_ clients.Env, request domain.ActivationRequest) error { + if (request.Plan.Scope == domain.ScopeProject || request.Plan.TargetContext.Scope == domain.ScopeProject) && request.ActivationComplete { + return fmt.Errorf("claude project native discovery cannot be established by activation attestation; retry exact project listing") + } _, err := PrepareClaudeActivationProbe(request) return err } @@ -66,7 +69,7 @@ func (*Adapter) Deactivate(ctx context.Context, env clients.Env, request domain. if !shared.HasClientCLI(env, request.BackendExecutable) { return outcome, fmt.Errorf("trusted Claude Code CLI is required for exact removal verification") } - listed, err := runClaudeList(ctx, env, request.BackendExecutable, request.Client.ConfigRoot, request.ManagedArtifactPath) + listed, err := listClaudeDeactivation(ctx, env, request) if err != nil { return outcome, fmt.Errorf("verify Claude Code plugin before removal: %w", err) } @@ -74,7 +77,7 @@ func (*Adapter) Deactivate(ctx context.Context, env clients.Env, request domain. } func finishClaudeDeactivation(outcome domain.DeactivationOutcome, stdout []byte, request domain.DeactivationRequest) (domain.DeactivationOutcome, error) { - switch PluginStatusFromList(stdout, request.DeclaredName, request.ManagedArtifactPath) { + switch PluginStatusFromListForScope(stdout, request.DeclaredName, request.ManagedArtifactPath, claudeListingScope(request.TargetContext.Scope)) { case StatusInstalled: case StatusAbsent: if request.CurrentActivation == domain.ActivationActive { @@ -96,7 +99,7 @@ func verifyClaudePlugin(ctx context.Context, env clients.Env, request domain.Act if err != nil { return fmt.Errorf("verify Claude Code plugin listing: %w", err) } - switch PluginStatusFromList(listed.Stdout, request.DeclaredName, probe.ActivePath) { + switch PluginStatusFromListForScope(listed.Stdout, request.DeclaredName, probe.ActivePath, claudeListingScope(request.Plan.Scope)) { case StatusInstalled: return nil case StatusAbsent, StatusCollision: diff --git a/install/integrationctl/agentplugins/clients/claude/list.go b/install/integrationctl/agentplugins/clients/claude/list.go index 7664677f0..335455f0e 100644 --- a/install/integrationctl/agentplugins/clients/claude/list.go +++ b/install/integrationctl/agentplugins/clients/claude/list.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" ) // ListStatus is the recognized shape of a Claude Code plugin listing. @@ -23,6 +24,15 @@ const ( // PluginStatusFromList classifies a `plugin list --json` document for the // exact managed @skills-dir identity. func PluginStatusFromList(body []byte, name, activePath string) ListStatus { + return PluginStatusFromListForScope(body, name, activePath, domain.ScopeUser) +} + +// PluginStatusFromListForScope admits only the selected user/project listing; +// the existing user-only entry point retains its strict original contract. +func PluginStatusFromListForScope(body []byte, name, activePath string, scope domain.InstallScope) ListStatus { + if scope != domain.ScopeUser && scope != domain.ScopeProject { + return StatusUnknown + } decoder := json.NewDecoder(strings.NewReader(string(body))) decoder.UseNumber() parsed, err := shared.DecodeUniqueJSONValue(decoder) @@ -36,14 +46,14 @@ func PluginStatusFromList(body []byte, name, activePath string) ListStatus { if !ok { return StatusUnknown } - return classifyClaudeEntries(entries, name+"@skills-dir", filepath.Clean(activePath)) + return classifyClaudeEntries(entries, name+"@skills-dir", filepath.Clean(activePath), string(scope)) } -func classifyClaudeEntries(entries []any, expectedID, expectedPath string) ListStatus { +func classifyClaudeEntries(entries []any, expectedID, expectedPath, expectedScope string) ListStatus { seen := map[string]struct{}{} found := false for _, value := range entries { - status, matched, ok := classifyClaudeEntry(value, expectedID, expectedPath, seen) + status, matched, ok := classifyClaudeEntry(value, expectedID, expectedPath, expectedScope, seen) if !ok { return status } @@ -61,7 +71,7 @@ func classifyClaudeEntries(entries []any, expectedID, expectedPath string) ListS return StatusAbsent } -func classifyClaudeEntry(value any, expectedID, expectedPath string, seen map[string]struct{}) (ListStatus, bool, bool) { +func classifyClaudeEntry(value any, expectedID, expectedPath, expectedScope string, seen map[string]struct{}) (ListStatus, bool, bool) { entry, ok := value.(map[string]any) if !ok { return StatusUnknown, false, false @@ -81,8 +91,28 @@ func classifyClaudeEntry(value any, expectedID, expectedPath string, seen map[st if id != expectedID { return StatusUnknown, false, true } - if filepath.Clean(installPath) != expectedPath || scope != "user" { + if filepath.Clean(installPath) != expectedPath || scope != expectedScope { return StatusCollision, false, false } + if expectedScope == "project" && !claudeProjectEntryLoaded(entry, expectedPath) { + return StatusUnknown, false, false + } return StatusUnknown, enabled, true } + +func claudeProjectEntryLoaded(entry map[string]any, expectedPath string) bool { + if installPath, _ := entry["installPath"].(string); installPath != expectedPath { + return false + } + if project, exists := entry["projectPath"]; exists { + path, ok := project.(string) + if !ok || !filepath.IsAbs(path) || path != filepath.Dir(filepath.Dir(filepath.Dir(expectedPath))) { + return false + } + } + if reported, exists := entry["errors"]; exists { + loadErrors, ok := reported.([]any) + return ok && len(loadErrors) == 0 + } + return true +} diff --git a/install/integrationctl/agentplugins/clients/claude/plan.go b/install/integrationctl/agentplugins/clients/claude/plan.go index 679625813..530c75e06 100644 --- a/install/integrationctl/agentplugins/clients/claude/plan.go +++ b/install/integrationctl/agentplugins/clients/claude/plan.go @@ -32,6 +32,11 @@ func (*Adapter) TargetRoot(client domain.DetectedClient, mode domain.PackageMode // CheckPlanPrecondition refuses to plan without the CLI: the exact @skills-dir // identity of a delivered package can only be confirmed through it. func (*Adapter) CheckPlanPrecondition(in clients.PlanInput, plan *domain.DeliveryPlan) error { + if in.TargetContext.Scope == domain.ScopeProject { + if _, err := claudeProjectRoot(in.Client, in.TargetContext, plan.NativeRegistryRoot); err != nil { + return err + } + } if strings.TrimSpace(in.Client.ExecutablePath) != "" { return nil } @@ -42,7 +47,12 @@ func (*Adapter) CheckPlanPrecondition(in clients.PlanInput, plan *domain.Deliver return nil } -func (*Adapter) RefinePlan(_ context.Context, _ clients.PlanInput, plan *domain.DeliveryPlan) error { +func (*Adapter) RefinePlan(_ context.Context, in clients.PlanInput, plan *domain.DeliveryPlan) error { + if in.TargetContext.Scope == domain.ScopeProject { + if _, err := claudePlanProjectRoot(in.Client, *plan); err != nil { + return err + } + } plan.UserActions = shared.AppendUnique(plan.UserActions, "start a new Claude Code session or run /reload-plugins") return nil } diff --git a/install/integrationctl/agentplugins/clients/claude/probe.go b/install/integrationctl/agentplugins/clients/claude/probe.go index 9f182391f..94c7598a5 100644 --- a/install/integrationctl/agentplugins/clients/claude/probe.go +++ b/install/integrationctl/agentplugins/clients/claude/probe.go @@ -33,6 +33,9 @@ type ClaudeActivationProbe struct { } func PrepareClaudeActivationProbe(request domain.ActivationRequest) (ClaudeActivationProbe, error) { + if request.Plan.Scope == domain.ScopeProject || request.Plan.TargetContext.Scope == domain.ScopeProject { + return prepareClaudeProjectActivationProbe(request) + } configRoot := filepath.Clean(strings.TrimSpace(request.Client.ConfigRoot)) if !filepath.IsAbs(configRoot) { return ClaudeActivationProbe{}, fmt.Errorf("absolute Claude Code config root is required") @@ -72,6 +75,10 @@ func RunClaudeListCommand(ctx context.Context, runner ports.CommandRunner, comma } func ClaudeListCommand(executable, configRoot, activePath string) (legacyports.Command, error) { + return claudeListCommand(executable, configRoot, activePath, filepath.Join(filepath.Clean(configRoot), "skills"), "") +} + +func claudeListCommand(executable, configRoot, activePath, skillsRoot, workingDir string) (legacyports.Command, error) { executable = strings.TrimSpace(executable) if executable == "" { return legacyports.Command{}, fmt.Errorf("Claude Code executable is required") @@ -82,7 +89,7 @@ func ClaudeListCommand(executable, configRoot, activePath string) (legacyports.C } configRoot = filepath.Clean(configRoot) activePath = filepath.Clean(strings.TrimSpace(activePath)) - if !filepath.IsAbs(activePath) || filepath.Dir(activePath) != filepath.Join(configRoot, "skills") { + if !filepath.IsAbs(activePath) || filepath.Dir(activePath) != skillsRoot { return legacyports.Command{}, fmt.Errorf("the Claude Code managed plugin path is not an exact child of the configured skills root") } environment, home, err := boundedClaudeProbeEnvironment() @@ -97,10 +104,13 @@ func ClaudeListCommand(executable, configRoot, activePath string) (legacyports.C if environmentSize(environment) > claudeProbeMaxEnvironmentTotal { return legacyports.Command{}, fmt.Errorf("Claude Code probe environment exceeds the bounded size") } + if workingDir == "" { + workingDir = home + } return legacyports.Command{ Argv: []string{executable, "plugin", "list", "--json"}, Env: environment, - Dir: home, + Dir: workingDir, }, nil } diff --git a/install/integrationctl/agentplugins/clients/claude/project.go b/install/integrationctl/agentplugins/clients/claude/project.go index aff9d3b94..d63a55a1c 100644 --- a/install/integrationctl/agentplugins/clients/claude/project.go +++ b/install/integrationctl/agentplugins/clients/claude/project.go @@ -32,6 +32,10 @@ func (*Adapter) StagingBase(plan domain.DeliveryPlan) string { // ValidateTargetLayout requires the planned target root to be the exact // configured skills directory under the Claude config root. func (*Adapter) ValidateTargetLayout(plan domain.DeliveryPlan) error { + if plan.Scope == domain.ScopeProject || plan.TargetContext.Scope == domain.ScopeProject { + _, err := claudePlanProjectRoot(domain.DetectedClient{ConfigRoot: plan.TargetContext.UserConfigRoot}, plan) + return err + } if filepath.Clean(plan.TargetRoot) != filepath.Join(filepath.Clean(plan.TargetAnchor), "skills") { return fmt.Errorf("the Claude Code delivery target root must be the exact configured skills directory") } diff --git a/install/integrationctl/agentplugins/clients/claude/project_deactivate.go b/install/integrationctl/agentplugins/clients/claude/project_deactivate.go new file mode 100644 index 000000000..7017cee79 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/claude/project_deactivate.go @@ -0,0 +1,20 @@ +package claude + +import ( + "context" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + legacyports "github.com/777genius/plugin-kit-ai/install/integrationctl/ports" +) + +func listClaudeDeactivation(ctx context.Context, env clients.Env, request domain.DeactivationRequest) (legacyports.CommandResult, error) { + if request.TargetContext.Scope != domain.ScopeProject { + return runClaudeList(ctx, env, request.BackendExecutable, request.Client.ConfigRoot, request.ManagedArtifactPath) + } + command, err := claudeProjectListCommand(request.BackendExecutable, request.Client, request.TargetContext, request.NativeRegistryRoot, request.ManagedArtifactPath) + if err != nil { + return legacyports.CommandResult{}, err + } + return runPreparedClaudeList(ctx, env, command) +} diff --git a/install/integrationctl/agentplugins/clients/claude/project_identity.go b/install/integrationctl/agentplugins/clients/claude/project_identity.go new file mode 100644 index 000000000..c15031865 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/claude/project_identity.go @@ -0,0 +1,17 @@ +package claude + +import ( + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + legacyports "github.com/777genius/plugin-kit-ai/install/integrationctl/ports" +) + +func claudeRegistryListCommand(plan domain.DeliveryPlan, client domain.DetectedClient, userRoot string) (legacyports.Command, error) { + if plan.Scope == domain.ScopeProject || plan.TargetContext.Scope == domain.ScopeProject { + root, err := claudePlanProjectRoot(client, plan) + if err != nil { + return legacyports.Command{}, err + } + return claudeProjectListCommand(plan.NativeRegistryExecutable, client, plan.TargetContext, root, plan.ActivePath) + } + return ClaudeListCommand(plan.NativeRegistryExecutable, userRoot, plan.ActivePath) +} diff --git a/install/integrationctl/agentplugins/clients/claude/project_list_test.go b/install/integrationctl/agentplugins/clients/claude/project_list_test.go new file mode 100644 index 000000000..d3465f5a7 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/claude/project_list_test.go @@ -0,0 +1,79 @@ +package claude + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +func TestProjectListingRequiresExactScopePathAndNoLoadErrors(t *testing.T) { + project := t.TempDir() + active := filepath.Join(project, ".claude", "skills", "demo-managed") + for _, change := range []string{"exact", "user-scope", "other-path", "other-project", "nonclean-path", "nonclean-project", "disabled", "load-errors", "malformed-errors", "duplicate"} { + t.Run(change, func(t *testing.T) { + entry := map[string]any{"id": "demo@skills-dir", "scope": "project", "enabled": true, "installPath": active, "projectPath": project} + switch change { + case "user-scope": + entry["scope"] = "user" + case "other-path": + entry["installPath"] = active + "-other" + case "other-project": + entry["projectPath"] = project + "-other" + case "nonclean-path": + entry["installPath"] = filepath.Dir(active) + string(filepath.Separator) + "alias" + string(filepath.Separator) + ".." + string(filepath.Separator) + filepath.Base(active) + case "nonclean-project": + entry["projectPath"] = project + string(filepath.Separator) + "alias" + string(filepath.Separator) + ".." + case "disabled": + entry["enabled"] = false + case "load-errors": + entry["errors"] = []string{"managed policy blocked load"} + case "malformed-errors": + entry["errors"] = "unrecognized" + } + entries := []map[string]any{entry} + if change == "duplicate" { + entries = append(entries, entry) + } + body, err := json.Marshal(entries) + if err != nil { + t.Fatal(err) + } + got := PluginStatusFromListForScope(body, "demo", active, domain.ScopeProject) + if (got == StatusInstalled) != (change == "exact") { + t.Fatalf("%s listing status=%v", change, got) + } + if change == "exact" && PluginStatusFromList(body, "demo", active) == StatusInstalled { + t.Fatal("project listing weakened user-only caller") + } + }) + } +} + +func TestProjectListingRejectsSymlinkTraversalSpelling(t *testing.T) { + project, other := t.TempDir(), t.TempDir() + skills := filepath.Join(project, ".claude", "skills") + for _, path := range []string{skills, filepath.Join(other, "sub"), filepath.Join(other, "demo-managed")} { + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(filepath.Join(other, "sub"), filepath.Join(skills, "alias")); err != nil { + t.Skipf("directory symlink unavailable: %v", err) + } + active := filepath.Join(skills, "demo-managed") + listingPath := filepath.Join(skills, "alias") + string(filepath.Separator) + ".." + string(filepath.Separator) + filepath.Base(active) + resolved, err := filepath.EvalSymlinks(listingPath) + if err != nil || resolved != filepath.Join(other, "demo-managed") || filepath.Clean(listingPath) != active { + t.Fatalf("traversal fixture=%q,%v", resolved, err) + } + body, err := json.Marshal([]map[string]any{{"id": "demo@skills-dir", "scope": "project", "enabled": true, "installPath": listingPath, "projectPath": project}}) + if err != nil { + t.Fatal(err) + } + if PluginStatusFromListForScope(body, "demo", active, domain.ScopeProject) == StatusInstalled { + t.Fatal("native traversal spelling manufactured project identity") + } +} diff --git a/install/integrationctl/agentplugins/clients/claude/project_probe.go b/install/integrationctl/agentplugins/clients/claude/project_probe.go new file mode 100644 index 000000000..2ccbdf033 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/claude/project_probe.go @@ -0,0 +1,36 @@ +package claude + +import ( + "fmt" + "path/filepath" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + legacyports "github.com/777genius/plugin-kit-ai/install/integrationctl/ports" +) + +func prepareClaudeProjectActivationProbe(request domain.ActivationRequest) (ClaudeActivationProbe, error) { + root, err := claudePlanProjectRoot(request.Client, request.Plan) + if err != nil { + return ClaudeActivationProbe{}, err + } + if request.Delivery.ActivePath != "" && request.Delivery.ActivePath != request.Plan.ActivePath { + return ClaudeActivationProbe{}, fmt.Errorf("claude project activation path differs from selected delivery") + } + command, err := claudeProjectListCommand(request.BackendExecutable, request.Client, request.Plan.TargetContext, root, request.Plan.ActivePath) + return ClaudeActivationProbe{Command: command, ConfigRoot: request.Client.ConfigRoot, ActivePath: request.Plan.ActivePath}, err +} + +func claudeProjectListCommand(executable string, client domain.DetectedClient, target domain.TargetContext, registryRoot, activePath string) (legacyports.Command, error) { + root, err := claudeProjectRoot(client, target, registryRoot) + if err != nil { + return legacyports.Command{}, err + } + if filepath.Clean(activePath) != activePath || filepath.Dir(activePath) != filepath.Join(root, "skills") { + return legacyports.Command{}, fmt.Errorf("claude project artifact differs from exact skills-directory layout") + } + if err := pathpolicy.RequireContainedChild(root, activePath); err != nil { + return legacyports.Command{}, err + } + return claudeListCommand(executable, client.ConfigRoot, activePath, filepath.Join(root, "skills"), target.ProjectRoot) +} diff --git a/install/integrationctl/agentplugins/clients/claude/scope.go b/install/integrationctl/agentplugins/clients/claude/scope.go new file mode 100644 index 000000000..8481938d4 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/claude/scope.go @@ -0,0 +1,72 @@ +package claude + +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/domain" +) + +var ( + _ clients.ProjectTargetLayout = (*Adapter)(nil) + _ clients.NativeRegistryLayout = (*Adapter)(nil) +) + +func (*Adapter) NativeRegistry(in clients.PlanInput) (string, string) { + if in.TargetContext.Scope == domain.ScopeProject { + return filepath.Join(in.TargetContext.ProjectRoot, ".claude"), in.Client.ExecutablePath + } + return in.Client.ConfigRoot, in.Client.ExecutablePath +} + +func (*Adapter) ProjectTargetRoot(client domain.DetectedClient, mode domain.PackageMode, _ string, target domain.TargetContext) (string, string, error) { + if mode != domain.PackageProjection { + return "", "", fmt.Errorf("claude project skills-directory installation requires projected delivery") + } + root, err := claudeProjectRoot(client, target, "") + if err != nil { + return "", "", err + } + return root, filepath.Join(root, "skills"), nil +} + +func claudeProjectRoot(client domain.DetectedClient, target domain.TargetContext, planned string) (string, error) { + validated, err := domain.NewTargetContext(target.Scope, target.ProjectRoot, target.UserConfigRoot) + if err != nil { + return "", err + } + if validated.Scope != domain.ScopeProject || validated.UserConfigRoot != client.ConfigRoot { + return "", fmt.Errorf("claude project context differs from selected scope or original user configuration") + } + root := filepath.Join(validated.ProjectRoot, ".claude") + if planned != "" && planned != root { + return "", fmt.Errorf("claude native registry differs from selected project") + } + if err := pathpolicy.RequireContainedChild(validated.ProjectRoot, root); err != nil { + return "", err + } + return root, nil +} + +func claudePlanProjectRoot(client domain.DetectedClient, plan domain.DeliveryPlan) (string, error) { + if plan.Scope != domain.ScopeProject || plan.TargetContext.Scope != domain.ScopeProject { + return "", fmt.Errorf("claude project lifecycle requires matching project scope") + } + root, err := claudeProjectRoot(client, plan.TargetContext, plan.NativeRegistryRoot) + if err != nil { + return "", err + } + if plan.TargetAnchor != root || plan.TargetRoot != filepath.Join(root, "skills") || filepath.Dir(plan.ActivePath) != plan.TargetRoot { + return "", fmt.Errorf("claude project delivery differs from exact skills-directory layout") + } + return root, nil +} + +func claudeListingScope(scope domain.InstallScope) domain.InstallScope { + if scope == domain.ScopeProject { + return scope + } + return domain.ScopeUser +} diff --git a/install/integrationctl/agentplugins/domain/clients.go b/install/integrationctl/agentplugins/domain/clients.go index eaed3b52d..a350de742 100644 --- a/install/integrationctl/agentplugins/domain/clients.go +++ b/install/integrationctl/agentplugins/domain/clients.go @@ -125,12 +125,12 @@ var clientDefinitions = []ClientDefinition{ LifecycleKind: LifecycleCLIRegistry, TracksNativeEffects: true, }), - withActivation(clientDefinition(ClientClaude, "Claude Code", "claude", "managed", "projected", false, PackageProjection, SupportProjected, SupportProjected, SupportProjected, SupportUnsupported, SupportUnsupported, ClientTraits{ + withProjectScope(withActivation(clientDefinition(ClientClaude, "Claude Code", "claude", "managed", "projected", false, PackageProjection, SupportProjected, SupportProjected, SupportProjected, SupportUnsupported, SupportUnsupported, ClientTraits{ QualifiedLocalInstallerFacade: true, InstallIntents: []InstallIntent{InstallIntentAutomatic}, LifecycleKind: LifecycleCLIRegistry, UsesManagedStdioLauncher: true, - }), ActivationAutomatic), + }), ActivationAutomatic), LifecycleCLIRegistry), withProjectScope(clientDefinition(ClientGemini, "Gemini CLI", "gemini", "managed", "native", false, PackageNative, SupportNative, SupportNative, SupportNative, SupportUnsupported, SupportUnsupported, ClientTraits{ InstallIntents: []InstallIntent{InstallIntentAutomatic}, LifecycleKind: LifecycleNativeConfig, diff --git a/install/integrationctl/agentplugins/installer/project_claude_fixture_test.go b/install/integrationctl/agentplugins/installer/project_claude_fixture_test.go new file mode 100644 index 000000000..2dae7aec4 --- /dev/null +++ b/install/integrationctl/agentplugins/installer/project_claude_fixture_test.go @@ -0,0 +1,164 @@ +package installer_test + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/statev2" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/claude" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/installer" + legacyports "github.com/777genius/plugin-kit-ai/install/integrationctl/ports" +) + +type claudeSDKFixture struct { + project, state, user, packageRoot, executable string + dataRoot string + failAfterCommit bool + engine *installer.Engine + runner *claudeSDKRunner + request installer.Request +} + +func claudeSDKWrite(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } +} + +func newClaudeSDKFixture(t *testing.T, base, project, user, pkg string) *claudeSDKFixture { + t.Helper() + f := &claudeSDKFixture{project: project, user: user, packageRoot: pkg, state: filepath.Join(base, "user-uap"), executable: filepath.Join(base, "inert-claude")} + scope := domain.ScopeUser + if project != "" { + f.state = filepath.Join(project, ".uap") + scope = domain.ScopeProject + } + claudeSDKWrite(t, f.executable, "inert test artifact; never executed\n") + if err := os.Chmod(f.executable, 0700); err != nil { + t.Fatal(err) + } + registry, err := clients.NewRegistry(claude.New()) + if err != nil { + t.Fatal(err) + } + f.runner = &claudeSDKRunner{t: t, user: user, project: project, executable: f.executable} + f.engine, err = installer.New(installer.Config{ProjectRoot: project, StateRoot: f.state, Registry: registry, Runner: f.runner, HelperExecutable: f.executable, EnableNativeObserver: true, TrustedLocalPackages: true, + OnCommittedBinding: func(context.Context, installer.BindingFacts) error { + if f.failAfterCommit { + f.runner.fail = true + } + return nil + }, + }) + if err != nil { + t.Fatal(err) + } + f.request = installer.Request{Scope: scope, ProjectRoot: project, Operation: installer.OpInstall, PackageRoot: pkg, ClientID: "claude", ClientConfigRoot: user, ClientExecutable: f.executable, InstallationID: "00000000-0000-4000-8000-000000000263", OperationID: "claude-project-sdk-install", RequiredComponents: []string{"skills", "mcp"}} + return f +} + +func (f *claudeSDKFixture) apply(t *testing.T, operation installer.Operation, label string) installer.Result { + t.Helper() + f.request.Operation, f.request.OperationID = operation, label + handle, err := f.engine.Prepare(t.Context(), f.request) + if err != nil { + t.Fatalf("prepare SDK %s (%s): %v", operation, label, err) + } + defer func() { + if err := handle.Close(); err != nil { + t.Error(err) + } + }() + result, err := f.engine.Apply(t.Context(), handle, installer.Decision{Confirmed: true}) + if err != nil || result.Outcome != installer.OutcomeCompleted && result.Outcome != installer.OutcomeUnchanged { + t.Fatalf("SDK %s=%+v,%v", operation, result, err) + } + if result.Binding.DataRoot != "" { + if f.dataRoot != "" && f.dataRoot != result.Binding.DataRoot { + t.Fatal("SDK lifecycle changed persistent data authority") + } + f.dataRoot = result.Binding.DataRoot + } + return result +} + +func (f *claudeSDKFixture) binding(t *testing.T) domain.ClientBinding { + t.Helper() + state, err := (statev2.Store{Path: filepath.Join(f.state, "state-v2.json"), ProjectRoot: f.project}).Load() + if err != nil || len(state.Installations) != 1 { + t.Fatalf("SDK state=%+v,%v", state, err) + } + for _, binding := range state.Installations[0].Clients { + return binding + } + t.Fatal("SDK binding absent") + return domain.ClientBinding{} +} + +// This inert runner discovers fixture manifests from the production-selected +// directory. It is a command contract boundary, not genuine Claude evidence. +type claudeSDKRunner struct { + t *testing.T + user, project, executable string + fail bool +} + +func (r *claudeSDKRunner) Run(_ context.Context, command legacyports.Command) (legacyports.CommandResult, error) { + r.t.Helper() + if !reflect.DeepEqual(command.Argv, []string{r.executable, "plugin", "list", "--json"}) { + r.t.Fatalf("unexpected SDK native command=%+v", command) + } + root, scope := r.user, "user" + if r.project != "" { + root, scope = filepath.Join(r.project, ".claude"), "project" + if command.Dir != r.project { + r.t.Fatalf("SDK project cwd=%q", command.Dir) + } + } + want := "CLAUDE_CONFIG_DIR=" + r.user + found := false + for _, entry := range command.Env { + if entry == want { + found = true + } + } + if !found { + r.t.Fatalf("SDK replaced original user config=%q", command.Env) + } + if r.fail { + return legacyports.CommandResult{}, fmt.Errorf("inert unavailable native listing") + } + entries, err := os.ReadDir(filepath.Join(root, "skills")) + if err != nil && !os.IsNotExist(err) { + return legacyports.CommandResult{}, err + } + listing := []map[string]any{} + for _, entry := range entries { + path := filepath.Join(root, "skills", entry.Name()) + body, err := os.ReadFile(filepath.Join(path, ".claude-plugin", "plugin.json")) + if os.IsNotExist(err) { + continue + } + if err != nil { + return legacyports.CommandResult{}, err + } + var manifest map[string]any + if err := json.Unmarshal(body, &manifest); err != nil { + return legacyports.CommandResult{}, err + } + listing = append(listing, map[string]any{"id": fmt.Sprint(manifest["name"]) + "@skills-dir", "scope": scope, "enabled": true, "installPath": path}) + } + body, err := json.Marshal(listing) + return legacyports.CommandResult{Stdout: body}, err +} diff --git a/install/integrationctl/agentplugins/installer/project_claude_lifecycle_test.go b/install/integrationctl/agentplugins/installer/project_claude_lifecycle_test.go new file mode 100644 index 000000000..351ed5d42 --- /dev/null +++ b/install/integrationctl/agentplugins/installer/project_claude_lifecycle_test.go @@ -0,0 +1,135 @@ +package installer_test + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/claude" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/installer" +) + +func claudeSDKBytes(t *testing.T, path string) string { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(body) +} + +func TestProjectClaudePublicInstallerLifecycleSeparatesUserAndProjects(t *testing.T) { + base := t.TempDir() + user, pkg := filepath.Join(base, "user", ".claude"), filepath.Join(base, "package") + for _, root := range []string{user, filepath.Join(base, "A", ".claude"), filepath.Join(base, "B", ".claude")} { + claudeSDKWrite(t, filepath.Join(root, "settings.json"), `{"foreign":true}`) + claudeSDKWrite(t, filepath.Join(root, "skills", "foreign", "SKILL.md"), "foreign skill\n") + } + claudeSDKWrite(t, filepath.Join(pkg, "plugin.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"sdk-claude","version":"1.0.0"}`) + const original = "---\nname: docs\ndescription: Inert SDK fixture\n---\nOriginal pinned bytes.\n" + claudeSDKWrite(t, filepath.Join(pkg, "skills", "docs", "SKILL.md"), original) + claudeSDKWrite(t, filepath.Join(pkg, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"docs":{"type":"stdio","command":"node","args":["${PLUGIN_ROOT}/server.js"],"env":{"DATA":"${PLUGIN_DATA}"}}}}`) + claudeSDKWrite(t, filepath.Join(pkg, "server.js"), "// Inert SDK fixture; never executed.\n") + fixtures := make([]*claudeSDKFixture, 0, 3) + for _, project := range []string{"", filepath.Join(base, "A"), filepath.Join(base, "B")} { + f := newClaudeSDKFixture(t, base, project, user, pkg) + f.apply(t, installer.OpInstall, "install") + if f.dataRoot == "" { + t.Fatal("SDK omitted persistent data locator") + } + claudeSDKWrite(t, filepath.Join(f.dataRoot, "keep.txt"), "retained scoped data\n") + binding := f.binding(t) + if binding.Activation != domain.ActivationActive || binding.Scope != string(f.request.Scope) { + t.Fatalf("SDK scoped binding=%+v", binding) + } + fixtures = append(fixtures, f) + } + claudeSDKUpdateRepairRemove(t, fixtures, original) +} + +func claudeSDKUpdateRepairRemove(t *testing.T, fixtures []*claudeSDKFixture, original string) { + t.Helper() + f := fixtures[1] + userBinding, bBinding := fixtures[0].binding(t), fixtures[2].binding(t) + beforeUser, beforeB := claudeSDKBytes(t, filepath.Join(fixtures[0].state, "state-v2.json")), claudeSDKBytes(t, filepath.Join(fixtures[2].state, "state-v2.json")) + binding := f.binding(t) + path := filepath.Join(binding.TargetLocator, claude.ClaudeRuntimeDirectory, "skills", "docs", "SKILL.md") + if result := f.apply(t, installer.OpUpdate, "unchanged"); result.Outcome != installer.OutcomeUnchanged { + t.Fatalf("unchanged SDK update=%+v", result) + } + claudeSDKWrite(t, path, "external drift\n") + claudeSDKWrite(t, filepath.Join(f.packageRoot, "skills", "docs", "SKILL.md"), "---\nname: docs\ndescription: Advanced SDK fixture\n---\nMoving source bytes.\n") + claudeSDKRepairPinned(t, f, original) + if claudeSDKBytes(t, path) != original { + t.Fatal("repair followed moving source") + } + claudeSDKInterruptedUpdateAndRestart(t, f) + if claudeSDKBytes(t, path) == original { + t.Fatal("update did not advance selected project") + } + view, err := f.engine.Inspect(t.Context()) + if err != nil || view.Recovery.Required { + t.Fatalf("SDK status=%+v,%v", view, err) + } + f.apply(t, installer.OpRemove, "remove-selected-project") + if _, err := os.Stat(binding.TargetLocator); !os.IsNotExist(err) { + t.Fatalf("SDK artifact survived remove: %v", err) + } + if !reflect.DeepEqual(userBinding, fixtures[0].binding(t)) || !reflect.DeepEqual(bBinding, fixtures[2].binding(t)) || beforeUser != claudeSDKBytes(t, filepath.Join(fixtures[0].state, "state-v2.json")) || beforeB != claudeSDKBytes(t, filepath.Join(fixtures[2].state, "state-v2.json")) { + t.Fatal("project SDK lifecycle changed user/project B ownership") + } + for _, other := range []domain.ClientBinding{userBinding, bBinding} { + if claudeSDKBytes(t, filepath.Join(other.TargetLocator, claude.ClaudeRuntimeDirectory, "skills", "docs", "SKILL.md")) != original { + t.Fatal("project SDK lifecycle changed another native package") + } + } + if claudeSDKBytes(t, filepath.Join(f.project, ".claude", "settings.json")) != `{"foreign":true}` { + t.Fatal("SDK lifecycle modified foreign settings") + } + for _, selected := range fixtures { + if claudeSDKBytes(t, filepath.Join(selected.dataRoot, "keep.txt")) != "retained scoped data\n" { + t.Fatal("project SDK lifecycle consumed scoped data") + } + } +} + +func claudeSDKInterruptedUpdateAndRestart(t *testing.T, f *claudeSDKFixture) { + t.Helper() + f.failAfterCommit = true + f.request.Operation, f.request.OperationID = installer.OpUpdate, "update-listing-unavailable-after-commit" + handle, err := f.engine.Prepare(t.Context(), f.request) + if err != nil { + t.Fatal(err) + } + result, applyErr := f.engine.Apply(t.Context(), handle, installer.Decision{Confirmed: true}) + if err := handle.Close(); err != nil { + t.Fatal(err) + } + if applyErr == nil || result.Outcome != installer.OutcomeIncomplete || !result.Mutated { + t.Fatalf("postcommit listing failure=%+v,%v", result, applyErr) + } + if binding := f.binding(t); binding.Activation == domain.ActivationActive || binding.TargetLocator != result.Binding.TargetPath { + t.Fatalf("failed native observation manufactured active or lost binding=%+v", binding) + } + fresh := newClaudeSDKFixture(t, filepath.Dir(f.executable), f.project, f.user, f.packageRoot) + fresh.dataRoot = f.dataRoot + *f = *fresh + f.apply(t, installer.OpRepair, "repair-after-process-restart") + if binding := f.binding(t); binding.Activation != domain.ActivationActive { + t.Fatalf("fresh public SDK did not reobserve exact project identity=%+v", binding) + } +} + +func claudeSDKRepairPinned(t *testing.T, f *claudeSDKFixture, original string) { + t.Helper() + pinned := t.TempDir() + for _, name := range []string{"plugin.json", "mcp.json", "server.js"} { + claudeSDKWrite(t, filepath.Join(pinned, name), claudeSDKBytes(t, filepath.Join(f.packageRoot, name))) + } + claudeSDKWrite(t, filepath.Join(pinned, "skills", "docs", "SKILL.md"), original) + f.request.PackageRoot, f.request.SourceRoot = pinned, f.packageRoot + f.apply(t, installer.OpRepair, "repair-installed-pin") + f.request.PackageRoot, f.request.SourceRoot = f.packageRoot, "" +} 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..77ad0ef4f 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json @@ -55,7 +55,8 @@ }, "PackageMode": "compatibility_projection", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "projected" }, 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..8eed2b465 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 @@ -79,7 +79,8 @@ }, "PackageMode": "compatibility_projection", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "projected" }, 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..1cc469e9f 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json @@ -51,7 +51,8 @@ }, "PackageMode": "compatibility_projection", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "projected" }, 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..f700b7607 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json @@ -62,7 +62,8 @@ }, "PackageMode": "compatibility_projection", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "projected" }, diff --git a/install/integrationctl/agentplugins/providers/project_claude_fixture_test.go b/install/integrationctl/agentplugins/providers/project_claude_fixture_test.go new file mode 100644 index 000000000..9e53d3d37 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_claude_fixture_test.go @@ -0,0 +1,141 @@ +package providers_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "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/specregistry" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/claude" + "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" + legacyports "github.com/777genius/plugin-kit-ai/install/integrationctl/ports" +) + +type claudeProjectFixture struct { + request domain.ActivationRequest + user, other, source string + registry *clients.Registry + stager providers.Stager + runner *claudeProjectRunner +} + +func newClaudeProjectFixture(t *testing.T) *claudeProjectFixture { + t.Helper() + base := projectGeminiTemp(t) + project, user, other := filepath.Join(base, "A"), filepath.Join(base, "user", ".claude"), filepath.Join(base, "B") + for _, root := range []string{user, filepath.Join(project, ".claude"), filepath.Join(other, ".claude")} { + projectGeminiWrite(t, filepath.Join(root, "settings.json"), `{"enabledPlugins":{"foreign@skills-dir":false}}`) + projectGeminiWrite(t, filepath.Join(root, "skills", "foreign", "SKILL.md"), "foreign skill\n") + } + registry, err := clients.NewRegistry(claude.New()) + if err != nil { + t.Fatal(err) + } + f := &claudeProjectFixture{user: user, other: other, source: filepath.Join(base, "source"), registry: registry} + f.request.Client = domain.DetectedClient{ClientID: domain.ClientClaude, ConfigRoot: user, ExecutablePath: "/inert/claude-not-executed", Status: domain.DetectionDetected} + f.request.Plan.TargetContext, err = domain.NewTargetContext(domain.ScopeProject, project, user) + if err != nil { + t.Fatal(err) + } + f.stager = providers.Stager{Registry: registry, Paths: pathpolicy.Policy{}, PluginDataRoot: filepath.Join(project, ".uap", "data")} + f.runner = &claudeProjectRunner{t: t, fixture: f, scope: "project", enabled: true} + projectGeminiWrite(t, filepath.Join(f.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(f.source, "skills", "docs", "SKILL.md"), "---\nname: docs\ndescription: Inert Claude fixture\n---\nVersion one.\n") + projectGeminiWrite(t, filepath.Join(f.source, filepath.FromSlash(commandext.RelativeDirectory), "echo.md"), "---\ndescription: Echo inert marker\ndisable-model-invocation: false\n---\nUAP-CLAUDE-PROJECT-MARKER\n") + projectGeminiWrite(t, filepath.Join(f.source, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"docs":{"type":"streamable-http","url":"https://managed.test/v1"}}}`) + f.stage(t, "initial") + return f +} + +func (f *claudeProjectFixture) stage(t *testing.T, operation string) { + t.Helper() + schemas, err := specregistry.New() + if err != nil { + t.Fatal(err) + } + envelope, err := (loader.Loader{Registry: schemas}).Load(t.Context(), domain.LoadInput{SnapshotRoot: f.source}) + if err != nil { + t.Fatal(err) + } + project := f.request.Plan.TargetContext.ProjectRoot + planning := planner.Planner{ProjectRoot: project, ManagedRoot: filepath.Join(project, ".uap", "managed"), Paths: pathpolicy.Policy{}, Registry: f.registry} + plan, err := planning.Plan(t.Context(), domain.PlanRequest{Envelope: envelope, Client: f.request.Client, Scope: domain.ScopeProject, ProjectRoot: project, PhysicalArtifactID: "demo-0123456789ab"}) + if err != nil || plan.Status != domain.PlanReady || plan.NativeRegistryRoot != filepath.Join(project, ".claude") { + t.Fatalf("Claude project plan=%+v, %v", plan, err) + } + delivery, err := f.stager.Stage(t.Context(), envelope, plan, operation, domain.CompatibilityHints{}) + if err != nil { + t.Fatal(err) + } + if filepath.Dir(delivery.StagingPath) == plan.TargetRoot { + t.Fatal("staging entered watched skill root") + } + if err := os.MkdirAll(plan.TargetRoot, 0700); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(plan.ActivePath); err != nil { + t.Fatal(err) + } + if err := os.Rename(delivery.StagingPath, plan.ActivePath); err != nil { + t.Fatal(err) + } + delivery.StagingPath = "" + f.request.Plan, f.request.Delivery, f.request.DeclaredName, f.request.BackendExecutable = plan, delivery, "demo", f.request.Client.ExecutablePath +} + +func (f *claudeProjectFixture) activator() providers.Activator { + return providers.Activator{Registry: f.registry, Runner: f.runner} +} + +func (f *claudeProjectFixture) removal() domain.DeactivationRequest { + return domain.DeactivationRequest{Client: f.request.Client, TargetContext: f.request.Plan.TargetContext, NativeRegistryRoot: f.request.Plan.NativeRegistryRoot, + Confirmed: true, BackendExecutable: f.request.BackendExecutable, DeclaredName: "demo", ManagedArtifactPath: f.request.Plan.ActivePath, CurrentActivation: domain.ActivationActive} +} + +// A fixture runner validates production command contracts without executing a +// native client or package. Its listing is fixture evidence only. +type claudeProjectRunner struct { + t *testing.T + fixture *claudeProjectFixture + scope string + enabled bool + wrongPath, malformed bool + calls int +} + +func (r *claudeProjectRunner) Run(_ context.Context, command legacyports.Command) (legacyports.CommandResult, error) { + r.t.Helper() + f := r.fixture + if !reflect.DeepEqual(command.Argv, []string{f.request.Client.ExecutablePath, "plugin", "list", "--json"}) || command.Dir != f.request.Plan.TargetContext.ProjectRoot { + r.t.Fatalf("wrong scoped native command: %+v", command) + } + want := "CLAUDE_CONFIG_DIR=" + f.user + found := false + for _, value := range command.Env { + if value == want { + found = true + } + } + if !found { + r.t.Fatalf("original user authority lost: %q", command.Env) + } + r.calls++ + if r.malformed { + return legacyports.CommandResult{Stdout: []byte(`{"unexpected":true}`)}, nil + } + path := f.request.Plan.ActivePath + if r.wrongPath { + path = filepath.Join(f.other, ".claude", "skills", filepath.Base(path)) + } + body, err := json.Marshal([]map[string]any{{"id": "demo@skills-dir", "scope": r.scope, "enabled": r.enabled, "installPath": path}}) + return legacyports.CommandResult{Stdout: body}, err +} diff --git a/install/integrationctl/agentplugins/providers/project_claude_lifecycle_test.go b/install/integrationctl/agentplugins/providers/project_claude_lifecycle_test.go new file mode 100644 index 000000000..454cf2656 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_claude_lifecycle_test.go @@ -0,0 +1,112 @@ +package providers_test + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/claude" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +// Production loader/planner/rendering/staging and provider lifecycle, with an +// inert exact-list runner. This is not a native Claude execution observation. +func TestProjectClaudeComposedProjectionListingAndScopeIsolation(t *testing.T) { + f := newClaudeProjectFixture(t) + user, other := projectGeminiInventory(t, f.user), projectGeminiInventory(t, f.other) + actor := f.activator() + for _, verify := range []bool{false, true, false} { + f.request.VerifyOnly = verify + outcome, err := actor.Activate(t.Context(), f.request) + if err != nil || outcome.Activation != domain.ActivationActive || outcome.Verification != domain.VerificationInstalled || outcome.ActivationAttested { + t.Fatalf("exact project listing=%+v,%v", outcome, err) + } + } + active := f.request.Plan.ActivePath + body, err := os.ReadFile(filepath.Join(active, claude.ClaudeRuntimeDirectory, "skills", "demo-echo", "SKILL.md")) + if err != nil || !strings.Contains(string(body), "UAP-CLAUDE-PROJECT-MARKER") { + t.Fatalf("rendered command=%q,%v", body, err) + } + mcp, err := os.ReadFile(filepath.Join(active, ".mcp.json")) + if err != nil || !strings.Contains(string(mcp), "https://managed.test/v1") { + t.Fatalf("project plugin MCP=%q,%v", mcp, err) + } + finding, err := claude.New().InspectNativeRegistry(t.Context(), clients.Env{Runner: f.runner}, f.request.Client, f.request.Plan, &domain.ClientBinding{}) + if err != nil || finding != clients.RegistryExpected { + t.Fatalf("project registry=%v,%v", finding, err) + } + for range 2 { + outcome, err := actor.Deactivate(t.Context(), f.removal()) + if err != nil || !outcome.ExternalRemovalComplete { + t.Fatalf("exact project removal admission=%+v,%v", outcome, err) + } + } + if !reflect.DeepEqual(user, projectGeminiInventory(t, f.user)) || !reflect.DeepEqual(other, projectGeminiInventory(t, f.other)) { + t.Fatal("project listing/projection changed other scopes") + } +} + +func TestProjectClaudeWrongDisabledOrUnknownNativeListingRefuses(t *testing.T) { + for _, condition := range []string{"user-scope", "wrong-project", "disabled", "malformed"} { + t.Run(condition, func(t *testing.T) { + f := newClaudeProjectFixture(t) + switch condition { + case "user-scope": + f.runner.scope = "user" + case "wrong-project": + f.runner.wrongPath = true + case "disabled": + f.runner.enabled = false + case "malformed": + f.runner.malformed = true + } + before := projectGeminiInventory(t, f.request.Plan.TargetContext.ProjectRoot) + if _, err := f.activator().Activate(t.Context(), f.request); err == nil { + t.Fatal("nonexact listing established activation") + } + if _, err := f.activator().Deactivate(t.Context(), f.removal()); err == nil { + t.Fatal("nonexact listing admitted active removal") + } + f.request.ActivationComplete = true + for _, prepared := range []bool{false, true} { + if prepared { + f.request.Plan.Components[0].Support = domain.SupportPrepared + } + if _, err := f.activator().Activate(t.Context(), f.request); err == nil || !strings.Contains(err.Error(), "activation attestation") { + t.Fatalf("attestation bypassed project listing: %v", err) + } + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, f.request.Plan.TargetContext.ProjectRoot)) { + t.Fatal("read-only listing refusal changed native files") + } + }) + } +} + +func TestProjectClaudeChangedContextRefusesBeforeNativeListing(t *testing.T) { + for _, change := range []string{"scope", "original-user", "registry", "copied-project"} { + t.Run(change, func(t *testing.T) { + f := newClaudeProjectFixture(t) + switch change { + case "scope": + f.request.Plan.TargetContext.Scope = domain.ScopeUser + case "original-user": + f.request.Plan.TargetContext.UserConfigRoot = f.other + case "registry": + f.request.Plan.NativeRegistryRoot = f.user + case "copied-project": + f.request.Plan.TargetContext.ProjectRoot = f.other + f.request.Plan.NativeRegistryRoot = filepath.Join(f.other, ".claude") + } + if _, err := f.activator().Activate(t.Context(), f.request); err == nil { + t.Fatal("changed project context admitted") + } + if f.runner.calls != 0 { + t.Fatal("changed authority reached native runner") + } + }) + } +} diff --git a/repotests/agentplugins_claude_project_native_e2e_test.go b/repotests/agentplugins_claude_project_native_e2e_test.go new file mode 100644 index 000000000..d8b5ab61a --- /dev/null +++ b/repotests/agentplugins_claude_project_native_e2e_test.go @@ -0,0 +1,41 @@ +package pluginkitairepo_test + +import ( + "os" + "path/filepath" + "testing" +) + +// The source-only project gate executes genuine pinned Claude read-only native +// listing/details after actual project UAP operations. Its opt-in and existing +// disposable-host guard run before any client process or package acquisition. +func TestAgentpluginsClaudeNativeProjectPluginLifecycle(t *testing.T) { + if os.Getenv("AGENTPLUGINS_CLAUDE_PROJECT_NATIVE_E2E") != "1" { + t.Skip("opt-in source project native client execution") + } + nativeRequireDisposable(t) + f := newClaudeProjectNativeFixture(t) + stages := commandsStages() + stages["native_mcp_initialization"] = nativeStage{Status: "not_evaluated", Reason: "skills-only fixture; no MCP process or model session"} + stages["project_scope_preservation"] = nativeStage{Status: "not_evaluated", Reason: "not reached"} + evidence := claudeProjectNativeIdentity(t, f) + defer f.writeEvidence(t, evidence, stages) + commandsNativeLifecycle(t, commandsNativeClient{ + target: "claude", pkg: f.base.PackageRoot, statePath: filepath.Join(f.project, ".uap", "state-v2.json"), + run: f.run, skills: f.skills, + dropManaged: func(t *testing.T, _ map[string]any) string { + t.Helper() + path := f.listedPath(t, f.project, "project-list-before-absent-repair") + if path == "" { + t.Fatal("native project plugin absent before missing-directory repair") + } + nativeRequireContained(t, f.project, path) + if err := os.RemoveAll(path); err != nil { + t.Fatal(err) + } + return path + }, + }, stages) + f.requireForeign(t) + stages["project_scope_preservation"] = nativeStage{Status: "passed", Reason: "fresh project-B native listings omit project-A plugin; foreign user/cache/project A/project B bytes are exact"} +} diff --git a/repotests/agentplugins_claude_project_native_fixture_test.go b/repotests/agentplugins_claude_project_native_fixture_test.go new file mode 100644 index 000000000..c1ccffd2c --- /dev/null +++ b/repotests/agentplugins_claude_project_native_fixture_test.go @@ -0,0 +1,147 @@ +package pluginkitairepo_test + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +type claudeProjectNativeFixture struct { + base *claudeNativeFixture + client, installer, project, otherProject string + foreign map[string]string +} + +func newClaudeProjectNativeFixture(t *testing.T) *claudeProjectNativeFixture { + t.Helper() + client := claudeNativeBinary(t, "AGENTPLUGINS_CLAUDE_BIN") + installer := claudeNativeBinary(t, "AGENTPLUGINS_INSTALLER_BIN") + if want := os.Getenv("AGENTPLUGINS_CLAUDE_SHA256"); len(want) != 64 || claudeSHA256(t, client) != want { + t.Fatal("Claude binary pin mismatch") + } + f := &claudeProjectNativeFixture{base: newClaudeNativeFixture(t), client: client, installer: installer, foreign: map[string]string{}} + f.project, f.otherProject = filepath.Join(f.base.Root, "project-A"), filepath.Join(f.base.Root, "project-B") + for _, root := range []string{f.base.ConfigDir, filepath.Join(f.project, ".claude"), filepath.Join(f.otherProject, ".claude")} { + f.keep(t, filepath.Join(root, "settings.json"), `{"enabledPlugins":{"foreign-project-fixture@skills-dir":false}}`) + f.keep(t, filepath.Join(root, "skills", "foreign-project-fixture", "SKILL.md"), "---\nname: foreign-project-fixture\ndescription: Preserve foreign project fixture\n---\nInert foreign bytes.\n") + } + f.keep(t, filepath.Join(f.base.ConfigDir, "plugins", "cache", "foreign", "keep.txt"), "foreign user plugin cache\n") + return f +} + +func (f *claudeProjectNativeFixture) keep(t *testing.T, path, body string) { + t.Helper() + nativeWrite(t, path, []byte(body), 0600) + f.foreign[path] = nativeSHA(t, path) +} + +func (f *claudeProjectNativeFixture) requireForeign(t *testing.T) { + t.Helper() + for path, want := range f.foreign { + if nativeSHA(t, path) != want { + t.Fatalf("foreign user/project configuration changed: %s", path) + } + } +} + +func (f *claudeProjectNativeFixture) command(t *testing.T, project, label string, args ...string) []byte { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), claudeNativeCommandTimeout) + defer cancel() + command := exec.CommandContext(ctx, f.client, args...) + command.Dir, command.Env = project, f.base.env(filepath.Dir(f.client)) + body, err := command.CombinedOutput() + f.base.record(t, label, body) + if err != nil { + t.Fatalf("Claude project read-only command %q: %v\n%s", args, err, body) + } + f.requireForeign(t) + return body +} + +func (f *claudeProjectNativeFixture) listedPath(t *testing.T, project, label string) string { + t.Helper() + body := f.command(t, project, label, "plugin", "list", "--json") + var entries []claudePluginListEntry + if err := json.Unmarshal(body, &entries); err != nil { + t.Fatal(err) + } + path := "" + for _, entry := range entries { + if entry.ID != claudeCommandsPluginID { + continue + } + if path != "" || entry.Scope != "project" || !entry.Enabled || entry.InstallPath != filepath.Clean(entry.InstallPath) || filepath.Dir(entry.InstallPath) != filepath.Join(project, ".claude", "skills") { + t.Fatalf("nonexact project listing=%+v", entry) + } + nativeRequireContained(t, project, entry.InstallPath) + path = entry.InstallPath + } + return path +} + +func (f *claudeProjectNativeFixture) skills(t *testing.T, label string) map[string]string { + t.Helper() + path := f.listedPath(t, f.project, label+"-project-A-list") + if other := f.listedPath(t, f.otherProject, label+"-project-B-list"); other != "" { + t.Fatalf("selected project plugin leaked into project B: %s", other) + } + if path == "" { + return nil + } + details := f.command(t, f.project, label+"-project-A-details", "plugin", "details", claudeCommandsPluginID) + found, err := claudeDiscoveredSkills(string(details), path) + if err != nil { + t.Fatal(err) + } + return found +} + +func (f *claudeProjectNativeFixture) run(t *testing.T, label string, args ...string) (map[string]any, error) { + t.Helper() + if len(args) > 0 && args[0] == "add" { + args = append(args, "--no-save") + } + args = append(args, "--scope", "project", "--project-root", f.project) + result, err := commandsRunInstaller(t, f.base.Root, f.project, f.base.env(filepath.Dir(f.client)), f.installer, label, args...) + f.requireForeign(t) + return result, err +} + +func (f *claudeProjectNativeFixture) writeEvidence(t *testing.T, evidence map[string]any, stages map[string]nativeStage) { + t.Helper() + hashes := map[string]string{} + entries, err := os.ReadDir(f.base.Root) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if !entry.IsDir() && (strings.HasSuffix(entry.Name(), ".json") || strings.HasSuffix(entry.Name(), ".jsonl") || strings.HasSuffix(entry.Name(), ".log")) { + hashes[entry.Name()] = nativeSHA(t, filepath.Join(f.base.Root, entry.Name())) + } + } + evidence["stages"], evidence["artifact_sha256"], evidence["finished_utc"] = stages, hashes, time.Now().UTC().Format(time.RFC3339Nano) + nativeJSON(t, filepath.Join(f.base.Root, "claude-project-plugin-evidence.json"), evidence) + if err := nativeStageFailures(stages); err != nil { + t.Error(err) + } +} + +func claudeProjectNativeIdentity(t *testing.T, f *claudeProjectNativeFixture) map[string]any { + t.Helper() + identity, err := nativeSourceIdentity(os.Getenv("AGENTPLUGINS_INSTALLER_COMMIT"), os.Getenv("AGENTPLUGINS_INSTALLER_TREE"), os.Getenv("AGENTPLUGINS_INSTALLER_PATCH_SHA256")) + if err != nil { + t.Fatal(err) + } + version := claudeVersionString(t, f.base, f.client) + if want := os.Getenv("AGENTPLUGINS_CLAUDE_VERSION"); want == "" || strings.TrimSpace(version) != want { + t.Fatalf("wrong native Claude version=%q,want=%q", version, want) + } + return map[string]any{"started_utc": time.Now().UTC().Format(time.RFC3339Nano), "os": runtime.GOOS, "arch": runtime.GOARCH, "source_identity": identity, "client": "claude", "client_version_measured": version, "client_sha256": nativeSHA(t, f.client), "installer_sha256": nativeSHA(t, f.installer), "native_surface": "fresh exact project plugin listing and details; no session/model/trust/MCP initialization", "scanner": nativeProvisionScanner(t, &nativeFixture{Root: f.base.Root})} +} diff --git a/scripts/run-native-client-matrix.py b/scripts/run-native-client-matrix.py index 6947dc0d1..928bd658f 100644 --- a/scripts/run-native-client-matrix.py +++ b/scripts/run-native-client-matrix.py @@ -72,6 +72,19 @@ "claude": {"TestAgentpluginsClaudeNativeLifecycle", "TestAgentpluginsClaudeNativeRuntimeLifecycle", "TestAgentpluginsClaudeNativeCommandsLifecycle"}, "opencode": {"TestAgentpluginsOpenCodeNativeLifecycle", "TestAgentpluginsOpenCodeNativeToolCollision", "TestAgentpluginsOpenCodeNativeRuntimeExtended", "TestAgentpluginsOpenCodeNativeCommandsLifecycle"}, } +PROJECT_TESTS = { + "claude": {"TestAgentpluginsClaudeNativeProjectPluginLifecycle"}, +} + + +def select_native_tests(client, project_components=False, released=False): + """Keep current-source project proof separate from released user evidence.""" + if not project_components: + return PATTERNS[client], REQUIRED_TESTS[client] + if released or client not in PROJECT_TESTS: + raise ValueError("project proof requires a qualified current-source client, not a released/draft installer") + required = REQUIRED_TESTS[client] | PROJECT_TESTS[client] + return "^(?:" + "|".join(re.escape(name) for name in sorted(required)) + ")$", required def require_verified_pin(pin): @@ -321,6 +334,7 @@ def main(): parser.add_argument("--prepare-only", action="store_true") parser.add_argument("--prepared-input", type=Path) parser.add_argument("--target-scope", choices=('historical-nine', 'linux-amd64'), default='historical-nine') + parser.add_argument("--project-components", action="store_true", help="also require current-source project component discovery; never released/draft proof") args = parser.parse_args() draft.validate(args) if args.release_state == "draft" and args.producer_source is None: @@ -329,6 +343,11 @@ def main(): parser.error("public mode must not receive --producer-source") if bool(args.release_tag) != bool(args.release_commit): parser.error("--release-tag and --release-commit must be supplied together") + try: + pattern, required_tests = select_native_tests(args.client, args.project_components, + bool(args.release_tag) or args.release_state == "draft" or bool(args.prepared_input) or args.prepare_only) + except ValueError as error: + parser.error(str(error)) require_hosted(args.target) source = Path(__file__).resolve().parents[1] if args.prepare_only: @@ -430,11 +449,15 @@ def main(): raise RuntimeError(f"released installer version mismatch: {measured!r}") prefix = "AGENTPLUGINS_" + args.client.upper() env[prefix + "_NATIVE_E2E"] = "1" + if args.project_components: + env[prefix + "_PROJECT_NATIVE_E2E"] = "1" env[prefix + "_BIN"] = str(client) env[prefix + "_SHA256"] = client_evidence["binary_sha256"] env[prefix + "_VERSION"] = {"codex": "0.153.4", "claude": "2.1.263 (Claude Code)", "opencode": "1.18.34"}[args.client] - command = [str(tests), "-test.v", "-test.timeout=15m", "-test.run=" + PATTERNS[args.client]] + command = [str(tests), "-test.v", "-test.timeout=15m", "-test.run=" + pattern] identity["test_command"] = command + identity["required_tests"] = sorted(required_tests) + identity["project_components"] = args.project_components with (output / "native-tests.log").open("w", encoding="utf-8") as transcript: result = subprocess.run(command, cwd=project, env=env, stdout=transcript, stderr=subprocess.STDOUT, timeout=930) identity["exit_code"] = result.returncode @@ -444,7 +467,7 @@ def main(): skipped = re.findall(r"^\s*--- SKIP: ([^\s]+)", log, re.M) identity["passed_tests"] = sorted(passed) identity["skipped_tests"] = skipped - if result.returncode or skipped or not REQUIRED_TESTS[args.client].issubset(passed): + if result.returncode or skipped or not required_tests.issubset(passed): raise RuntimeError("native suite failed, skipped, or omitted a required test; inspect transcript") identity["status"] = "passed" finally: diff --git a/scripts/test_native_client_matrix.py b/scripts/test_native_client_matrix.py index 04dfb42a6..4697be45c 100644 --- a/scripts/test_native_client_matrix.py +++ b/scripts/test_native_client_matrix.py @@ -142,6 +142,25 @@ def test_runtime_rejects_ambient_and_self_hosted_machine(self): with self.assertRaises(RuntimeError): matrix.require_hosted("darwin-arm64") + def test_source_project_proof_selects_exact_required_tests(self): + pattern, required = matrix.select_native_tests("claude", project_components=True) + declared = set() + for path in (Path(__file__).resolve().parents[1] / "repotests").glob("*_test.go"): + declared.update(re.findall(r"^func (Test\w+)\(t \*testing\.T\)", path.read_text(encoding="utf-8"), re.M)) + self.assertEqual(required, matrix.REQUIRED_TESTS["claude"] | matrix.PROJECT_TESTS["claude"]) + self.assertEqual({name for name in declared if re.search(pattern, name)}, required) + self.assertNotIn("TestAgentpluginsClaudeNativeProjectPluginLifecycle", released.CLIENT_TESTS["claude"]) + + def test_source_project_proof_rejects_released_and_unsupported_clients(self): + for client in matrix.PROJECT_TESTS: + with self.assertRaises(ValueError): + matrix.select_native_tests(client, project_components=True, released=True) + for client in set(matrix.REQUIRED_TESTS) - set(matrix.PROJECT_TESTS): + with self.assertRaises(ValueError): + matrix.select_native_tests(client, project_components=True) + for client in matrix.REQUIRED_TESTS: + self.assertEqual(matrix.select_native_tests(client), (matrix.PATTERNS[client], matrix.REQUIRED_TESTS[client])) + def test_target_must_be_native(self): env = {"GITHUB_ACTIONS": "true", "RUNNER_ENVIRONMENT": "github-hosted", "AGENTPLUGINS_NATIVE_DISPOSABLE_HOSTED": "1"} with patch.dict(matrix.os.environ, env, clear=True), patch.object(matrix.platform, "system", return_value="Darwin"), patch.object(matrix.platform, "machine", return_value="arm64"): From 2c767ea0a2eb3e3e2640afcff0fbab5cfa31973b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D0=B8=D1=8F?= Date: Fri, 23 Oct 2026 23:30:27 +0300 Subject: [PATCH 2/2] test(claude): preserve project failure and capture bounded native list diagnostics after two reviews --- ...laude-failure-diagnostic-code-review-r1.md | 49 +++++++++ ...laude-failure-diagnostic-code-review-r2.md | 13 +++ ...laude-failure-diagnostic-plan-review-r1.md | 39 +++++++ ...laude-failure-diagnostic-plan-review-r2.md | 13 +++ .../claude-failure-listing-diagnostic-plan.md | 72 +++++++++++++ .../claude-implementation-evidence.md | 28 +++++ ...claude_project_diagnostic_contract_test.go | 61 +++++++++++ ...tplugins_claude_project_diagnostic_test.go | 101 ++++++++++++++++++ ...gins_claude_project_native_fixture_test.go | 9 ++ 9 files changed, 385 insertions(+) create mode 100644 docs/plans/project-install-e2e/claude-failure-diagnostic-code-review-r1.md create mode 100644 docs/plans/project-install-e2e/claude-failure-diagnostic-code-review-r2.md create mode 100644 docs/plans/project-install-e2e/claude-failure-diagnostic-plan-review-r1.md create mode 100644 docs/plans/project-install-e2e/claude-failure-diagnostic-plan-review-r2.md create mode 100644 docs/plans/project-install-e2e/claude-failure-listing-diagnostic-plan.md create mode 100644 repotests/agentplugins_claude_project_diagnostic_contract_test.go create mode 100644 repotests/agentplugins_claude_project_diagnostic_test.go diff --git a/docs/plans/project-install-e2e/claude-failure-diagnostic-code-review-r1.md b/docs/plans/project-install-e2e/claude-failure-diagnostic-code-review-r1.md new file mode 100644 index 000000000..05e9043c2 --- /dev/null +++ b/docs/plans/project-install-e2e/claude-failure-diagnostic-code-review-r1.md @@ -0,0 +1,49 @@ +# Claude bounded failure-listing diagnostics — independent code R1 + +Reviewed frozen clean `0b85d4a293beee1be05733cfc75dd4fd9afd260a` in +`work/uap-project-claude`, after the plan and both GO reports were committed at +`c53e030e`. **GO** for the diagnostic-only checkpoint. Reviewer did not author +the implementation and executed no Claude/native process or acquisition. + +The production parser, Claude adapter, opt-in and real disposable-host guard +are unchanged. The four-file diff adds test-only capture/metadata/contracts, +first-error fixture wiring and truthful evidence. Successful installer runs +take the original path. The fixture's latch permits only the first returned +installer error to trigger the fixed two A/B listing observations. It uses the +already verified genuine client and fresh explicit original profile environment, +then retains the original result/error and existing foreign-byte check. The +lifecycle marks a stage failed before running its installer, and the original +installer stdout/stderr are already recorded before this additional capture; +diagnostics do not mark it passed or retry it. + +Each capture has its own ten-second context, empty stdin/no shell, separate +stdout/stderr writers capped live at 1MiB, cancellation on overflow and 250ms +WaitDelay for inherited pipes. Writer state is mutex-protected, never appends +beyond its cap, and returns an independent snapshot. Direct-child kill/pipe +bounds are described accurately without a descendant-sandbox claim. Exit -1 +for failure to start, real process exit when available, context deadline, +per-stream overflow, elapsed time and bounded error remain explicit. + +The finite helper copies the fixture environment per child, uses only +`plugin list --json` from exact A/B cwd, and preserves separate capped raw logs +and byte/SHA256 metadata. No unknown listing is parsed into ownership, active +status or a success result; every diagnostic record and aggregate qualification +remain false. Fixed artifact paths are inside the existing evidence root, whose +ordinary writer hashes the files for upload. No mutation, guessed plugin ID, +trust/account/model/MCP action, new acquisition or format relaxation appears. + +Independent inert verification: +`go test -p=2 ./repotests -run '^TestClaudeProjectDiagnostic' -count=1` passed +both contracts in **0.009s**, without opting in to a native test. They cover +exact boundary/overflow cancellation, bounded later writes and snapshot copies, +fixed two-call selection, independent env/streams, failure metadata/hashes and +bounded error text. `git diff --check` passed. Guard and production adapter bytes +are unchanged across the reviewed diff. The author's exact lint pass remains +separate recorded evidence. + +No blocking implementation issue found. Root may publish/replay this exact +diagnostic checkpoint only after the other independent implementation GO. +Actual raw hosted output and both reviewers' interpretation are still required +before a separate evidence-based production decoder amendment. This GO does not +qualify the failed project lane, native discovery/runtime, trust, another OS, +all-client E2E or a release. diff --git a/docs/plans/project-install-e2e/claude-failure-diagnostic-code-review-r2.md b/docs/plans/project-install-e2e/claude-failure-diagnostic-code-review-r2.md new file mode 100644 index 000000000..20a269b5e --- /dev/null +++ b/docs/plans/project-install-e2e/claude-failure-diagnostic-code-review-r2.md @@ -0,0 +1,13 @@ +# Claude first project failure diagnostics — independent code R2 + +**GO** at frozen clean `0b85d4a293beee1be05733cfc75dd4fd9afd260a` in `work/uap-project-claude`, reviewed by cursor_clients on 2026-10-23. The diff from `c53e030e` is four files: the test-only capture primitive, two inert contracts, the fixture's first-error hook, and the truthful evidence note. Both independent plan GO reports precede source. No production parser, adapter, native success assertion, acquisition, opt-in or hosted guard changes. + +The hook runs only when the existing installer runner returns an error and diagnosticAttempted was false. It sets the flag before capture, then uses the same verified client path, exact existing A/B roots and original explicit fixture environment. Its fixed loop produces exactly two fresh `plugin list --json` calls. Successful installer paths launch neither. The original result/error are returned unchanged after diagnostics and the existing foreign-byte check; the installer stdout/stderr had already been recorded by commandsRunInstaller. No retry, details with a guessed ID, native mutation, trust, reload, model or MCP operation is introduced. + +Each real capture creates an independent10second context and a direct exec.CommandContext with no shell, empty stdin, copied environment and WaitDelay250ms. Separate mutex-protected writers append at most1MiB each while pipe copying runs. A write beyond the remaining limit stores only the bounded prefix, marks overflow, cancels the child and returns a short-write error. Exact-boundary writes are permitted and the next nonempty write triggers cancellation. Repeated writes after overflow cannot grow the buffer. Snapshot clones cannot modify captured data. The direct-child deadline plus WaitDelay bound inherited pipe closure; this is not OS/process-group isolation, and the evidence note correctly avoids such a claim. + +Capture records process exit or -1, bounded512byte error, independent deadline and per-stream limit flags, elapsed duration, separate raw log filenames/lengths/SHA256 and exact argv/cwd. Unknown or malformed JSON is retained raw rather than parsed into installed/active evidence. Each record has qualification:false; the wrapper metadata keeps project_qualified:false and identifies the original installer label/error. New logs/metadata are inside the ordinary evidence root, so existing writeEvidence hashes/uploads them. Supplementary capture failure does not convert the original failed operation into success. Existing fatal IO/foreign-byte checks continue failing the test if evidence cannot be safely recorded or foreign state drifts. + +Independent verification: both inert diagnostic tests passed in0.012s. They exercise exact-cap acceptance, overflow cancellation and bounded subsequent writes, cloned snapshots, fixed two-call argv/cwd/environment selection, copied environment, raw separate streams, exit/deadline/limit/hash metadata and bounded errors. git diff --check passed. Static comparison confirms the actual native opt-in and disposable-host guard remain before fixture creation, and all existing production parser/qualifier paths are untouched. No Claude binary, installer, MCP, account or model process was run by this review. + +No blocking finding remains. Root can publish/replay this diagnostic-only checkpoint under the existing native guards. The actual failing project artifact remains failed/pending; a successful diagnostic artifact must be independently interpreted before any separately planned parser/scope correction. This GO does not establish project discovery/runtime, trust, other-platform, all-client or release qualification. diff --git a/docs/plans/project-install-e2e/claude-failure-diagnostic-plan-review-r1.md b/docs/plans/project-install-e2e/claude-failure-diagnostic-plan-review-r1.md new file mode 100644 index 000000000..bf1496811 --- /dev/null +++ b/docs/plans/project-install-e2e/claude-failure-diagnostic-plan-review-r1.md @@ -0,0 +1,39 @@ +# Claude failure-listing diagnostic plan — independent R1 + +Reviewed documentation-only frozen `423d912ff56c033cbae455786b6176a64394cfac` +in `work/uap-project-claude` on 2026-10-23. **GO** for this test-only diagnostic +supplement, after both independent plan GO reports are committed. No Claude +process, acquisition or native operation was executed during review. + +Independently traced the existing project fixture. Its installer run can return +an error before the later skills/listedPath probes, so the existing failure +artifact can lack the raw native project listing needed to assess a decoder +refusal. The unchanged real disposable-host guard precedes fixture creation. +The fixture already owns the verified client path, original explicit profile +environment and existing A/B project directories, and its evidence writer hashes +captured JSON/log files. The plan uses those existing authorities rather than +guessing an install ID, root, profile or native shape. + +The proposed first-error-only capture is at most two fixed read-only +`plugin list --json` calls, A then B, with the same genuine verified binary and +fresh profile. Successful installer paths incur no diagnostic calls. No retry, +mutation, guessed plugin details, trust/account/model/MCP action, parser change, +new acquisition or guard alteration is introduced. The original installer error +and failed lifecycle stage remain failed; diagnostic failure is recorded +separately. The foreign-byte check still runs after collection. + +The stream design is appropriately bounded while running: distinct 1MiB writers +for stdout/stderr, cancellation on overflow, independent ten-second contexts, +closed stdin, no shell and at-most-250ms WaitDelay for inherited pipes. Preserve +the available capped raw bytes even for errors, malformed output or overflow, +plus bounded exact argv/cwd, exit/error/deadline/limit/elapsed and hash/count +metadata. This does not promise OS isolation or general descendant sandboxing. +Inert tests must exercise exact caps, cancellation, separate-stream accounting, +first-failure call selection and bounded metadata without executing Claude or +spoofing hosted admission. + +No blocking plan issue found. Both implementation reviews still precede root's +exact hosted replay, and both reviewers must interpret the resulting genuine +listing before any production decoder/scope amendment. Neither collected JSON +nor passing existing user suites may relabel the failed project lane, runtime, +trust, another platform, all-client E2E or release qualification. diff --git a/docs/plans/project-install-e2e/claude-failure-diagnostic-plan-review-r2.md b/docs/plans/project-install-e2e/claude-failure-diagnostic-plan-review-r2.md new file mode 100644 index 000000000..607bf374f --- /dev/null +++ b/docs/plans/project-install-e2e/claude-failure-diagnostic-plan-review-r2.md @@ -0,0 +1,13 @@ +# Claude first project failure diagnostics — independent plan R2 + +**GO** for frozen docs-only `423d912ff56c033cbae455786b6176a64394cfac` in `work/uap-project-claude`, reviewed by cursor_clients on 2026-10-23. No source changed and no Claude/native process was executed. This approves only the bounded failure-observability supplement; both plan GO reports precede implementation, then both code reviews must precede root's replay. + +Independently inventoried the retained genuine artifact ZIP. The failing project prefix `uap-claude-native-evidence-58929984/` contains version, add installer stdout/stderr and project evidence JSON, with no raw A/B native list logs. Traced the current fixture: `run` returns the installer result only after its foreign-byte check, while ordinary `skills`/`listedPath` observations happen later and cannot be reached after add fails. The plan addresses missing evidence rather than assuming a populated schema from an unrecognized-list error. + +The proposed hook is narrow: only the first installer error, at most two fixed `plugin list --json` calls from exact already-existing A/B cwd, same verified client and fresh explicit original CLAUDE_CONFIG_DIR environment. Successful installer paths do nothing extra. There is no add retry, guessed plugin ID/details call, mutation, reload, new acquisition, trust/account/model/MCP execution, parser change or success relaxation. Both existing opt-in and the real disposable-host guard remain before fixture/acquisition/process creation. + +The independent10second context per command, fixed1MiB-per-stream capped writers, cancellation on overflow, DEVNULL/no shell and WaitDelay<=250ms are appropriate for this read-only diagnostic. The code review must confirm the cap is enforced while writing, exact-limit/overflow behavior is recorded without unbounded accumulation, and stdout/stderr are preserved separately even on error/timeout/unknown output. Metadata must retain fixed argv/cwd, bounded errors, elapsed/deadline/limit/exit and hashes/lengths. The primitive does not promise general descendant isolation, as the plan states. + +The original installer failure/result/stage remains the failure; diagnostic failure cannot suppress it or qualify discovery. The foreign-byte check remains after diagnostics, and existing writeEvidence includes the new logs/metadata hashes. Inert stream-boundary/cancellation/metadata/finite-selection tests plus existing native guard/safety checks are sufficient. No broad rebuild or local Claude execution is needed. + +The next actual hosted artifact must be independently interpreted before a separately reviewed evidence-based parser/scope correction. Existing user-suite success and diagnostic collection cannot establish project, trust, MCP runtime, other-platform, all-client or release qualification. No blocking plan finding remains. diff --git a/docs/plans/project-install-e2e/claude-failure-listing-diagnostic-plan.md b/docs/plans/project-install-e2e/claude-failure-listing-diagnostic-plan.md new file mode 100644 index 000000000..cd4c55369 --- /dev/null +++ b/docs/plans/project-install-e2e/claude-failure-listing-diagnostic-plan.md @@ -0,0 +1,72 @@ +# Claude project native failure — bounded diagnostic capture + +Pre-code supplement, 2026-10-23. The original twice-reviewed Claude project +adapter/native gate remains in force. This changes only missing failure +observability in the opt-in source native fixture, not its success criteria +or the production adapter/parser. Obtain two independent plan GO reviews before +source changes, then two independent implementation reviews before root replay. + +## Actual failure and missing fact + +Genuine disposable hosted macOS arm64 run40011624952, job120613172265, artifact +12528049546 executed Claude Code2.1.263 with measured binary SHA256 +`ef5d2909c8af49f31ab6d5487e90316777bc2fac170adfe8160716caa8aaf4f9`. +Artifact ZIP SHA256 is +`c45098c244379dd4e0b62f2dfb7e235b415959428e19cd235cf083652fb5caf7`. +User command/plugin/runtime suites passed; the project test actually failed +at add with `the Claude Code plugin list output is not recognized`. Its project +prefix `uap-claude-native-evidence-58929984/` contains installer failure/version +but no raw native project listing, because add aborts before the later probe. +No project list shape or correct decoder change can be inferred from this +error alone. Keep the current fail-closed parser and actual failed stage. + +## Narrow test-only correction + +In the existing Claude project native fixture, when its installer runner first +returns an error, collect at most two fresh read-only native commands before +returning the same original failure: `plugin list --json`, once from its exact +project A cwd and once from its already-existing project B cwd. Use the same +verified genuine client path and explicit fixture environment/original +CLAUDE_CONFIG_DIR as ordinary native probes. No installer retry, native mutation, +reload, plugin details with a guessed ID, trust/account/model/MCP invocation, +new acquisition or hosted guard change is allowed. Successful installer paths +run no additional diagnostic commands. Both existing opt-in and unchanged real +disposable-host guard remain before fixture/acquisition/process creation. + +Capture stdout and stderr separately with a fixed1MiB cap per stream while the +process runs, an independent10s context deadline per command, DEVNULL/empty +stdin, no shell and a short exec.WaitDelay (at most250ms) for inherited pipe +closure. Each capped writer cancels the child promptly on overflow and records +that overflow; do not use CombinedOutput or truncate unbounded output afterward. +Use fixed labels/argv and only known fixture roots. Record exact argv/cwd, +exit/error/deadline/limit/elapsed and byte/hash metadata with bounded error text; +preserve separate raw capped stdout/stderr logs under the existing evidence +root even on nonzero exit, timeout, malformed/unknown JSON or overflow. Do not +parse this diagnostic into installed/active evidence or suppress its error. +The existing context/WaitDelay primitive bounds this read-only diagnostic; +it does not claim OS isolation or general descendant/process sandboxing. + +Run the existing foreign-byte check after diagnostics and preserve the original +installer failure/stage as failed. Additional diagnostic failure is separately +recorded, never a reason to mark the original lifecycle successful. Existing +writeEvidence hashes the captured logs/metadata in the ordinary uploaded +artifact. No secrets/account state exist in this fresh explicit fixture. + +## Meaningful inert verification and next gate + +Add focused tests for the capped stream primitive's exact boundary, overflow +cancellation and preservation without unbounded accumulation, separate streams, +bounded metadata/errors and finite diagnostic argv/cwd selection. Tests may +inject inert capture functions or use an inert test process; they may not launch +Claude, spoof hosted admission or weaken native success assertions. Retain +existing native opt-in/guard safety tests, syntax/format/diff checks and bounded +relevant repotests. No broad test rebuild is required while task storage is +constrained. + +Commit the source checkpoint with both independent implementation GO reports; +root publishes/replays the exact hosted native suite. Independently inspect +that genuine artifact before proposing any production parser/scope change. +If a decoder correction is warranted, freeze a separate narrow evidence-based +plan and obtain both plan GO reviews before coding it. Neither successful +diagnostic collection nor existing user-suite success qualifies the failing +project lane, trust, MCP/runtime, another OS, all clients or release completion. diff --git a/docs/plans/project-install-e2e/claude-implementation-evidence.md b/docs/plans/project-install-e2e/claude-implementation-evidence.md index cd52a4324..5766ee424 100644 --- a/docs/plans/project-install-e2e/claude-implementation-evidence.md +++ b/docs/plans/project-install-e2e/claude-implementation-evidence.md @@ -158,3 +158,31 @@ suite (56 PASS), while R2 reran the selected native matrix contracts (17 PASS). These implementation reviews close the earlier pending adapter-review gate. Genuine hosted project observations remain pending and must be reported apart from fixture execution, authored gate code and historical user evidence. + +## Genuine project failure and diagnostic-only checkpoint + +The actual macOS arm64 Claude2.1.263 hosted run40011624952 failed project add +with unrecognized native plugin-list output; user suites passed independently. +Artifact12528049546 ZIP SHA256 is +`c45098c244379dd4e0b62f2dfb7e235b415959428e19cd235cf083652fb5caf7`. +The project prefix has installer failure/version records but no raw list, +because add failed before its later read-only probe. Project qualification is +therefore failed/pending, not implied by prior fixture or user-suite success. + +The supplemental diagnostic plan423d912f and both independent plan GO reports +were committed before source atc53e030e. This test-only change captures at most +two fixed A/B `plugin list --json` commands after the first installer error, +with the same verified binary and fresh explicit original profile. Each has +live1MiB-per-stream caps/cancellation,10s context deadline, empty stdin, no shell +and250ms WaitDelay; raw separate streams plus bounded exit/error/hash metadata +remain artifact evidence. The original installer error and failed stage remain +unchanged. Production parser, client success criteria, opt-in/hosted guard, +acquisition and successful lifecycle paths are unchanged. No diagnostic output +can become an installed/active result. + +Two inert diagnostic contracts PASS0.007s without a Claude process; exact +changed-code lint reports zero issues after routine naming/preallocation fixes. +Genuine hosted replay and independent raw-output interpretation are required +before proposing any parser correction. The direct-child timeout/pipe bounds +are not OS isolation or general descendant sandboxing, and no model/trust/MCP +initialization or project success is claimed. diff --git a/repotests/agentplugins_claude_project_diagnostic_contract_test.go b/repotests/agentplugins_claude_project_diagnostic_contract_test.go new file mode 100644 index 000000000..f967bf093 --- /dev/null +++ b/repotests/agentplugins_claude_project_diagnostic_contract_test.go @@ -0,0 +1,61 @@ +package pluginkitairepo_test + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" +) + +func TestClaudeProjectDiagnosticLiveCapPreservesExactBoundary(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + stream := &claudeDiagnosticStream{limit: 4, cancel: cancel} + if n, err := stream.Write([]byte("abcd")); n != 4 || err != nil || ctx.Err() != nil { + t.Fatalf("exact boundary=%d,%v", n, err) + } + if n, err := stream.Write([]byte("e")); n != 0 || err == nil || !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("overflow not canceled=%d,%v", n, err) + } + if n, err := stream.Write([]byte(strings.Repeat("x", 4096))); n != 0 || err == nil { + t.Fatal("later writes exceeded cap") + } + body, overflow := stream.snapshot() + if string(body) != "abcd" || !overflow { + t.Fatalf("captured=%q overflow=%t", body, overflow) + } + body[0] = 'z' + snapshot, _ := stream.snapshot() + if string(snapshot) != "abcd" { + t.Fatal("snapshot aliases capture") + } +} + +func TestClaudeProjectDiagnosticFiniteReadonlySelectionAndSeparateStreams(t *testing.T) { + calls := 0 + env := []string{"HOME=/fresh/home", "CLAUDE_CONFIG_DIR=/fresh/original"} + records, logs := claudeFailureListingRecords("/verified/claude", "/fresh/A", "/fresh/B", env, func(client, cwd string, childEnv, args []string) claudeDiagnosticResult { + calls++ + if client != "/verified/claude" || cwd != []string{"/fresh/A", "/fresh/B"}[calls-1] || !reflect.DeepEqual(childEnv, env) || !reflect.DeepEqual(args, []string{"plugin", "list", "--json"}) { + t.Fatal("diagnostic selection changed") + } + childEnv[0] = "inert changed copy" + return claudeDiagnosticResult{Stdout: []byte("unknown raw JSON"), Stderr: []byte("bounded failure"), Exit: 1, Error: "original child error", Deadline: true, StderrLimit: true, Elapsed: time.Millisecond} + }) + if calls != 2 || len(records) != 2 || len(logs) != 4 || env[0] != "HOME=/fresh/home" { + t.Fatal("diagnostic count/env/log boundary changed") + } + for _, r := range records { + if r["qualification"] != false || r["exit"] != 1 || r["deadline"] != true || r["stderr_limit"] != true || r["error"] != "original child error" || r["stdout_bytes"] != len("unknown raw JSON") || r["stderr_bytes"] != len("bounded failure") { + t.Fatalf("failure metadata fabricated success: %+v", r) + } + if len(r["stdout_sha256"].(string)) != 64 || string(logs[r["stdout_file"].(string)]) != "unknown raw JSON" || string(logs[r["stderr_file"].(string)]) != "bounded failure" { + t.Fatal("raw streams/hash metadata changed") + } + } + if len(boundedClaudeDiagnosticError(errors.New(strings.Repeat("x", 2048)))) != 512 { + t.Fatal("unbounded diagnostic error") + } +} diff --git a/repotests/agentplugins_claude_project_diagnostic_test.go b/repotests/agentplugins_claude_project_diagnostic_test.go new file mode 100644 index 000000000..7c1c23dd0 --- /dev/null +++ b/repotests/agentplugins_claude_project_diagnostic_test.go @@ -0,0 +1,101 @@ +package pluginkitairepo_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os/exec" + "sync" + "time" +) + +const claudeDiagnosticLimit = 1 << 20 + +// Writes are capped while exec copies each pipe; overflow cancels the direct +// read-only child. WaitDelay bounds inherited pipes, not a process sandbox. +type claudeDiagnosticStream struct { + mu sync.Mutex + body []byte + limit int + overflow bool + cancel context.CancelFunc +} + +func (s *claudeDiagnosticStream) Write(body []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + n := len(body) + if remaining := s.limit - len(s.body); n > remaining { + n = remaining + s.overflow = true + } + s.body = append(s.body, body[:n]...) + if s.overflow { + s.cancel() + return n, errors.New("Claude diagnostic output limit exceeded") + } + return n, nil +} + +func (s *claudeDiagnosticStream) snapshot() ([]byte, bool) { + s.mu.Lock() + defer s.mu.Unlock() + return append([]byte(nil), s.body...), s.overflow +} + +type claudeDiagnosticResult struct { + Stdout, Stderr []byte + Exit int + Error string + Deadline, StdoutLimit, StderrLimit bool + Elapsed time.Duration +} + +type claudeDiagnosticCapture func(string, string, []string, []string) claudeDiagnosticResult + +func captureClaudeDiagnostic(client, cwd string, env, args []string) claudeDiagnosticResult { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + stdout := &claudeDiagnosticStream{limit: claudeDiagnosticLimit, cancel: cancel} + stderr := &claudeDiagnosticStream{limit: claudeDiagnosticLimit, cancel: cancel} + command := exec.CommandContext(ctx, client, args...) + command.Dir, command.Env = cwd, append([]string(nil), env...) + command.Stdin = nil + command.Stdout, command.Stderr = stdout, stderr + command.WaitDelay = 250 * time.Millisecond + started := time.Now() + err := command.Run() + out := claudeDiagnosticResult{Exit: -1, Elapsed: time.Since(started), Deadline: errors.Is(ctx.Err(), context.DeadlineExceeded)} + out.Stdout, out.StdoutLimit = stdout.snapshot() + out.Stderr, out.StderrLimit = stderr.snapshot() + if command.ProcessState != nil { + out.Exit = command.ProcessState.ExitCode() + } + if err != nil { + out.Error = boundedClaudeDiagnosticError(err) + } + return out +} + +func boundedClaudeDiagnosticError(err error) string { + value := err.Error() + if len(value) > 512 { + return value[:512] + } + return value +} + +func claudeFailureListingRecords(client, project, other string, env []string, capture claudeDiagnosticCapture) ([]map[string]any, map[string][]byte) { + records := make([]map[string]any, 0, 2) + logs := map[string][]byte{} + for _, selected := range []struct{ label, cwd string }{{"failure-project-A-list", project}, {"failure-project-B-list", other}} { + args := []string{"plugin", "list", "--json"} + result := capture(client, selected.cwd, append([]string(nil), env...), args) + stdout, stderr := selected.label+".stdout.log", selected.label+".stderr.log" + logs[stdout], logs[stderr] = result.Stdout, result.Stderr + digest := func(body []byte) string { sum := sha256.Sum256(body); return hex.EncodeToString(sum[:]) } + records = append(records, map[string]any{"argv": append([]string{client}, args...), "cwd": selected.cwd, "exit": result.Exit, "error": result.Error, "deadline": result.Deadline, "stdout_limit": result.StdoutLimit, "stderr_limit": result.StderrLimit, "elapsed_ms": result.Elapsed.Milliseconds(), "stdout_file": stdout, "stderr_file": stderr, "stdout_bytes": len(result.Stdout), "stderr_bytes": len(result.Stderr), "stdout_sha256": digest(result.Stdout), "stderr_sha256": digest(result.Stderr), "qualification": false}) + } + return records, logs +} diff --git a/repotests/agentplugins_claude_project_native_fixture_test.go b/repotests/agentplugins_claude_project_native_fixture_test.go index c1ccffd2c..a7ace55f5 100644 --- a/repotests/agentplugins_claude_project_native_fixture_test.go +++ b/repotests/agentplugins_claude_project_native_fixture_test.go @@ -16,6 +16,7 @@ type claudeProjectNativeFixture struct { base *claudeNativeFixture client, installer, project, otherProject string foreign map[string]string + diagnosticAttempted bool } func newClaudeProjectNativeFixture(t *testing.T) *claudeProjectNativeFixture { @@ -110,6 +111,14 @@ func (f *claudeProjectNativeFixture) run(t *testing.T, label string, args ...str } args = append(args, "--scope", "project", "--project-root", f.project) result, err := commandsRunInstaller(t, f.base.Root, f.project, f.base.env(filepath.Dir(f.client)), f.installer, label, args...) + if err != nil && !f.diagnosticAttempted { + f.diagnosticAttempted = true + records, logs := claudeFailureListingRecords(f.client, f.project, f.otherProject, f.base.env(filepath.Dir(f.client)), captureClaudeDiagnostic) + for name, body := range logs { + nativeWrite(t, filepath.Join(f.base.Root, name), body, 0600) + } + nativeJSON(t, filepath.Join(f.base.Root, "failure-listing-diagnostics.json"), map[string]any{"installer_label": label, "original_installer_error": boundedClaudeDiagnosticError(err), "diagnostics": records, "original_failure_preserved": true, "project_qualified": false}) + } f.requireForeign(t) return result, err }