diff --git a/docs/plans/project-install-e2e/client-plans.md b/docs/plans/project-install-e2e/client-plans.md index 9ed2cd791..76e87fa89 100644 --- a/docs/plans/project-install-e2e/client-plans.md +++ b/docs/plans/project-install-e2e/client-plans.md @@ -260,6 +260,17 @@ malformed/commented settings, conflicting user server and trust gating. A settings-file receipt proves configuration, not initialized MCP/tools or authentication. No extension install/global profile redirection is needed. +**Implemented checkpoint (2026-10-23).** `65b8f17a` qualifies the native +project component lane with independent implementation +[R1](gemini-code-review-r1.md) and [R2](gemini-code-review-r2.md) GO. Project +configuration success remains prepared with installed exact objects. Native +session discovery, workspace trust, system policy and MCP initialization remain +unobserved; activation attestation cannot upgrade that status. Bounded +user/workspace restriction and alias warnings preserve policy and foreign +files. [Pinned evidence and verification](gemini-implementation-evidence.md) +separate actual version/help research, production composition contracts and +remaining hosted native runtime qualification. + ## 5. OpenCode — project config at repository root **Evidence/choice.** Native project MCP uses `/opencode.json` or @@ -590,7 +601,7 @@ is replaced by explicit unsupported. R2 fixes independently verified GO in | claude | GO | Verified | GO | Root assignment required | | codex | GO | Verified | GO | Root assignment required | | cursor | GO | Verified | GO | Root assignment required | -| gemini | GO | Verified | GO | Root assignment required | +| gemini | GO | Verified | GO | Implemented at65b8f17a; independent implementation R1/R2 GO; native discovery pending | | opencode | GO | Verified | GO | Root assignment required | | copilot | GO | Verified | GO | Root assignment required | | kiro | GO | Verified | GO | Root assignment required | diff --git a/docs/plans/project-install-e2e/gemini-code-review-r1.md b/docs/plans/project-install-e2e/gemini-code-review-r1.md new file mode 100644 index 000000000..a5b678547 --- /dev/null +++ b/docs/plans/project-install-e2e/gemini-code-review-r1.md @@ -0,0 +1,37 @@ +# Gemini project implementation independent review R1 + +Reviewer: project_core, 2026-10-23. Initial read-only checkpoint `d76f29ae..a96b4aff07dedfb99297d3fab564b1c778d6968b` in the Gemini child worktree. Review scope is the Gemini adapter and the owner-authorized precise CodecGemini comment parsing delta. Shared core was reviewed separately. + +## Decision + +**R1 GO at final frozen `65b8f17a951e80f06f9ed9c2677dd16a3ccd3d0a`.** Scoped root selection, conservative project readiness, exact native ownership and the precise Gemini comment codec changes have no confirmed blocker. Initial checkpoint observations below are retained as review history. Native Gemini session discovery and cohesive source/project CLI execution remain separate qualification/integration gates. + +## Initial checkpoint assessment + +**No confirmed blocker in the scoped native paths or codec delta. Final GO is pending the author's catalog qualification, conservative project readiness, disabled/shadowed settings, alias/preflight and composed stager tests.** The initial checkpoint alone is not yet project admission/product proof. + +- Project native settings/skills root is explicitly `/.gemini`. Detected `Client.ConfigRoot` remains the original user profile. Project/root mismatch and changed original user context are rejected before native operations. +- Native mutation, verification, removal and registry inspection select the same explicit root; removal is supplied the recorded actual native registry and TargetContext. Exact existing ownership codecs and native skill transactions are reused. +- The managed artifact layout stays under the selected project, with physical profile canonicalization and no-follow containment reused. User home variables are not replaced and host trust is not granted. +- `CodecGemini` settings.json accepts comments while preserving the existing AST and exact entry receipts. Duplicate keys, malformed input and trailing commas remain errors. Other strict JSON codecs stay strict. +- Independently inspected hujson's parser representation: a final child Value.AfterExtra is cleared for a closing delimiter without a trailing comma and remains present when a trailing comma exists; the new sentinel check therefore does distinguish this format boundary while retaining comments. +- Remaining direct parseDocument calls in transition*.go are guarded closed OpenCode dialect transitions. They do not admit CodecGemini, so no unrelated transition broadening is needed. + +## Final review checklist + +Review the final scoped policy/catalog and readiness delta, run the full Gemini and nativeconfig packages independently on the frozen tree, and verify user behavior plus scoped isolation/disabled/foreign-edit/collision/alias/attestation boundaries. A cohesive composed source/project lifecycle follows integration; adapter fixture proof must not be labeled native Gemini process certification. + +## Final frozen checkpoint verification + +Reviewed the exact final tree read-only, including the real core merge, Gemini catalog qualification and project policy, all moved composed contracts in the external providers test package, and the final `document.go` / `gemini_document.go` last-member deletion correction. + +- The catalog qualifies only Gemini project scope through its declared native-config lifecycle. It does not select a test constructor or alter the user lifecycle traits. +- Project filesystem success consistently remains ActivationPrepared plus VerificationInstalled, without activation attestation. Neither repeated automatic reconciliation, verify-only, a restart, ambient trust variables nor `--activation-complete` can claim native session discovery. The existing ActivationPreflighter rejects the shortcut before mutation and direct adapter reuse invokes the same guard. +- Preflight reads user/project settings through the exact comment-aware native kernel, preserves disabled skills and MCP restrictions, and reports possible `.agents/skills` shadowing. Workspace trust, system policy and MCP initialization remain explicitly unobserved; there is no new trust/settings engine. +- The composed loader/planner/renderer/stager/activator contract renders portable commands as Gemini skills and installs/removes the exact skill/command/MCP objects under the project root. Foreign entries/comments, user profile and project B remain unchanged. Malformed settings, object collisions, writer-lock failure, scope/root mismatch and changed original user context fail closed. +- The final codec fix removes only trailing commas created by deleting a last managed member, moves surviving foreign trivia to the enclosing delimiter, and revalidates the comment-only document. Native-invalid input trailing commas still fail; unrelated strict JSON codecs remain unchanged. The foreign-trivia regression exercises the problematic preceding member directly. +- Architecture was preserved by moving composed journey tests to `providers/project_gemini_*_test.go`, package providers_test; no upward-import suppression was added. + +Independent final execution (`-count=1 -p=2 -timeout=2m`): full Gemini **PASS 0.046s**, full nativeconfig **PASS 3.730s**, full domain **PASS 0.034s**, and providers `^TestProjectGemini` **PASS 0.061s**. The authored CI lint evidence records both exact changed-code and full-file size/architecture gates at zero issues. Shared core CI now discovers TestProject contracts in nativeconfig, providers and all client packages on Linux, macOS and Windows. + +No confirmed review finding remains open. These are inert fixture/contract checks, not hosted native Gemini product certification. diff --git a/docs/plans/project-install-e2e/gemini-code-review-r2.md b/docs/plans/project-install-e2e/gemini-code-review-r2.md new file mode 100644 index 000000000..1faa37583 --- /dev/null +++ b/docs/plans/project-install-e2e/gemini-code-review-r2.md @@ -0,0 +1,32 @@ +# Gemini project implementation independent review R2 + +Reviewer: sources_lead, 2026-10-23. Reviewed exact frozen commit `65b8f17a951e80f06f9ed9c2677dd16a3ccd3d0a` read-only, cumulatively against the reviewed project core. No Gemini worker files were changed. + +## Decision + +**R2 GO.** No confirmed implementation blocker remains in the Gemini project adapter, bounded settings-policy warnings or precise native-config comment parser. This qualifies the implemented file and lifecycle contracts; native Gemini session discovery, workspace trust, system policy and MCP initialization remain explicitly unobserved. Combined source/project CLI qualification follows integration. + +## Independent review findings + +- Project selection consistently uses the explicit TargetContext root for planning, registry inspection, native object preparation, activation, verification and removal. Original detected user configuration remains separate. Changed user authority, user registry substitution and managed-root escape fail closed through existing contextual physical-path checks. +- The domain catalog exposes the project native-config component lane without altering user lifecycle traits. The production loader/planner/command renderer/stager/activator composition is exercised by external provider tests; no test-only adapter composition is required. +- Project file success is consistently `ActivationPrepared` and `VerificationInstalled` with `ActivationAttested=false`. Repeated automatic reconciliation and verify-only do not promote readiness. The shared ActivationPreflighter runs before generic activation attestation, and direct adapter reuse also rejects that shortcut before mutation. An ambient trust flag is not accepted as discovery evidence. +- Preserved user/workspace restrictions and a same-name `.agents/skills` alias generate bounded warnings. These checks do not write trust or policy and do not claim to reconstruct Gemini's complete settings engine. System policy and MCP initialization remain explicitly unobserved. +- Native entry receipts and exact skill ownership remain the mutation authority. Project A add/repeat/verify/remove preserves user configuration, project B, foreign skills, foreign MCP entries, settings and comments. Foreign same-name collisions, malformed settings and writer-lock failure leave native state unchanged; removal is repeatable and exact. +- CodecGemini accepts the pinned native format's comments without admitting duplicate keys or input trailing commas. The deletion correction removes only renderer-created trailing commas and preserves surviving foreign trivia. Strict JSON codecs retain their prior parser. Existing OpenCode-only transitions are unaffected. +- Reviewed pinned v0.65.0 release evidence, configuration/help boundaries and the documented original user-root handling. The implementation makes no live-session, model, authentication or native trust claim from these contract tests. + +## Independent execution + +Executed against the exact clean frozen tree with `-count=1 -p=2`: + +| Scope | Result | +| --- | --- | +| Full clients/gemini package | PASS, 0.028s | +| Full adapters/nativeconfig package | PASS, 3.570s | +| Full domain package | PASS, 0.023s | +| Providers `^TestProjectGemini` composition contracts | PASS, 0.072s | + +The composed tests cover rendered command skills, MCP and skills, prepared status, attestation rejection, original user context, other-project isolation, restrictions/alias preservation, exact removal, malformed input, collision and rollback. No package payload, model request or MCP server was executed. The hosted native runtime guard remains unchanged. + +No review correction was requested in this rotation. diff --git a/docs/plans/project-install-e2e/gemini-implementation-evidence.md b/docs/plans/project-install-e2e/gemini-implementation-evidence.md new file mode 100644 index 000000000..6a2a38f48 --- /dev/null +++ b/docs/plans/project-install-e2e/gemini-implementation-evidence.md @@ -0,0 +1,88 @@ +# Gemini project component implementation evidence + +Date: 2026-10-23. Implemented frozen code: `65b8f17a`. The selected lane installs +native project skills and MCP configuration; it does not invent a project +extension registry. Independent implementation [R1](gemini-code-review-r1.md) +and [R2](gemini-code-review-r2.md) both grant GO for the bounded file and +lifecycle contracts. + +## Pre-code official artifact and help research + +Official [Gemini CLI v0.65.0 release bundle](https://github.com/google-gemini/gemini-cli/releases/download/v0.65.0/gemini-cli-bundle.zip): +21,215,960 bytes; SHA256 +`bb5e7e879608bd9b3fe261930d3886fa56aacbf471da1655f2978eb35b705a73`. +The artifact checksum was verified before implementation. Actual read-only +version, skills help and MCP help probes ran with no network, no user mounts +and fresh isolated home/project directories. The version reports `0.65.0`; +skills help exposes list, enable, disable, install and uninstall; MCP help +exposes add, remove, list, enable and disable. These observations establish the +pinned help/schema research boundary, not project session discovery. + +Primary documentation: +[configuration](https://geminicli.com/docs/reference/configuration/), +[skills](https://geminicli.com/docs/cli/skills/), and +[trusted folders](https://geminicli.com/docs/cli/trusted-folders/). + +Read-only inspection of the exact released JavaScript bundle establishes: + +- `chunk-AZOL6UFL.js`: project settings are `.gemini/settings.json`; trust gates + workspace merging between user and system settings (`mergeSettings`, around + line16338). The pinned schema defaults folder trust to true, resolving stale + text in the separate trusted-folders page. +- The same chunk defines `mcpServers` named-entry shallow merge around + line12455; `mcp.allowed`/`mcp.excluded` around line13987; skills enabled and + union-merged disabled names around line14692; admin MCP/skills enabled fields + around lines15098/15148. Admin sections use replace merging. +- Native settings parse JSON after stripping comments. Comments are accepted; + input trailing commas remain invalid. +- `chunk-DPTJ5QZL.js`: original user configuration and trust storage stay under + native user authority. Workspace `.agents/skills` is a higher-priority alias + at the workspace tier; neither HOME nor GEMINI_CLI_HOME is rebound to the + project by this implementation. + +Offsets identify this immutable bundle and are not claims about future native +versions. No package payload, MCP server or model request was executed during +these probes. + +## Implemented contract and limits + +Planning, inspection, activation, verification and removal use the actual +selected project `.gemini` root while retaining detected user ConfigRoot. +Managed artifacts remain separately under project `.uap`. Exact MCP receipts, +skill directory ownership, rollback and no-follow/canonical path checks are +reused. Comment-aware Gemini writes preserve foreign entries/trivia; duplicate +keys and trailing commas remain invalid, and other strict JSON codecs retain +their previous parser. + +Every configuration-only project success is `ActivationPrepared` plus +`VerificationInstalled`, with no activation attestation. Repeat automatic +reconciliation and read-only verification do not claim native discovery. The +existing preflight port rejects `--activation-complete` before the generic +shortcut, including direct adapter reuse. An ambient trust bypass flag is not +discovery evidence. Trust and policy files are never changed. + +Warnings inspect configured user/workspace restrictions and potential +same-name `.agents/skills` alias paths. They do not recreate Gemini's full +settings engine. Session discovery, workspace trust, system policy and MCP +initialization remain explicitly unobserved. The existing user-scope lifecycle +stays unchanged. + +## Verification + +Production loader, planner, portable command renderer, stager and activator +contracts deliver ordinary skills, command skills and MCP entries into project +A; original user and project B byte/mode inventories remain unchanged. Tests +cover repeated activation/verification, exact repeatable remove, preserved +restrictions/alias, malformed settings, foreign same-name collision, and an MCP +writer failure after skill installation with complete skill rollback. + +Author's exact Go1.26.10 checks: composed project contracts PASS0.063s; +native codec project contracts PASS0.008s; full Gemini/nativeconfig/domain +PASS0.028s/3.790s/0.040s. Exact golangci-lint2.13.2 changed-code and whole-file +size/complexity/architecture gates both report zero issues. Both independent +reviewers separately ran the frozen packages and composed tests successfully. + +These are inert transport-independent file/composition contracts. Real Gemini +project skills/MCP listing from A plus a fresh session, and the cohesive +source/project CLI scenario, remain separate hosted qualification/integration +gates. The existing hosted-only native runtime guard remains unchanged. diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/document.go b/install/integrationctl/agentplugins/adapters/nativeconfig/document.go index e62abb7cb..4f84a1f4f 100644 --- a/install/integrationctl/agentplugins/adapters/nativeconfig/document.go +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/document.go @@ -12,9 +12,10 @@ import ( ) type document struct { - ast hujson.Value - root *hujson.Object - jsonc bool + ast hujson.Value + root *hujson.Object + jsonc bool + commentsOnly bool } func parseDocument(body []byte, jsonc bool) (*document, error) { @@ -35,7 +36,9 @@ func parseDocument(body []byte, jsonc bool) (*document, error) { return &document{ast: ast, root: root, jsonc: jsonc}, nil } -func newDocument(jsonc bool) (*document, error) { return parseDocument([]byte("{}\n"), jsonc) } +func newDocument(jsonc bool, codec Codec) (*document, error) { + return parseCodecDocument([]byte("{}\n"), jsonc, codec) +} func rejectDuplicateKeys(value *hujson.Value, path string) error { switch node := value.Value.(type) { @@ -146,11 +149,20 @@ func removeEntry(obj *hujson.Object, name string) bool { } func (doc *document) render() ([]byte, error) { + if doc.commentsOnly { + geminiRemoveTrailingCommas(&doc.ast) + } body := doc.ast.Pack() if !doc.jsonc && !json.Valid(body) { return nil, fmt.Errorf("rendered native config is not strict JSON") } - if _, err := parseDocument(body, doc.jsonc); err != nil { + var err error + if doc.commentsOnly { + _, err = parseCodecDocument(body, false, CodecGemini) + } else { + _, err = parseDocument(body, doc.jsonc) + } + if err != nil { return nil, fmt.Errorf("validate rendered native config: %w", err) } return body, nil diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/gemini_comments_test.go b/install/integrationctl/agentplugins/adapters/nativeconfig/gemini_comments_test.go new file mode 100644 index 000000000..e028d7e02 --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/gemini_comments_test.go @@ -0,0 +1,78 @@ +package nativeconfig + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestProjectGeminiCommentedSettingsExactLifecycle(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + before := "{\n // retain policy comment\n \"skills\":{\"disabled\":[\"foreign\"]},\n \"mcpServers\":{ /* retain foreign entry */ \"foreign\":{\"url\":\"https://foreign.test/mcp\"}}\n}\n" + mustWrite(t, path, before) + kernel := New() + request := Request{Paths: Paths{JSON: path}, Codec: CodecGemini, Action: ActionAdd, Name: "managed", Server: Server{Type: "remote", URL: "https://managed.test/mcp", RemoteTransport: "streamable-http"}} + receipt, err := kernel.Apply(request) + if err != nil { + t.Fatal(err) + } + if present, owned, err := kernel.Inspect(request.Paths, request.Codec, request.Name, &receipt); err != nil || !present || !owned { + t.Fatalf("commented exact inspection = %v/%v, %v", present, owned, err) + } + request.Action, request.Owned = ActionUpdate, &receipt + request.Server.URL = "https://updated.test/mcp" + receipt, err = kernel.Apply(request) + if err != nil { + t.Fatal(err) + } + // A foreign writer may add trivia before the preceding member's comma. + // Removing our last entry must not create a native-invalid trailing comma. + updated, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + foreign := `"foreign":{"url":"https://foreign.test/mcp"}` + mustWrite(t, path, strings.Replace(string(updated), foreign, foreign+" /* keep new foreign trivia */", 1)) + request.Action, request.Owned = ActionRemove, &receipt + if _, err := kernel.Apply(request); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, preserved := range []string{"// retain policy comment", "/* retain foreign entry */", "/* keep new foreign trivia */", `"disabled":["foreign"]`, `"foreign":{"url":"https://foreign.test/mcp"}`} { + if !strings.Contains(string(body), preserved) { + t.Fatalf("lost %q in %s", preserved, body) + } + } + if present, _, err := kernel.Inspect(request.Paths, request.Codec, request.Name, nil); err != nil || present { + t.Fatalf("removed project entry still present = %v, %v", present, err) + } +} + +func TestProjectGeminiCommentsDoNotBroadenInvalidJSON(t *testing.T) { + for name, body := range map[string]string{ + "root comma": `{"mcpServers":{},}`, + "nested comma": `{"mcpServers":{"foreign":{"args":["x",]}}}`, + "duplicate": `{"mcpServers":{},"mcpServers":{}}`, + "malformed comment": "{/* incomplete", + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + mustWrite(t, path, body) + request := Request{Paths: Paths{JSON: path}, Codec: CodecGemini, Action: ActionAdd, Name: "managed", Server: Server{Type: "stdio", Command: "TEST-never-executed"}} + if _, err := New().Apply(request); !errors.Is(err, ErrMalformed) { + t.Fatalf("invalid settings accepted: %v", err) + } + assertBytes(t, path, body) + }) + } + path := filepath.Join(t.TempDir(), "settings.json") + mustWrite(t, path, "{// comment\n\"mcpServers\":{}}") + if _, _, err := New().Inspect(Paths{JSON: path}, CodecMCPServers, "managed", nil); !errors.Is(err, ErrMalformed) { + t.Fatalf("generic JSON codec was broadened: %v", err) + } +} diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/gemini_document.go b/install/integrationctl/agentplugins/adapters/nativeconfig/gemini_document.go new file mode 100644 index 000000000..51c3da548 --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/gemini_document.go @@ -0,0 +1,60 @@ +package nativeconfig + +import ( + "fmt" + + "github.com/tailscale/hujson" +) + +// Gemini v0.65.0 parses JSON after stripping comments. Its settings.json is +// comment-aware, but trailing commas remain invalid. Keep the original AST so +// managed entry mutations preserve comments and unrelated configuration. +func parseCodecDocument(body []byte, jsonc bool, codec Codec) (*document, error) { + if codec != CodecGemini || jsonc { + return parseDocument(body, jsonc) + } + doc, err := parseDocument(body, true) + if err != nil { + return nil, err + } + for value := range doc.ast.All() { + if !geminiWithoutTrailingComma(value) { + return nil, fmt.Errorf("%w: Gemini settings do not support trailing commas", ErrMalformed) + } + } + doc.commentsOnly = true + return doc, nil +} + +func geminiWithoutTrailingComma(value *hujson.Value) bool { + switch node := value.Value.(type) { + case *hujson.Object: + return len(node.Members) == 0 || node.Members[len(node.Members)-1].Value.AfterExtra == nil + case *hujson.Array: + return len(node.Elements) == 0 || node.Elements[len(node.Elements)-1].AfterExtra == nil + default: + return true + } +} + +// Deleting a last member can turn the preceding member's comma into a trailing +// comma. Input validation already rejected native-invalid commas; keep the +// surviving foreign trivia while removing only commas created by mutation. +func geminiRemoveTrailingCommas(ast *hujson.Value) { + for value := range ast.All() { + switch node := value.Value.(type) { + case *hujson.Object: + if len(node.Members) > 0 { + last := &node.Members[len(node.Members)-1].Value + node.AfterExtra = append(last.AfterExtra, node.AfterExtra...) + last.AfterExtra = nil + } + case *hujson.Array: + if len(node.Elements) > 0 { + last := &node.Elements[len(node.Elements)-1] + node.AfterExtra = append(last.AfterExtra, node.AfterExtra...) + last.AfterExtra = nil + } + } + } +} diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/kernel.go b/install/integrationctl/agentplugins/adapters/nativeconfig/kernel.go index 02cfa33f9..849234f93 100644 --- a/install/integrationctl/agentplugins/adapters/nativeconfig/kernel.go +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/kernel.go @@ -92,9 +92,9 @@ func (kernel Kernel) ApplyBatch(requests []Request) (receipts []Receipt, err err if err != nil { return nil, err } - doc, err := newDocument(file.jsonc) + doc, err := newDocument(file.jsonc, requests[0].Codec) if file.exists { - doc, err = parseDocument(file.body, file.jsonc) + doc, err = parseCodecDocument(file.body, file.jsonc, requests[0].Codec) } if err != nil { return nil, err @@ -251,7 +251,7 @@ func (kernel Kernel) Inspect(paths Paths, codec Codec, name string, owned *Recei if err != nil || !file.exists { return false, false, err } - doc, err := parseDocument(file.body, file.jsonc) + doc, err := parseCodecDocument(file.body, file.jsonc, codec) if err != nil { return false, false, err } diff --git a/install/integrationctl/agentplugins/clients/gemini/activate.go b/install/integrationctl/agentplugins/clients/gemini/activate.go index 744aa7e81..938649656 100644 --- a/install/integrationctl/agentplugins/clients/gemini/activate.go +++ b/install/integrationctl/agentplugins/clients/gemini/activate.go @@ -22,33 +22,41 @@ func (*Adapter) VerifierAvailable(_ domain.DetectedClient, plan domain.DeliveryP // Activate installs or verifies managed Gemini CLI skills and MCP servers // through the native config kernel. -func (*Adapter) Activate(ctx context.Context, env clients.Env, request domain.ActivationRequest) (domain.ActivationOutcome, error) { +func (adapter *Adapter) Activate(ctx context.Context, env clients.Env, request domain.ActivationRequest) (domain.ActivationOutcome, error) { if err := shared.ActivationIdentityMismatch(request); err != nil { return domain.ActivationOutcome{}, err } - if err := validateProfile(request.Client.ConfigRoot, request.Plan.NativeRegistryRoot); err != nil { + root, err := geminiPlanRoot(request.Client, request.Plan) + if err != nil { + return domain.ActivationOutcome{}, err + } + if err := adapter.PreflightActivation(env, request); err != nil { return domain.ActivationOutcome{}, err } automatic := request.Plan.InstallIntent == domain.InstallIntentAutomatic && shared.HasNativeConfigRoot(request) - return shared.CompleteNativeConfigActivation(ctx, request, shared.NativeConfigActivation{ + outcome, err := shared.CompleteNativeConfigActivation(ctx, request, shared.NativeConfigActivation{ Automatic: automatic, UnavailableAction: "install Gemini CLI and rerun add with an isolated writable Gemini config root", RepairAction: "repair the managed Gemini CLI skills and MCP configuration", RetryAction: "retry the managed Gemini CLI native installation", CompletedAction: "in a running Gemini CLI session use `/mcp reload` and `/skills reload`, or restart Gemini CLI", Verify: func() error { - return VerifyGeminiNativeObjects(request.Client.ConfigRoot, request.Delivery.NativeObjects, false, env.NativeConfig) + return VerifyGeminiNativeObjects(root, request.Delivery.NativeObjects, false, env.NativeConfig) }, Activate: func(ctx context.Context, request domain.ActivationRequest) error { return ActivateGeminiNativeWithKernel(ctx, request, env.NativeConfig) }, }) + if err != nil { + return outcome, err + } + return geminiProjectStatus(env, request, outcome) } // Deactivate removes managed Gemini native objects. func (*Adapter) Deactivate(ctx context.Context, env clients.Env, request domain.DeactivationRequest) (domain.DeactivationOutcome, error) { outcome := shared.StartedDeactivation() - if err := validateProfile(request.Client.ConfigRoot, ""); err != nil { + if _, err := geminiNativeRoot(request.Client, request.TargetContext, request.NativeRegistryRoot); err != nil { return outcome, err } if !request.Confirmed { diff --git a/install/integrationctl/agentplugins/clients/gemini/identity.go b/install/integrationctl/agentplugins/clients/gemini/identity.go index 3c565f53e..1f7e54c8d 100644 --- a/install/integrationctl/agentplugins/clients/gemini/identity.go +++ b/install/integrationctl/agentplugins/clients/gemini/identity.go @@ -15,16 +15,24 @@ func (*Adapter) InspectNativeRegistry(ctx context.Context, env clients.Env, clie if err := ctx.Err(); err != nil { return clients.RegistryIndeterminate, err } + root, err := geminiInspectionRoot(client, plan) + if err != nil { + return clients.RegistryIndeterminate, err + } + plan.NativeRegistryRoot = root + return InspectGeminiRegistry(plan, managed, env.NativeConfig) +} + +func geminiInspectionRoot(client domain.DetectedClient, plan domain.DeliveryPlan) (string, error) { + if plan.Scope == domain.ScopeProject { + return geminiPlanRoot(client, plan) + } root := client.ConfigRoot if root == "" { root = plan.NativeRegistryRoot } if root == "" { - return clients.RegistryIndeterminate, nil - } - if err := validateProfile(root, plan.NativeRegistryRoot); err != nil { - return clients.RegistryIndeterminate, err + return "", nil } - plan.NativeRegistryRoot = root - return InspectGeminiRegistry(plan, managed, env.NativeConfig) + return root, validateProfile(root, plan.NativeRegistryRoot) } diff --git a/install/integrationctl/agentplugins/clients/gemini/native.go b/install/integrationctl/agentplugins/clients/gemini/native.go index a0f7b0ce4..d35daeaf8 100644 --- a/install/integrationctl/agentplugins/clients/gemini/native.go +++ b/install/integrationctl/agentplugins/clients/gemini/native.go @@ -28,14 +28,22 @@ func ActivateGeminiNativeWithKernel(ctx context.Context, request domain.Activati if err := ctx.Err(); err != nil { return err } - return applyGeminiNativeMutationWithKernel(request.Client.ConfigRoot, request.Delivery.ActivePath, request.PreviousNativeObjects, request.Delivery.NativeObjects, kernel) + root, err := geminiPlanRoot(request.Client, request.Plan) + if err != nil { + return err + } + return applyGeminiNativeMutationWithKernel(root, request.Delivery.ActivePath, request.PreviousNativeObjects, request.Delivery.NativeObjects, kernel) } func DeactivateGeminiNativeWithKernel(ctx context.Context, request domain.DeactivationRequest, kernel nativeconfig.Kernel) error { if err := ctx.Err(); err != nil { return err } - return applyGeminiNativeMutationWithKernel(request.Client.ConfigRoot, "", request.NativeObjects, nil, kernel) + root, err := geminiNativeRoot(request.Client, request.TargetContext, request.NativeRegistryRoot) + if err != nil { + return err + } + return applyGeminiNativeMutationWithKernel(root, "", request.NativeObjects, nil, kernel) } func VerifyGeminiNativeObjects(configRoot string, objects []domain.NativeObjectOwnership, allowMissing bool, kernel nativeconfig.Kernel) error { diff --git a/install/integrationctl/agentplugins/clients/gemini/plan.go b/install/integrationctl/agentplugins/clients/gemini/plan.go index dce45f036..3c29068ed 100644 --- a/install/integrationctl/agentplugins/clients/gemini/plan.go +++ b/install/integrationctl/agentplugins/clients/gemini/plan.go @@ -14,11 +14,21 @@ var ( ) func (*Adapter) CheckPlanPrecondition(in clients.PlanInput, plan *domain.DeliveryPlan) error { - return validateProfile(in.Client.ConfigRoot, plan.NativeRegistryRoot) + _, err := geminiNativeRoot(in.Client, in.TargetContext, plan.NativeRegistryRoot) + return err } func (*Adapter) RefinePlan(_ context.Context, in clients.PlanInput, plan *domain.DeliveryPlan) error { - shared.PromoteNativeReady(plan, in.Client.ConfigRoot, shared.OnlyNativeComponents(plan.Components)) + root := in.Client.ConfigRoot + if in.TargetContext.Scope == domain.ScopeProject { + root = plan.NativeRegistryRoot + } + shared.PromoteNativeReady(plan, root, shared.OnlyNativeComponents(plan.Components)) + if in.TargetContext.Scope == domain.ScopeProject { + plan.Activation = domain.ActivationPrepared + plan.UserActions = shared.AppendUnique(plan.UserActions, "project skills and MCP settings can be configured automatically; native discovery, workspace trust and system policy require observation in Gemini CLI") + return nil + } plan.UserActions = shared.AppendUnique(plan.UserActions, "agentplugins will install and verify Gemini CLI skills and MCP servers automatically; reload them in a running session or restart Gemini CLI") return nil } diff --git a/install/integrationctl/agentplugins/clients/gemini/project_status.go b/install/integrationctl/agentplugins/clients/gemini/project_status.go new file mode 100644 index 000000000..84caaf56f --- /dev/null +++ b/install/integrationctl/agentplugins/clients/gemini/project_status.go @@ -0,0 +1,161 @@ +package gemini + +import ( + "encoding/json" + "fmt" + "path/filepath" + "slices" + + "github.com/tailscale/hujson" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +var _ clients.ActivationPreflighter = (*Adapter)(nil) + +// PreflightActivation runs before the provider's generic attestation shortcut. +// Native configuration ownership cannot prove discovery in a Gemini session. +func (*Adapter) PreflightActivation(env clients.Env, request domain.ActivationRequest) error { + if request.Plan.Scope != domain.ScopeProject { + return nil + } + if _, err := geminiPlanRoot(request.Client, request.Plan); err != nil { + return err + } + if _, err := geminiProjectConditions(env.NativeConfig, request); err != nil { + return err + } + if request.ActivationComplete { + return fmt.Errorf("gemini project native discovery cannot be established by activation attestation; inspect skills and MCP servers in a fresh trusted session") + } + return nil +} + +func geminiProjectStatus(env clients.Env, request domain.ActivationRequest, outcome domain.ActivationOutcome) (domain.ActivationOutcome, error) { + if request.Plan.Scope != domain.ScopeProject || outcome.Verification != domain.VerificationInstalled { + return outcome, nil + } + outcome.Activation = domain.ActivationPrepared + outcome.ActivationAttested = false + outcome.UserActions = shared.AppendUnique(outcome.UserActions, "Gemini project configuration is installed; native discovery, workspace trust, system policy and MCP initialization remain unobserved; start Gemini CLI in the selected project and inspect skills list and mcp list") + conditions, err := geminiProjectConditions(env.NativeConfig, request) + if err != nil { + return outcome, fmt.Errorf("gemini project configuration was installed but effective settings could not be inspected: %w", err) + } + for _, condition := range conditions { + outcome.UserActions = shared.AppendUnique(outcome.UserActions, condition) + } + return outcome, nil +} + +type geminiProjectSettings struct { + Skills struct { + Enabled *bool `json:"enabled"` + Disabled []string `json:"disabled"` + } `json:"skills"` + MCP struct { + Allowed *[]string `json:"allowed"` + Excluded *[]string `json:"excluded"` + } `json:"mcp"` + Admin struct { + Skills struct { + Enabled *bool `json:"enabled"` + } `json:"skills"` + MCP struct { + Enabled *bool `json:"enabled"` + } `json:"mcp"` + } `json:"admin"` +} + +func readGeminiProjectSettings(kernel nativeconfig.Kernel, root string) (geminiProjectSettings, error) { + settings := geminiProjectSettings{} + // Inspect applies the same comment-only parser and uniqueness rules as the + // mutation path without creating a file or claiming ownership of this name. + if _, _, err := kernel.Inspect(GeminiConfigPaths(root), nativeconfig.CodecGemini, "agentplugins-policy-inspection", nil); err != nil { + return settings, err + } + file, err := kernel.ReadExactFile(filepath.Join(root, "settings.json")) + if err != nil || !file.Exists { + return settings, err + } + if len(file.Body) > 1<<20 { + return settings, fmt.Errorf("gemini settings exceed the inspection limit") + } + standard, err := hujson.Standardize(file.Body) + if err != nil { + return settings, err + } + if _, err := shared.DecodeStrictJSONObject(standard); err != nil { + return settings, err + } + if err := json.Unmarshal(standard, &settings); err != nil { + return settings, fmt.Errorf("invalid Gemini component settings: %w", err) + } + return settings, nil +} + +// These are configured user/workspace restrictions, not a second Gemini +// settings engine. Higher system policy and session trust remain unobserved. +func geminiProjectConditions(kernel nativeconfig.Kernel, request domain.ActivationRequest) ([]string, error) { + user, err := readGeminiProjectSettings(kernel, request.Client.ConfigRoot) + if err != nil { + return nil, err + } + workspace, err := readGeminiProjectSettings(kernel, request.Plan.NativeRegistryRoot) + if err != nil { + return nil, err + } + conditions := geminiConfiguredRestrictions(user, workspace, request.Plan.Components) + for _, component := range request.Plan.Components { + if component.Kind != domain.ComponentSkill { + continue + } + if err := pathpolicy.ValidateLeafID(component.Name); err != nil { + return nil, err + } + alias := filepath.Join(request.Plan.TargetContext.ProjectRoot, ".agents", "skills", component.Name, "SKILL.md") + file, err := kernel.ReadExactFile(alias) + if err != nil { + return nil, fmt.Errorf("inspect Gemini workspace skill alias: %w", err) + } + if file.Exists { + conditions = append(conditions, fmt.Sprintf("workspace .agents/skills/%s/SKILL.md may shadow the managed .gemini skill; native discovery remains pending", component.Name)) + } + } + return conditions, nil +} + +func geminiConfiguredRestrictions(user, workspace geminiProjectSettings, components []domain.ComponentDecision) []string { + conditions := []string{} + skills := geminiConfiguredEnabled(user.Skills.Enabled, workspace.Skills.Enabled) + adminSkills := geminiConfiguredEnabled(user.Admin.Skills.Enabled, workspace.Admin.Skills.Enabled) + adminMCP := geminiConfiguredEnabled(user.Admin.MCP.Enabled, workspace.Admin.MCP.Enabled) + allowed, excluded := user.MCP.Allowed, user.MCP.Excluded + if workspace.MCP.Allowed != nil { + allowed = workspace.MCP.Allowed + } + if workspace.MCP.Excluded != nil { + excluded = workspace.MCP.Excluded + } + disabled := append(slices.Clone(user.Skills.Disabled), workspace.Skills.Disabled...) + for _, component := range components { + if component.Kind == domain.ComponentSkill && (!skills || !adminSkills || slices.Contains(disabled, component.Name)) { + conditions = append(conditions, fmt.Sprintf("preserved Gemini user/workspace settings disable skill %q; it remains configured pending native discovery", component.Name)) + } + if component.Kind == domain.ComponentMCPServer && (!adminMCP || allowed != nil && !slices.Contains(*allowed, component.Name) || excluded != nil && slices.Contains(*excluded, component.Name)) { + conditions = append(conditions, fmt.Sprintf("preserved Gemini user/workspace settings restrict MCP server %q; it remains configured pending native discovery", component.Name)) + } + } + return conditions +} + +func geminiConfiguredEnabled(user, workspace *bool) bool { + if workspace != nil { + return *workspace + } + return user == nil || *user +} diff --git a/install/integrationctl/agentplugins/clients/gemini/scope.go b/install/integrationctl/agentplugins/clients/gemini/scope.go new file mode 100644 index 000000000..d3d1928d0 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/gemini/scope.go @@ -0,0 +1,63 @@ +package gemini + +import ( + "fmt" + "path/filepath" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +var ( + _ clients.ProjectTargetLayout = (*Adapter)(nil) + _ clients.NativeRegistryLayout = (*Adapter)(nil) +) + +// NativeRegistry selects actual workspace configuration without replacing the +// detected user profile or changing Gemini's native home environment. +func (*Adapter) NativeRegistry(in clients.PlanInput) (string, string) { + if in.TargetContext.Scope == domain.ScopeProject { + return filepath.Join(in.TargetContext.ProjectRoot, ".gemini"), in.Client.ExecutablePath + } + return in.Client.ConfigRoot, in.Client.ExecutablePath +} + +func (*Adapter) ProjectTargetRoot(client domain.DetectedClient, mode domain.PackageMode, managedRoot string, target domain.TargetContext) (string, string, error) { + if target.Scope != domain.ScopeProject { + return "", "", fmt.Errorf("gemini project layout requires project scope") + } + if _, err := geminiNativeRoot(client, target, ""); err != nil { + return "", "", err + } + if err := pathpolicy.RequireContainedChild(target.ProjectRoot, managedRoot); err != nil { + return "", "", fmt.Errorf("gemini prepared package must stay in the selected project: %w", err) + } + return shared.ManagedTargetRoot(client, mode, managedRoot) +} + +func geminiPlanRoot(client domain.DetectedClient, plan domain.DeliveryPlan) (string, error) { + if (plan.Scope == domain.ScopeProject) != (plan.TargetContext.Scope == domain.ScopeProject) { + return "", fmt.Errorf("gemini plan scope differs from its target context") + } + return geminiNativeRoot(client, plan.TargetContext, plan.NativeRegistryRoot) +} + +func geminiNativeRoot(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.UserConfigRoot != "" && validated.UserConfigRoot != client.ConfigRoot { + return "", fmt.Errorf("gemini detected user profile differs from the selected target context") + } + root := client.ConfigRoot + if validated.Scope == domain.ScopeProject { + root = filepath.Join(validated.ProjectRoot, ".gemini") + } + if err := validateProfile(root, planned); err != nil { + return "", err + } + return root, nil +} diff --git a/install/integrationctl/agentplugins/domain/clients.go b/install/integrationctl/agentplugins/domain/clients.go index 9883a2bcc..eaed3b52d 100644 --- a/install/integrationctl/agentplugins/domain/clients.go +++ b/install/integrationctl/agentplugins/domain/clients.go @@ -131,11 +131,11 @@ var clientDefinitions = []ClientDefinition{ LifecycleKind: LifecycleCLIRegistry, UsesManagedStdioLauncher: true, }), ActivationAutomatic), - clientDefinition(ClientGemini, "Gemini CLI", "gemini", "managed", "native", false, PackageNative, SupportNative, SupportNative, SupportNative, SupportUnsupported, SupportUnsupported, ClientTraits{ + withProjectScope(clientDefinition(ClientGemini, "Gemini CLI", "gemini", "managed", "native", false, PackageNative, SupportNative, SupportNative, SupportNative, SupportUnsupported, SupportUnsupported, ClientTraits{ InstallIntents: []InstallIntent{InstallIntentAutomatic}, LifecycleKind: LifecycleNativeConfig, BindsNativeProfileRoot: true, - }), + }), LifecycleNativeConfig), withActivation(clientDefinition(ClientOpenCode, "OpenCode", "opencode", "managed", "prepared", false, PackagePrepared, SupportPrepared, SupportPrepared, SupportUnsupported, SupportUnsupported, SupportUnsupported, ClientTraits{ InstallIntents: []InstallIntent{InstallIntentAutomatic}, LifecycleKind: LifecycleNativeConfig, 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 de0253c2a..29d3cdfc2 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json @@ -258,7 +258,8 @@ }, "PackageMode": "native", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "native" }, 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 8fbb5d9e9..9c6afc8df 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 @@ -390,7 +390,8 @@ }, "PackageMode": "native", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "native" }, 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 09692c991..1352c239c 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json @@ -234,7 +234,8 @@ }, "PackageMode": "native", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "native" }, 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 6111451ec..d1c06431c 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json @@ -280,7 +280,8 @@ }, "PackageMode": "native", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "native" }, diff --git a/install/integrationctl/agentplugins/providers/project_gemini_helpers_test.go b/install/integrationctl/agentplugins/providers/project_gemini_helpers_test.go new file mode 100644 index 000000000..e22ded930 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_gemini_helpers_test.go @@ -0,0 +1,65 @@ +package providers_test + +import ( + "crypto/sha256" + "fmt" + "io/fs" + "os" + "path/filepath" + "testing" +) + +func projectGeminiTemp(t *testing.T) string { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return root +} + +func projectGeminiWrite(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 projectGeminiAssert(t *testing.T, path, want string) { + t.Helper() + body, err := os.ReadFile(path) + if err != nil || string(body) != want { + t.Fatalf("fixture bytes at %s = %q, %v", path, body, err) + } +} + +func projectGeminiInventory(t *testing.T, root string) map[string]string { + t.Helper() + files := map[string]string{} + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + if entry.IsDir() { + files[path] = fmt.Sprintf("dir:%s", info.Mode()) + return nil + } + body, err := os.ReadFile(path) + if err != nil { + return err + } + files[path] = fmt.Sprintf("%s:%x", info.Mode(), sha256.Sum256(body)) + return nil + }) + if err != nil { + t.Fatal(err) + } + return files +} diff --git a/install/integrationctl/agentplugins/providers/project_gemini_scope_test.go b/install/integrationctl/agentplugins/providers/project_gemini_scope_test.go new file mode 100644 index 000000000..5804799d9 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_gemini_scope_test.go @@ -0,0 +1,105 @@ +package providers_test + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/gemini" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/providers" +) + +func projectGeminiRequest(t *testing.T) (domain.ActivationRequest, string, string) { + t.Helper() + base := projectGeminiTemp(t) + user, projectA, projectB := filepath.Join(base, "user", ".gemini"), filepath.Join(base, "A"), filepath.Join(base, "B") + for _, root := range []string{user, filepath.Join(projectA, ".gemini"), filepath.Join(projectB, ".gemini")} { + projectGeminiWrite(t, filepath.Join(root, "settings.json"), "{\n // preserve foreign settings\n \"mcpServers\": {\"foreign\": {\"url\":\"https://foreign.test/mcp\"}}, \"theme\":\"foreign\"\n}\n") + projectGeminiWrite(t, filepath.Join(root, "skills", "foreign", "SKILL.md"), "foreign skill\n") + } + client := domain.DetectedClient{ClientID: domain.ClientGemini, ConfigRoot: user, Status: domain.DetectionDetected} + target, err := domain.NewTargetContext(domain.ScopeProject, projectA, user) + if err != nil { + t.Fatal(err) + } + active := filepath.Join(projectA, ".uap", "managed", "clients", "gemini", "archive-demo") + projectGeminiWrite(t, filepath.Join(active, "skills", "docs", "SKILL.md"), "---\nname: docs\ndescription: Project fixture\n---\nInert fixture.\n") + projectGeminiWrite(t, filepath.Join(active, "mcp.json"), `{"mcpServers":{"docs":{"type":"sse","url":"https://managed.test/mcp"}}}`) + plan := domain.DeliveryPlan{ClientID: domain.ClientGemini, Scope: domain.ScopeProject, TargetContext: target, InstallIntent: domain.InstallIntentAutomatic, + NativeRegistryRoot: filepath.Join(projectA, ".gemini"), ActivePath: active, Components: []domain.ComponentDecision{ + {Kind: domain.ComponentSkill, Name: "docs", Support: domain.SupportNative}, {Kind: domain.ComponentMCPServer, Name: "docs", Support: domain.SupportNative}, + }} + envelope := domain.PackageEnvelope{Skills: map[string]domain.Skill{"docs": {Name: "docs", RelativePath: "skills/docs/SKILL.md"}}, + MCP: domain.MCPComponent{Servers: map[string]domain.MCPServer{"docs": {Name: "docs", Type: "sse", Decoded: map[string]any{"url": "https://managed.test/mcp"}}}}} + objects, err := gemini.BuildGeminiNativeObjects(active, envelope, plan, filepath.Join(projectA, ".uap", "data", "demo")) + if err != nil { + t.Fatal(err) + } + return domain.ActivationRequest{Client: client, Plan: plan, DeclaredName: "archive-demo", Delivery: domain.StagedDelivery{ + ClientID: domain.ClientGemini, ActivePath: active, OwnedBase: filepath.Dir(active), NativeObjects: objects}}, projectB, user +} + +func TestProjectGeminiNativeLifecycleKeepsUserAndOtherProject(t *testing.T) { + request, projectB, user := projectGeminiRequest(t) + userBefore, bBefore := projectGeminiInventory(t, user), projectGeminiInventory(t, projectB) + registry, err := clients.NewRegistry(gemini.New()) + if err != nil { + t.Fatal(err) + } + kernel := nativeconfig.New() + activator := providers.Activator{Registry: registry, NativeConfig: &kernel} + outcome, err := activator.Activate(context.Background(), request) + if err != nil || outcome.Verification != domain.VerificationInstalled || len(outcome.NativeObjects) != 2 { + t.Fatalf("project config activation = %+v, %v", outcome, err) + } + root := request.Plan.NativeRegistryRoot + projectGeminiAssert(t, filepath.Join(root, "skills", "docs", "SKILL.md"), "---\nname: docs\ndescription: Project fixture\n---\nInert fixture.\n") + settings, err := os.ReadFile(filepath.Join(root, "settings.json")) + if err != nil || !strings.Contains(string(settings), "preserve foreign settings") || !strings.Contains(string(settings), "managed.test") || !strings.Contains(string(settings), "foreign.test") { + t.Fatalf("project MCP merge lost entries/comments: %s, %v", settings, err) + } + request.VerifyOnly, request.PreviousNativeObjects = true, outcome.NativeObjects + if _, err := activator.Activate(context.Background(), request); err != nil { + t.Fatal(err) + } + remove := domain.DeactivationRequest{Client: request.Client, TargetContext: request.Plan.TargetContext, NativeRegistryRoot: root, Confirmed: true, NativeObjects: outcome.NativeObjects} + for range 2 { + removed, err := activator.Deactivate(context.Background(), remove) + if err != nil || !removed.ExternalRemovalComplete { + t.Fatalf("exact project removal = %+v, %v", removed, err) + } + } + if _, err := os.Stat(filepath.Join(root, "skills", "docs")); !os.IsNotExist(err) { + t.Fatalf("managed project skill survived removal: %v", err) + } + if !reflect.DeepEqual(userBefore, projectGeminiInventory(t, user)) || !reflect.DeepEqual(bBefore, projectGeminiInventory(t, projectB)) { + t.Fatal("project lifecycle mutated user profile or project B") + } +} + +func TestProjectGeminiTargetContextRejectsUserRegistryAndAliases(t *testing.T) { + request, _, user := projectGeminiRequest(t) + adapter := gemini.New() + managed := filepath.Join(request.Plan.TargetContext.ProjectRoot, ".uap", "managed") + if _, root, err := adapter.ProjectTargetRoot(request.Client, domain.PackageNative, managed, request.Plan.TargetContext); err != nil || root != filepath.Join(managed, "clients", "gemini") { + t.Fatalf("project artifact layout = %q, %v", root, err) + } + if _, _, err := adapter.ProjectTargetRoot(request.Client, domain.PackageNative, filepath.Dir(user), request.Plan.TargetContext); err == nil { + t.Fatal("project artifacts escaped to user profile") + } + request.Plan.NativeRegistryRoot = user + if _, err := adapter.Activate(context.Background(), clients.Env{NativeConfig: nativeconfig.New()}, request); err == nil { + t.Fatal("project plan accepted user registry") + } + request.Plan.NativeRegistryRoot = filepath.Join(request.Plan.TargetContext.ProjectRoot, ".gemini") + request.Plan.TargetContext.UserConfigRoot = filepath.Join(user, "other") + if _, err := adapter.Activate(context.Background(), clients.Env{NativeConfig: nativeconfig.New()}, request); err == nil { + t.Fatal("project plan replaced original user profile authority") + } +} diff --git a/install/integrationctl/agentplugins/providers/project_gemini_stager_test.go b/install/integrationctl/agentplugins/providers/project_gemini_stager_test.go new file mode 100644 index 000000000..6287608fd --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_gemini_stager_test.go @@ -0,0 +1,87 @@ +package providers_test + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/loader" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/specregistry" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/gemini" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/commandext" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/planner" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/providers" +) + +// This uses the production loader, planner, renderer, stager and activator. +// It proves an inert contract journey, not Gemini session/model discovery. +func TestProjectGeminiPlannerStagerRendersCommandsAndOwnsExactNativeComponents(t *testing.T) { + request, projectB, user := projectGeminiRequest(t) + beforeUser, beforeB := projectGeminiInventory(t, user), projectGeminiInventory(t, projectB) + source := filepath.Join(projectGeminiTemp(t), "source") + projectGeminiWrite(t, filepath.Join(source, "plugin.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"demo","version":"1.0.0"}`) + projectGeminiWrite(t, filepath.Join(source, "skills", "docs", "SKILL.md"), "---\nname: docs\ndescription: Project docs\n---\nInert docs.\n") + projectGeminiWrite(t, filepath.Join(source, filepath.FromSlash(commandext.RelativeDirectory), "echo.md"), "---\ndescription: Echo the marker\ndisable-model-invocation: false\n---\nUAP-PROJECT-INERT\n") + projectGeminiWrite(t, filepath.Join(source, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"docs":{"type":"sse","url":"https://managed.test/mcp"}}}`) + schemas, err := specregistry.New() + if err != nil { + t.Fatal(err) + } + envelope, err := (loader.Loader{Registry: schemas}).Load(context.Background(), domain.LoadInput{SnapshotRoot: source}) + if err != nil { + t.Fatal(err) + } + registry, err := clients.NewRegistry(gemini.New()) + if err != nil { + t.Fatal(err) + } + project := request.Plan.TargetContext.ProjectRoot + planning := planner.Planner{ProjectRoot: project, ManagedRoot: filepath.Join(project, ".uap", "managed"), Paths: pathpolicy.Policy{}, Registry: registry} + plan, err := planning.Plan(context.Background(), domain.PlanRequest{Envelope: envelope, Client: request.Client, Scope: domain.ScopeProject, ProjectRoot: project, PhysicalArtifactID: "demo-0123456789ab"}) + if err != nil || plan.Status != domain.PlanReady || plan.Activation != domain.ActivationPrepared || plan.NativeRegistryRoot != filepath.Join(project, ".gemini") { + t.Fatalf("scoped Gemini plan = %+v, %v", plan, err) + } + stager := providers.Stager{Registry: registry, Paths: pathpolicy.Policy{}, PluginDataRoot: filepath.Join(project, ".uap", "data")} + delivery, err := stager.Stage(context.Background(), envelope, plan, "project-gemini-command", domain.CompatibilityHints{}) + if err != nil { + t.Fatal(err) + } + if len(gemini.GeminiObjects(delivery.NativeObjects)) != 3 { + t.Fatalf("staged skill/command/MCP objects = %+v", delivery.NativeObjects) + } + if err := os.MkdirAll(filepath.Dir(plan.ActivePath), 0700); err != nil { + t.Fatal(err) + } + if err := os.Rename(delivery.StagingPath, plan.ActivePath); err != nil { + t.Fatal(err) + } + delivery.StagingPath = "" + request.Plan, request.Delivery, request.DeclaredName = plan, delivery, "demo" + activator := projectGeminiActivator(t, nativeconfig.New()) + outcome, err := activator.Activate(context.Background(), request) + if err != nil || outcome.Activation != domain.ActivationPrepared || outcome.Verification != domain.VerificationInstalled { + t.Fatalf("staged project activation = %+v, %v", outcome, err) + } + body, err := os.ReadFile(filepath.Join(project, ".gemini", "skills", "demo-echo", "SKILL.md")) + if err != nil || !strings.Contains(string(body), "UAP-PROJECT-INERT") { + t.Fatalf("rendered command skill = %q, %v", body, err) + } + activeObjects, err := stager.ProjectActiveNative(context.Background(), envelope, plan, delivery.ArtifactDigest, "") + if err != nil || !reflect.DeepEqual(activeObjects, gemini.GeminiObjects(delivery.NativeObjects)) { + t.Fatalf("read-only native objects changed = %+v, %v", activeObjects, err) + } + remove := domain.DeactivationRequest{Client: request.Client, TargetContext: plan.TargetContext, NativeRegistryRoot: plan.NativeRegistryRoot, Confirmed: true, NativeObjects: outcome.NativeObjects} + if out, err := activator.Deactivate(context.Background(), remove); err != nil || !out.ExternalRemovalComplete { + t.Fatalf("project cleanup = %+v, %v", out, err) + } + if !reflect.DeepEqual(beforeUser, projectGeminiInventory(t, user)) || !reflect.DeepEqual(beforeB, projectGeminiInventory(t, projectB)) { + t.Fatal("stager journey changed another scope") + } +} diff --git a/install/integrationctl/agentplugins/providers/project_gemini_status_test.go b/install/integrationctl/agentplugins/providers/project_gemini_status_test.go new file mode 100644 index 000000000..dfd299b55 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_gemini_status_test.go @@ -0,0 +1,119 @@ +package providers_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/gemini" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/providers" +) + +func projectGeminiActivator(t *testing.T, kernel nativeconfig.Kernel) providers.Activator { + t.Helper() + registry, err := clients.NewRegistry(gemini.New()) + if err != nil { + t.Fatal(err) + } + return providers.Activator{Registry: registry, NativeConfig: &kernel} +} + +func TestProjectGeminiConfiguredStateDoesNotClaimNativeDiscovery(t *testing.T) { + request, _, _ := projectGeminiRequest(t) + // An ambient bypass flag is never proof of durable workspace trust. + t.Setenv("GEMINI_CLI_TRUST_WORKSPACE", "true") + activator := projectGeminiActivator(t, nativeconfig.New()) + for pass := range 3 { + outcome, err := activator.Activate(context.Background(), request) + if err != nil || outcome.Activation != domain.ActivationPrepared || outcome.Verification != domain.VerificationInstalled || outcome.ActivationAttested { + t.Fatalf("pass %d configured status = %+v, %v", pass, outcome, err) + } + if !strings.Contains(strings.Join(outcome.UserActions, "\n"), "workspace trust, system policy and MCP initialization remain unobserved") { + t.Fatalf("native proof boundary missing: %+v", outcome) + } + request.PreviousNativeObjects = outcome.NativeObjects + // A second automatic reconciliation and a fresh verifier both stay + // exact and prepared; neither config presence nor a restart promotes it. + request.VerifyOnly = pass > 0 + } + before := projectGeminiInventory(t, request.Plan.NativeRegistryRoot) + for _, automatic := range []bool{true, false} { + request.ActivationComplete = true + if !automatic { + request.Plan.Components[0].Support = domain.SupportPrepared + } + if _, err := activator.Activate(context.Background(), request); err == nil || !strings.Contains(err.Error(), "cannot be established by activation attestation") { + t.Fatalf("generic attestation bypassed project preflight: %v", err) + } + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, request.Plan.NativeRegistryRoot)) { + t.Fatal("attestation mutated project configuration") + } +} + +func TestProjectGeminiPreservesRestrictionsAndSkillAlias(t *testing.T) { + request, projectB, user := projectGeminiRequest(t) + userSettings := `{"skills":{"disabled":["docs"]},"mcp":{"allowed":[]},"mcpServers":{"docs":{"url":"https://user.test/mcp"}}}` + workspaceSettings := "{ // preserve workspace restriction\n\"skills\":{\"enabled\":false},\"mcp\":{\"excluded\":[\"docs\"]},\"mcpServers\":{}}\n" + projectGeminiWrite(t, filepath.Join(user, "settings.json"), userSettings) + projectGeminiWrite(t, filepath.Join(request.Plan.NativeRegistryRoot, "settings.json"), workspaceSettings) + alias := filepath.Join(request.Plan.TargetContext.ProjectRoot, ".agents", "skills", "docs", "SKILL.md") + const aliasBody = "---\nname: docs\ndescription: Foreign alias\n---\nKeep alias bytes.\n" + projectGeminiWrite(t, alias, aliasBody) + beforeUser, beforeB := projectGeminiInventory(t, user), projectGeminiInventory(t, projectB) + outcome, err := projectGeminiActivator(t, nativeconfig.New()).Activate(context.Background(), request) + if err != nil || outcome.Activation != domain.ActivationPrepared { + t.Fatalf("restricted configuration = %+v, %v", outcome, err) + } + actions := strings.Join(outcome.UserActions, "\n") + for _, reason := range []string{`disable skill "docs"`, `restrict MCP server "docs"`, `.agents/skills/docs/SKILL.md may shadow`} { + if !strings.Contains(actions, reason) { + t.Fatalf("missing %q in %s", reason, actions) + } + } + projectGeminiAssert(t, alias, aliasBody) + settings, err := os.ReadFile(filepath.Join(request.Plan.NativeRegistryRoot, "settings.json")) + if err != nil || !strings.Contains(string(settings), `"enabled":false`) || !strings.Contains(string(settings), `"excluded":["docs"]`) || !strings.Contains(string(settings), "// preserve workspace restriction") { + t.Fatalf("workspace policy changed: %s, %v", settings, err) + } + if !reflect.DeepEqual(beforeUser, projectGeminiInventory(t, user)) || !reflect.DeepEqual(beforeB, projectGeminiInventory(t, projectB)) { + t.Fatal("restriction inspection mutated another scope") + } +} + +func TestProjectGeminiMalformedSettingsAndWriterFailureLeaveNativeStateUnchanged(t *testing.T) { + for name, prepare := range map[string]func(*testing.T, domain.ActivationRequest) nativeconfig.Kernel{ + "malformed project": func(t *testing.T, r domain.ActivationRequest) nativeconfig.Kernel { + projectGeminiWrite(t, filepath.Join(r.Plan.NativeRegistryRoot, "settings.json"), `{"skills":{"enabled":false},}`) + return nativeconfig.New() + }, + "foreign MCP collision": func(t *testing.T, r domain.ActivationRequest) nativeconfig.Kernel { + projectGeminiWrite(t, filepath.Join(r.Plan.NativeRegistryRoot, "settings.json"), `{"mcpServers":{"docs":{"url":"https://foreign.test/keep"}}}`) + return nativeconfig.New() + }, + "MCP writer lock failure": func(*testing.T, domain.ActivationRequest) nativeconfig.Kernel { + return nativeconfig.NewWithLockAcquirer(func(nativeconfig.Paths, nativeconfig.Codec) (func() error, error) { + return nil, errors.New("TEST writer lock unavailable") + }) + }, + } { + t.Run(name, func(t *testing.T) { + request, b, user := projectGeminiRequest(t) + kernel := prepare(t, request) + beforeProject, beforeUser, beforeB := projectGeminiInventory(t, request.Plan.NativeRegistryRoot), projectGeminiInventory(t, user), projectGeminiInventory(t, b) + if _, err := projectGeminiActivator(t, kernel).Activate(context.Background(), request); err == nil { + t.Fatal("unsafe/failed project mutation succeeded") + } + if !reflect.DeepEqual(beforeProject, projectGeminiInventory(t, request.Plan.NativeRegistryRoot)) || !reflect.DeepEqual(beforeUser, projectGeminiInventory(t, user)) || !reflect.DeepEqual(beforeB, projectGeminiInventory(t, b)) { + t.Fatal("failed project install left native mutations") + } + }) + } +}