diff --git a/.github/workflows/agentplugins-native-clients.yml b/.github/workflows/agentplugins-native-clients.yml index d56aa5111..b0e4d230f 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 }}' == 'codex' ]]; 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/codex-authority-recovery-plan.md b/docs/plans/project-install-e2e/codex-authority-recovery-plan.md new file mode 100644 index 000000000..5522451f4 --- /dev/null +++ b/docs/plans/project-install-e2e/codex-authority-recovery-plan.md @@ -0,0 +1,91 @@ +# Codex exact project authority recovery — fresh amendment C2 + +The reset erased finald8daad5a including exact authority corrections and public +lifecycle fixtures. C1 is now independently reviewed with current tests at +5708e861 and reports8c4e1cec. C2 restores the known separate obligations against +that concrete parser seam. Historical GOs/native passes are context, never proof +for reconstructed bytes. Two fresh independent plan GOs precede C2 code. + +## Exact authority before effects + +Every project candidate includes either project scope or any nonempty project +root; inconsistent user/project facts must not fall into user CLI behavior. At +Codex project boundaries require literal ClientCodex for detected client, plan, +and populated delivery; matching explicit ScopeProject on plan and context; a +validated canonical target context retaining the exact original user ConfigRoot; +nonempty exact native project root; clean portable nonempty physical artifact; +and literal deterministic managed target/active paths under selected project's +.uap/managed/clients/codex/. Reject omitted, rebound, cleaned aliases, +foreign client/profile/project/artifact and malformed paths before reads/writes +or native runner effects. Root and native object ownership rules stay strict. + +Early activation preflight legitimately occurs before staging. Accept only an +entirely zero/unstaged delivery there, after validating exact plan/context; once +any delivery fact is present require full exact delivery client, active path and +owned base. Actual Activate always requires populated exact delivery. Validate +ActivationComplete refusal before any operational effect. Readiness remains +prepared with independently observed installed native configuration, never +automatically active/native discovery/trust or attested activation. + +Prepared inspection must construct complete client/scope/artifact/native-root/ +active-locator binding facts rather than relying on missing-field tolerance. +A real managed project binding requires literal client/scope/artifact/root and +target locator equality against the validated plan; validate native objects as +before. Confirmed project removal similarly requires exact recorded client, +artifact, selected original profile/project/native root and deterministic managed +artifact path before mutation, independent of current executable/version. +No current user-scope behavior or legacy marketplace evidence is broadened. + +## Physical stage and current stdio reuse + +Before ProjectOpenAIMCP writes anything, validate project staging as an existing +clean absolute physical nonalias directory strictly inside the selected project +and its managed target area, separate from active/native source/watch locations. +Validate all existing ancestors using no-follow containment; do not silently +canonicalize caller input. Refuse project root, original user profile, project B, +external source, active-path alias, symlink and nonclean spelling with whole-tree +inventories unchanged. This is adapter-local, not a generic pathpolicy rewrite. + +Read-only active reconstruction requires root exactly equal to plan.ActivePath +and strict plan authority. For each selected stdio server require the current +selected data locator to be nonempty clean absolute physical project-contained +input, then exact projected PLUGIN_ROOT==plan.ActivePath and +PLUGIN_DATA==current data locator. Omitted/relative/rebound data or either stale +package/data projection returns errors wrapping existing +domain.ErrProjectionRefreshRequired, with zero writes; installer can reproject +from the exact pinned package. Remote-only and skill-only reuse remains valid +without an artificial data requirement. Keep all authentication-reference and +transport validation. Never execute the inert MCP command. + +## Fresh composed proof and boundaries + +Direct adapter and real provider tests exercise all omitted/mismatched identity, +scope/context, artifact/layout, populated-delivery, prepared-binding and recorded +removal facts; assert no runner invocation and unchanged user/A/B/source/package +inventories on refusal. Positive exact project delivery must still install/ +verify/update/remove supported skill/command and stdio/remote MCP components. +Preserve malformed/collision/foreign-edited receipt and user contract tests. + +Reconstruct a real public Engine/SDK A/B journey with original user profile, +stdio persistent-data binding, unchanged sync, restart/cold pinned repair despite +moving source, explicit update and confirmed removal retaining owned data. +Reconstruct public production CLI journeys over actual disposable TLS Git and +HTTPS ZIP transports: saved manifest/lock/state, source pins and cold repair, +selected native config/skills, user/B foreign policy preservation, update/status/ +remove with retained data. Reuse existing project fixture infrastructure without +substituting fake parser/installer/provider results; host detector/security and +network endpoints may be inert controlled boundaries. Test fixture seed roots +are physically canonical before composition, retaining intentional alias +negative tests; no production normalization or platform skips. + +Run complete preserved Codex client/provider packages, affected public Engine/ +SDK and CLI journeys, unchanged architecture closure and both pinned changed +code/full-file size/arch lint. No vendor/model/auth/trust or MCP execution. +Freeze for two fresh independent code reviews, resolve findings, commit reports +and send root checkpoints immediately. C2 fixture proof does not relabel old +macOS/Windows native evidence or replace required current assembled/native gates. + +The existing project configuration readers have a known post-read size check; +this amendment does not authorize an unrelated kernel/reader redesign. Any new +concrete bounded-reader defect requiring source changes receives its own narrow +reviewed amendment rather than broadening this recovery silently. diff --git a/docs/plans/project-install-e2e/codex-code-review-r1.md b/docs/plans/project-install-e2e/codex-code-review-r1.md new file mode 100644 index 000000000..366b0b74e --- /dev/null +++ b/docs/plans/project-install-e2e/codex-code-review-r1.md @@ -0,0 +1,103 @@ +# Codex project implementation independent review R1 + +Reviewer: core_clients, 2026-10-23. Reviewed frozen `048c3455` including the +`fda36d23` TOML codec, `6387ac85` adapter and source-native discovery addition. +This review is independent of the codec-only R1 and the author implementation. + +## Decision + +**R1 GO at `4f4a320bc58c5c45e23a75d56c43bc0c2b3da1a6`.** No remaining +implementation blocker was found in scope authority, ownership, mutation or +native evidence boundaries. The R2 selector correction and R1 follow-up below +are independently inspected closed. Hosted native discovery is still pending; +this review supplies no runtime GO. + +## Reviewed behavior + +- Project admission uses the declarative native-config scoped policy instead + of Codex user CLI-registry traits. The adapter requires both plan and target + context to name project scope, retains the original detected user profile, + and checks the explicit project registry root and no-follow native children. + Project layout does not derive authority from a prepared package path. +- The production planner/stager/projector/activator path emits exact owned + `.agents/skills` directories and `.codex/config.toml` MCP members. Existing + rendered commands, OpenAI MCP projection, managed stdio package/data/cwd + materialization and authentication references are reused. Active reprojection + validates the bound stdio locators without editing immutable package bytes. +- Skill and MCP mutation consumes the reviewed shared component transaction + and native ownership kernel. Per-object IDs, kinds, logical names, paths and + digests fence update/remove, foreign disabled entries remain collisions, and + missing exact owned skills can be repaired. Project operations do not run + Codex user marketplace registration/removal or mutate its user cache. +- The TOML parser refuses malformed/duplicate documents. Retained syntax edits + are re-parsed and compared against complete requested MCP entries and all + non-MCP semantic settings before publication. Existing JSON/JSONC lifecycle + remains on the same kernel. SSE is refused rather than silently converted. +- Project file success is always prepared activation plus installed + verification. The adapter's preflight unconditionally rejects project + activation attestation, including through the composed provider shortcut. + Visible user/project restrictions are diagnostic observations rather than a + claim to reconstruct native trust, profile, managed or session precedence. +- The new native gate is restricted to current-source Codex. It uses exact + required-test accounting, rejects released/draft/prepared evidence inputs, + retains hosted disposal and artifact integrity gates, and fails skipped or + missing required tests. No guard is spoofed or removed. +- The native fixture uses fresh genuine pinned app-server processes and the + documented `initialize`/`skills/list` route only. Its parser requires exact + selected-project repository paths, enabled entries, no plugin identity, + correct cwd, no discovery errors and no duplicates. Update, deleted-skill + repair and removal observe fresh discovery; project B must not see A's + fixture skills and recorded foreign user/cache/project files retain bytes. + Exact rendered command bytes are checked. No thread/model request, trust, + credentials, MCP initialization or effective-policy proof is manufactured. + +## Independent execution + +Go 1.26.10, GOMAXPROCS=3, `-p=2`, RAM toolchain/cache: + +- Focused nativeconfig Codex contracts: PASS 0.011s. +- Codex client contracts (including existing user Codex cases): PASS 0.033s. +- Production provider Codex contracts: PASS 0.242s. +- Domain compatibility and planner goldens: PASS 0.005s / 0.020s. +- Exact new response-parser and disposable-boundary contracts: PASS 0.008s. +- Native runner Python safety/evidence suite: 56 tests, PASS 0.495s. + +The first broader user tests correctly rejected this harness's case-duplicate +ambient proxy entries. A clean process environment resolved those failures; +production validation was unchanged. An overbroad repotest name selection also +reached existing legacy CLI build cases whose two uncached dependencies could +not be downloaded without inherited network configuration. Those unrelated +legacy cases are not claimed as passing; the exact new parser/safety contracts +were rerun successfully. No native client was executed on the task host. + +## Selector correction and R1 closure + +Independently compared the pinned native `codex-rs/config/src/skills_config.rs` +with `9d128aa2`. Ordered matching, trimmed names, canonical SKILL.md document +identity, ignored both-selector rules, and user/session-only selector authority +are reflected by the bounded user-file warning helper. Project rules no longer +manufacture a native disable observation; session effective policy is explicit +unknown. + +One R1 follow-up was confirmed: native SkillsConfig decoding is all-or-nothing +per layer. A missing required enabled value or nonabsolute typed path +invalidates the section before selector interpretation, including a relative +path on an otherwise ignored both-selector entry. The initial correction +skipped only the invalid rule and could falsely retain a disable warning from +another rule. Closed in `b39a4e9d` and final `4f4a320b`: these typed-invalid +sections now suppress all bounded selector warnings. The focused composed +regressions retain the exact invalid-section cases. + +Independently reran all `^TestProjectCodex` production provider contracts at the +final frozen SHA: **PASS 0.283s**. The code remains conservatively prepared, +never accepts project activation attestation, and never interprets a partial +settings observation as native effective policy. No production file was edited +by this reviewer and no native client was executed. + +## Remaining gates + +Retain both full independent implementation GO results on the final SHA. Run +hosted current-source native +discovery and cross-platform CI. Complete the saved production CLI composed +project journey after integration. Project MCP initialization, native effective +trust/policy, task completion and release qualification remain unqualified. diff --git a/docs/plans/project-install-e2e/codex-code-review-r2.md b/docs/plans/project-install-e2e/codex-code-review-r2.md new file mode 100644 index 000000000..740e43235 --- /dev/null +++ b/docs/plans/project-install-e2e/codex-code-review-r2.md @@ -0,0 +1,111 @@ +# Codex project adapter independent implementation review R2 + +Reviewer: cursor_clients, 2026-10-23. Reviewed the final adapter checkpoint +`6387ac8509a76b84cd42ef19bcf53ebcefa91dcb` and source-only native discovery gate +`048c34555543534bfc38eecf0a58cb64ff0d5ac5` in `work/uap-project-codex`. +The earlier codec-only review is separate and is not counted as full-adapter +approval. Scope includes production planner/provider composition, selected +project roots, exact native ownership, activation/deactivation, policy-warning +truthfulness, and the new hosted native-test selection and evidence boundary. + +## Initial decision: NO GO pending configured-warning correction + +The adapter's bounded skill restriction warnings do not match pinned Codex +`rust-v0.153.4` semantics. `project_status.go` currently inspects skill rules +from both project and user files and treats any matching disabled selector as +effective. Native `codex-rs/config/src/skills_config.rs` accepts skill rules only +from User and SessionFlags layers and processes ordered selectors, allowing +later matching enables to override earlier disables. Rules with both a name +and a path are ignored. Consequently the adapter can incorrectly report that a +preserved project selector disables a skill, or report a disable that a later +user rule reverses. The author independently confirmed the exact-tag source +and is correcting the narrow configured-warning claim. No project trust, +profile, ancestor, managed or session-policy evaluator should be added. + +## Reviewed boundaries without another blocker + +- Project context binds the selected root to its original detected user + ConfigRoot; native authority is the project root, with independent contained + `.agents/skills` and `.codex/config.toml` paths. Scope/root/user mismatches + refuse before native effects. Prepared packages and persistent data remain + under the project's `.uap` namespace. `CODEX_HOME` remains the original user + profile; project operations do not invoke marketplace/cache mutation. +- Project skills and rendered command skills use the shared exact directory + transaction; MCP uses CodecCodex and the existing nativeconfig kernel. + Object identity, selected path and staged digest validation cover updates, + missing-same-pin repair, drift, unowned collisions and exact removal. Foreign + MCP fields, TOML comments/settings, skills and user cache remain outside + ownership. Unsupported SSE is withheld rather than rewritten as HTTP. +- Active reprojection reads already staged values and verifies the bound + package/data locators without changing digest-covered package bytes. Auth + references retain the existing nonsecret OpenAI projection contract. +- Preflight executes before generic activation-attestation shortcuts and also + on direct adapter activation. File configuration remains `prepared` plus + installed verification with ActivationAttested false. Native trust, + selection/aliases, effective managed/session policy and MCP initialization + remain unobserved. Exact native-file verification is not a native session + discovery claim. +- The source matrix adds the exact anchored project-test name to existing + required Codex tests. Released/draft/prepared evidence inputs refuse the new + selection. Existing immutable client pins, fresh environment and disposable + hosted guard remain intact; skipped or omitted required tests cannot pass. + The workflow is still limited to its existing configured platforms and + triggers; writing this test is not evidence of a successful hosted run. +- The new skills-only native suite uses fresh app-server `skills/list`, exact + repo scope/path/enabled checks, expected rendered command bytes and project-B + absence through add/update/missing-skill repair/removal. It has no thread, + model, trust mutation or configured MCP initialization request. Evidence + explicitly separates those unobserved stages from project discovery. + +## Independent verification + +With Go 1.26.10, GOMAXPROCS=3 and bounded existing caches: + +- Complete `clients/codex`: PASS, 0.140s. +- Complete `adapters/nativeconfig`: PASS, 3.624s. +- Complete `providers`: PASS, 1.888s. +- Complete `planner`: PASS, 0.023s; complete `domain`: PASS, 0.025s. +- `repotests -run 'TestCodexProjectDiscoveredSkills|TestNativeDisposableBoundary|TestNativePlatformEnvironmentIsolation' -count=1`: + PASS, 0.014s. Only inert validators/environment contracts were selected. +- `python scripts/test_native_client_matrix.py`: PASS, 17 tests. + +The task host carries duplicate uppercase/lowercase proxy environment names. +An initial pattern that also selected existing user Codex provider tests +reproduced their existing strict environment refusal. Excluding only lowercase +`http_proxy`, `https_proxy`, `all_proxy`, `no_proxy` for the full inert package +run gave the passing results above; no production/test/golden change was made +for that host condition. No native Codex process, package payload, model call, +MCP command, login or trust action ran during this review. + +## Fix verification and final decision: GO + +Independently rechecked correction +`9d128aa206fd9560a2c3d4d32602e4cf3b17ab59` against the exact-tag +`codex-rs/config/src/skills_config.rs` source snapshot. Only the visible user +file now contributes skill selectors. Ordered matching name/document selectors +honor later enables, names are trimmed, both/no selector combinations are +ignored and existing document aliases are canonicalized. Messages identify +the user settings and leave session/effective enablement unobserved. Project +selectors no longer manufacture a disabled-policy claim. This is bounded +observation, without a second native settings/precedence engine. + +Independently ran all composed `providers -run '^TestProjectCodex' -count=1` +contracts at the fixed checkpoint: PASS, 0.244s. The new cases exercise +disabled canonical document/name, later name/path enable, ignored ambiguous +selectors, directory versus document path, ignored project selector and a +canonical document alias. Existing scope/rollback/attestation contracts remain +selected. No native process was launched. + +The sole implementation blocker is closed. GO for the configured project +component implementation and authored source-only discovery gate. Hosted +runtime qualification remains separate: this review does not establish +native skills discovery, MCP initialization, effective trust/policy, model +use, real task completion or release readiness. + +Final frozen-checkpoint follow-up: reviewed +`4f4a320bc58c5c45e23a75d56c43bc0c2b3da1a6`, including the native all-or-nothing +SkillsConfig section decoding guard. A missing required enabled value or any +nonabsolute typed selector path suppresses warnings from the whole section, +matching the native layer decoder. Independent complete `^TestProjectCodex` +provider selection PASS, 0.298s, including nine selector scenarios plus a +canonical document alias. Final decision remains GO at this checkpoint. diff --git a/docs/plans/project-install-e2e/codex-codec-review-r1.md b/docs/plans/project-install-e2e/codex-codec-review-r1.md new file mode 100644 index 000000000..5a79efe7c --- /dev/null +++ b/docs/plans/project-install-e2e/codex-codec-review-r1.md @@ -0,0 +1,51 @@ +# Codex TOML codec independent implementation review R1 + +Reviewer: client_plans, 2026-10-23. +Reviewed committed codec-only diff `fda36d23755318b5cb95c858e1f38430924294ad` +against its parent in the Codex child worktree. Uncommitted adapter changes were +excluded from review. This is not full Codex adapter or native runtime approval. + +## Decision: GO for the narrow codec checkpoint + +No correctness blocker found in the committed diff. The new codec keeps the +existing ownership/locking/conditional-write kernel and supplies a TOML parser, +retained syntax tree and checked render route. Existing JSON/JSONC codecs keep +their parsers and collection names; Codex-only auth options are rejected by +other codecs instead of silently ignored. + +## Reviewed boundaries + +- TOML is parsed semantically before editing, so duplicate keys/table conflicts + and malformed syntax are refused without mutation. The MCP collection must + be a table. Only MCP values enter the neutral JSON mutation document; unrelated + TOML date/time and other native types remain in their original AST. +- The native edit scanner gathers matching complete key-prefix entries before + removal. It removes the owned server's nested table mappings as well as its + heading, while sibling prefixes remain separate. Inline, dotted, parent-table + and child-table collection forms have concrete lifecycle coverage. +- Each render re-parses the resulting TOML, compares all non-MCP semantic + settings, and compares every desired/unchanged MCP member against the + requested neutral document before committing. An unchanged semantic + selection returns the original bytes rather than reformatting the document. +- MCP whole-entry digest ownership includes all native entry fields, including + foreign `enabled=false`. An unreceipted existing member is a collision; + adding a foreign field makes update/remove fail exact ownership. The generic + kernel's desired-receipt checks, batch paths/codec checks, no-follow reads, + writer locks, conditional replacement and verification still apply. +- Native projection maps headers to `http_headers`, retains explicit nonsecret + Codex authentication references, and rejects unsupported SSE rather than + degrading its transport. Stdio values remain command/args/env/cwd. Auth and + OpenCode-specific options cannot silently cross codec boundaries. +- The new TOML editing dependency is limited to native syntax preservation; + this does not add another installation or settings engine. + +## Independent verification + +Ran the complete `adapters/nativeconfig` package on the committed codec scope, +including all new `TestProjectCodexTOML*` fixtures and the existing ownership, +CAS, locking, malformed/duplicate parsing, rollback and other-codec regressions. +Result: PASS, `nativeconfig` 3.680s (Go 1.26.10). No +native Codex process, model call, MCP execution, login or trust action was run. + +Full adapter scope qualification, project/user/A/B isolation and conservative +readiness remain subject to the separate Codex implementation reviews. diff --git a/docs/plans/project-install-e2e/codex-implementation-evidence.md b/docs/plans/project-install-e2e/codex-implementation-evidence.md new file mode 100644 index 000000000..86179c1e8 --- /dev/null +++ b/docs/plans/project-install-e2e/codex-implementation-evidence.md @@ -0,0 +1,148 @@ +# Codex project component implementation evidence + +Pre-code evidence recorded 2026-10-23. This implements the reviewed single +component lane from `client-plans.md`: project skills and command skills in +`.agents/skills/`, plus exact MCP entries in `.codex/config.toml`. Existing user +marketplace/plugin behavior remains separate and unchanged. + +## Official pinned inputs + +- Codex CLI **0.153.4**, official asset: + https://github.com/openai/codex/releases/download/rust-v0.153.4/codex-x86_64-unknown-linux-musl.tar.gz +- Asset SHA256 `f479424eca092484dc40d87ae28c44f4cc40234a60045d6131e493800d814a30`, + 98,582,872 bytes. Binary SHA256 + `56ef98ab4032d317ab26e9b5e5a175650717351edb16ed9cde0cb6d1734d62da`, + 258,659,424 bytes. +- Parent independently ran this binary's version, top-level help, MCP help and + `mcp list --help` in a networkless namespace with a fresh home. These prove + version/help contracts only, without credentials, trust or model requests. +- Exact-tag native schema: + https://github.com/openai/codex/blob/rust-v0.153.4/codex-rs/core/config.schema.json + SHA256 `692da7699367f6f4fbbd46c0021278c1311440bcebf0bcb9b836690c05e56196`, 200,401 bytes. +- Exact-tag config-layer source: + https://github.com/openai/codex/blob/rust-v0.153.4/codex-rs/config/src/loader/README.md + and `loader/mod.rs`. Skill source is under `codex-rs/ext/skills/src/loader/`. + +## Confirmed native contract + +The pinned schema defines `mcp_servers.` with native `command`, `args`, +`env`, `cwd` for stdio and `url`, `http_headers` for HTTP; it supports `enabled`. +Skills have `[[skills.config]]` disable selectors by absolute `path` or `name`. +The current official docs describe repository `.agents/skills` discovery from +cwd up to repo root and allow same-name skills from separate roots to coexist. +Managed ownership of a file does not establish which skill a session selects. + +The exact config loader applies project layers only after native trust admission; +user profile, user base, system, cloud-managed, session and legacy managed layers +remain separate. Project config walks from the detected root toward cwd. The +implementation does not change `CODEX_HOME`, add trust or duplicate that settings +engine. An MCP entry may inherit/merge user or higher-priority policy fields. + +Current official pages were opened before implementation: + +- https://developers.openai.com/codex/config-file/config-basic +- https://developers.openai.com/codex/config-file/config-advanced +- https://developers.openai.com/codex/mcp +- https://developers.openai.com/codex/build-skills + +## Implementation boundary + +- Use core's validated TargetContext and scoped native-config lifecycle policy; + native root is the selected project, with `.agents/skills` and `.codex/config.toml` + validated independently under it. Original detected user profile stays intact. +- Add the narrowly required `CodecCodex` TOML format to the existing nativeconfig + kernel. Reuse exact receipts, writer locks, no-follow file IO, conditional + replacement, postconditions and rollback. Preserve foreign TOML members and + comments. TOML decoding must reject duplicate/malformed documents before edits. +- Reuse existing loader, rendered command skills, immutable staging and native + skill transaction semantics. Exact project objects govern update, repair, + registry collision checks and removal; project operations never run global + marketplace registration/removal or clean a user plugin cache. +- File-only success remains prepared with installed exact objects. Bounded + configured disabled/shadowing warnings do not claim complete effective-policy + evaluation. Generic activation attestation cannot manufacture project native + discovery. Unknown versions or unavailable native observation remain explicit. +- Composed fixture journeys and genuine pinned process observations are recorded + separately. Hosted native guard remains unchanged; no local model task, trust + mutation, credential creation or package payload execution is authorized by + this evidence record. + + +## Initial implementation checkpoint + +The scoped adapter now selects the exact project root, `.agents/skills` and +`.codex/config.toml` while retaining the original detected user profile. It +consumes the reviewed shared ProjectComponents transaction with a distinct +Codex codec and explicit skill/MCP ownership kinds. The existing OpenAI MCP +projector materializes stdio package/data/cwd values and nonsecret auth +references once; active reprojection verifies the bound data locator without +rewriting digest-covered package bytes. Project operations do not invoke a +user marketplace command or change `CODEX_HOME`. + +Native file success remains prepared plus installed. User/project disabled +selectors and MCP entries are preserved and reported; system/managed/session +precedence and native trust remain unobserved. Existing explicit-only command +withholding policy is retained, while accepted commands use the existing skill +renderer. No global user trait is enabled to manufacture project support. + +Author contract checks on the initial adapter checkpoint: full Codex **PASS +0.161s**, full domain **PASS 0.029s**, full planner **PASS 0.033s**; composed +providers `^TestProjectCodex` **PASS 0.170s**. Those provider contracts include +standard loading/planning/staging, rendered commands, remote and managed stdio +MCP, auth references, exact data-bound active reprojection, add/repeat/missing-pin +repair/update/removal, user/cache/project-B isolation, malformed TOML, foreign +disabled collision, writer failure rollback, context/root refusal and generic +attestation rejection. They execute no package payload, native Codex process, +configured MCP command or model request. Independent full-adapter reviews and +hosted native discovery qualification remain separate gates. + +## Source-only native discovery gate + +The source matrix now accepts an explicit `--project-components` selection for +Codex. It retains the existing disposable-host checks, immutable native artifact +pins, fresh environment, exact required-test accounting and refusal of any skip. +The option is rejected for released, draft and prepared installer inputs; those +historical evidence contracts remain unchanged. The source Codex jobs select +both existing user tests and the project component lifecycle test. + +The new test runs production UAP `add --scope project`, `update`, `repair` and +`remove`. Fresh pinned Codex app-server processes call only `skills/list` and +must return the exact enabled repository skill paths under the selected +`.agents/skills`, with exact rendered command bytes. A command-rule update must +withdraw the old command skill, a deleted owned directory must disappear from +native discovery before repair and return afterwards, and removal must leave no +fixture skill. Separate project-B discovery and exact foreign user/cache/A/B +byte checks fence the scope. UAP outcomes must remain prepared plus installed; +this test does not change the product's activation admission. + +The exact-tag repository-root implementation independently resolves +`.agents/skills` from cwd to the detected project boundary: +https://github.com/openai/codex/blob/rust-v0.153.4/codex-rs/ext/skills/src/host_roots.rs +The native app-server contract is also exercised upstream at: +https://github.com/openai/codex/blob/rust-v0.153.4/codex-rs/app-server/tests/suite/v2/skills_list.rs +The fixture supplies distinct existing project roots and native `.git` markers; +it neither writes trust nor changes `CODEX_HOME` to a project path. + +The discovery gate has been authored but has not yet supplied a hosted runtime +result at this checkpoint. No native process was launched on the task host. +Project MCP initialization, effective trust/policy, model use and real task +completion remain unqualified. The dedicated +`codex-project-components-evidence.json` records those limits and stage outcomes +separately from existing released lifecycle evidence. + +## Independent review correction: visible skill selectors + +Exact-tag native source at +https://github.com/openai/codex/blob/rust-v0.153.4/codex-rs/config/src/skills_config.rs +(blob `cc944d817c1a65c8424e069b5a7cd466646f84bf`) establishes that skill +enablement selectors come from user/session layers, not project layers. Path +selectors identify the canonical skill document; names are trimmed, entries +with both selectors are ignored, and later matching rules may reenable a skill. +The adapter's bounded warnings now honor these semantics for the observed user +file only, including canonical document aliases. They do not interpret project +skill selectors as native restrictions or infer a native session's final policy. +Composed provider regressions cover both override orders, ignored project/both +selectors, document-versus-directory identity and canonical aliases. The whole +project provider selection passed after this correction (0.228s); exact changed +code and full-file size/architecture lint retain the existing gates without new +suppression rules. diff --git a/docs/plans/project-install-e2e/codex-parser-recovery-evidence.md b/docs/plans/project-install-e2e/codex-parser-recovery-evidence.md new file mode 100644 index 000000000..e3c675f1b --- /dev/null +++ b/docs/plans/project-install-e2e/codex-parser-recovery-evidence.md @@ -0,0 +1,83 @@ +# Codex concrete parser reconstruction checkpoint + +Both fresh plan GOs at5500e56f were committed atc50956de before source edits. +This first recovery slice reconstructs the neutral parser seam and concrete +retained TOML backend. Historicald8daad5a tests are not current proof. + +Nativeconfig now declares only CodexDocument/parser and configured-copy seam. +No concrete TOML production import remains. Missing parser refuses before +FileIO/locks, input bytes cross as clones, literal/typed-nil documents refuse +before methods, neutral entries and output bytes are copied. DesiredReceipt +remains pure/parser-free. Concrete codextoml retains TOML AST/foreign semantics, +original no-op bytes, complete nested-entry removal and checked reparse/render. +JSON UseNumber plus exact signed-integer conversion prevents >2^53 rounding. + +The previous TOML lifecycle/refusal tests were moved to the concrete consumer, +not discarded. Their four table forms, foreign semantic/comment preservation, +exact no-op, malformed/collision/edited ownership, nested removal and unsupported +SSE assertions remain. New seam and concrete tests exercise missing parser +before effects, injected I/O/locks, adversarial input mutation, nil/typed-nil +cleanup, raw/output isolation and9007199254740993 preservation. + +All four Codex boundaries explicitly configure the backend on the existing +kernel copy: component mutation, registry/prepared inspection, object verification, +and native-settings observation. User CLI/profile behavior is unchanged. The +kernel still owns validated paths/receipts/CAS/rollback/locks and other codecs. +Guard snapshots/platform sweep and dependency manifests are unchanged. + +At this initial source freeze only formatting/whitespace checks have passed. +Fresh architecture/full codec/preserved Codex/provider tests, lint and two +independent implementation reviews remain pending and must close before root +publishes/integrates this correction. No vendor/native, model, trust or MCP +process ran during source reconstruction. Exact Codex authority/public SDK/CLI +recovery is a separate required reviewed slice, not completed by this extraction. + +## Fresh first execution and concrete fixture correction + +At initial sourcefff4b87f, unchanged TestContractLayerDependencyClosure actually +passed0.021s and complete nativeconfig passed4.013s. The moved backend test +initially failed compilation because mechanical type qualification also +qualified struct field labels/request selectors; both reviewers independently +reported that same concrete blocker. Only labels/selectors were corrected to +their original spelling, retaining qualified RHS types and all assertions. + +The initial full preserved Codex client attempt also failed on duplicate ambient +uppercase/lowercase HTTP_PROXY/HTTPS_PROXY/NO_PROXY variable names. The production +validator was not changed. A new run removed only the conflicting lowercase +ambient entries for that test process, preserving explicit fixture validation. + +After the test-only qualification correction, complete concrete codextoml tests +passed0.053s, full preserved Codex client passed0.181s, and all ProjectCodex +provider contracts passed0.733s. These are new workspace executions with the +verified Go1.26.10 toolchain/caches and one coordinated GOMAXPROCS2/-p2 process. +Failed compile/environment attempts are not passes. Lint and final independent +review closures remain pending; no native/client/model/MCP/trust execution. + +## Fresh lint closure and narrow helper correction + +Pinned golangci-lint2.13.2 initially reported three new concrete-backend import +grouping issues and gocritic's pointer-to-map parameter warning. Those are +retained failures. The helper now returns its decoded map explicitly; its +UseNumber, trailing-input refusal and recursive exact conversion are unchanged. +Only the caller signature and import groups changed. Complete concrete-backend +tests passed again0.073s after this correction. + +The initial lint also emitted cache-write warnings because its default cache was +under read-only /root. Subsequent actual gates explicitly used the writable +workspace GOLANGCI_LINT_CACHE. The exact changed-code gate fromc50956de over +nativeconfig/codextoml/Codex/providers passed0issues. The separate full-file +revive/funlen/gocyclo/gocognit/dupl/depguard gate over those same packages also +passed0issues. No lint baseline, architecture snapshot or dependency policy was +changed. Fresh test results above remain attributed to their actual source +checkpoints; no vendor/native/model/trust/MCP process executed. Two final fresh +independent code-review closures are now requested on this corrected freeze. + +## Two fresh final implementation reviews + +Both independent reviewers issued final GO at5708e8616bf6aea740e293c5d5929d845c884683 +after inspecting the exact narrow corrections and source-attributed actual gate +results. Their fresh reports are committed beside this evidence. This final +checkpoint adds documentation only; root may publish/integrate the approved +architecture extraction and rerun the unchanged assembled contract closure. +C2 authority, public lifecycle and genuine current-source native gates remain +required, and no prior native pass is relabeled. diff --git a/docs/plans/project-install-e2e/codex-parser-recovery-plan.md b/docs/plans/project-install-e2e/codex-parser-recovery-plan.md new file mode 100644 index 000000000..f3ecc9068 --- /dev/null +++ b/docs/plans/project-install-e2e/codex-parser-recovery-plan.md @@ -0,0 +1,76 @@ +# Codex concrete TOML parser recovery — fresh pre-code plan + +The reset erased unpublished finald8daad5a/source296acbc2 and their objects. +Published base0d1319f947c7f4403691b3a60b1193baa0074ee9 still contains concrete +tomledit/go-toml imports inside nativeconfig, which the unchanged contract +dependency-closure guard correctly rejects. Restore the previously reviewed +concrete backend extraction through two fresh independent plan GOs before code, +then fresh meaningful tests/lint and two fresh code GOs. Historical GOs/tests +are context, never evidence for reconstructed bytes. + +## Exact first slice: architecture without losing behavior + +Move retained TOML syntax parsing/editing into concrete adapters/codextoml, +outside the clients/domain/ports contract's nativeconfig closure. Preserve +tomledit/go-toml dependencies for that real consumer and useful legacy tests; +do not remove capability, widen guard snapshots/allowlists/platform sweeps or +change go.mod just to turn the guard green. Keep TOML-specific server projection +and pure desired-receipt/digest handling in the neutral kernel where appropriate. + +Expose only a small nativeconfig CodexDocument interface and parser function: +copy-returned MCP entry map as json.RawMessage plus render-from-current-entry +map. The concrete backend retains original bytes/TOML AST/foreign semantic +settings; nativeconfig owns hujson neutral edits, whole-entry receipt/digest, +FileIO, locks, original snapshots, CAS/rollback and committed cleanup. + +A per-Kernel WithCodexDocumentParser method returns a configured copy retaining +the exact injected FileIO/lock acquirer. No global registration, implicit default +parser or nativeconfig import of the concrete package. CodecCodex Inspect/Apply +refuse a missing parser before reads/locks/effects; ordinary codecs are unchanged. +Pure DesiredReceipt remains parser-free and effect-free. Reject literal/typed +nil documents before native methods, release acquired locks and perform no write. +Clone original input bytes before crossing the parser seam and clone raw entry/ +no-op output bytes so injected parsers cannot mutate retained snapshots. + +The concrete backend preserves existing malformed/duplicate TOML refusals, +inline/dotted/parent/child collection forms, exact original no-op bytes, foreign +settings/comments, complete owned nested-entry removal and all requested native +MCP values. Reparse rendered TOML and compare foreign semantics and desired MCP +entries before returning bytes. Neutral canonical JSON keeps UseNumber; conversion +back to TOML retains exact signed integer values, including above2^53, rather +than silently introducing float rounding. No serializer rewrite for other codecs. + +Supply the concrete parser explicitly on configured copies at all Codex project +mutation, registry/prepared inspection, object verification and native-settings +observation boundaries. Do not replace injected kernels with New() or alter user +Codex CLI/profile/trust/auth/model behavior. Update affected direct fixtures to +configure the concrete backend explicitly; retain every old TOML lifecycle and +refusal assertion at the concrete consumer plus new seam/codec regressions. + +## Fresh proof and incremental publication + +First verify the unchanged exact TestContractLayerDependencyClosure on its actual +internal/archtest package, including its unchanged platform sweep. Run complete +nativeconfig, concrete codextoml and preserved Codex client packages, affected +ProjectCodex provider/public lifecycle contracts, and pinned changed-code plus +full-file size/architecture lint. Meaningful seam tests cover missing parser +before FileIO/locks, injected I/O/lock preservation, adversarial byte mutation, +literal/typed nil with cleanup, raw-entry/no-op copy isolation and large integers. +No vendor/native/model/MCP/trust process executes during local checks. + +Freeze this first source slice for two fresh independent code reviews, commit +reports and send each clean checkpoint immediately to root for feature-branch +publication. Root may integrate this approved extraction to remove the inherited +architecture blocker and rerun the unchanged guard. This is not all-client or +current-native qualification. + +## Retained separate recovery obligations + +The lost final Codex correction also tightened exact client/scope/original-user/ +artifact/managed-layout/direct-delivery/binding/removal authority, physical +non-alias staging before projection, and exact current stdio data/package reuse. +Its public SDK A/B/pin-repair/restart/data and composed TLS Git/ZIP journeys must +be reconstructed through a separate reviewed authority recovery slice after this +architecture slice. Do not silently drop these known obligations or claim that +earlier native passes qualify changed source. Preserve the existing genuine +macOS/Windows evidence at its original source/run until a current-source replay. diff --git a/docs/plans/project-install-e2e/codex-parser-recovery-review-r1.md b/docs/plans/project-install-e2e/codex-parser-recovery-review-r1.md new file mode 100644 index 000000000..638e64a8c --- /dev/null +++ b/docs/plans/project-install-e2e/codex-parser-recovery-review-r1.md @@ -0,0 +1,33 @@ +# Fresh Codex parser recovery plan R1 + +Reviewer finish_sources_vscode_cline, independent of author. **GO** at +5500e56f01f99fa28c9db619b4263b5b8e803dfa before reconstructed source. +Full plan read: concrete TOML AST/edit backend outside neutral contract closure, +no guard/dependency/platform weakening, per-Kernel copy injection preserving +FileIO/locks, missing parser before effects, typed-nil/byte/raw/no-op isolation, +UseNumber/exact large integers and whole-entry/foreign semantics retained. +Explicit four Codex composition boundaries and complete consumer/seam/unchanged +architecture tests are meaningful. Known exact authority recovery remains a +separate required slice; no earlier native pass is relabeled. No blocker, +source edit, Go or native process by this review. Fresh code reviews required. + +## Fresh final reconstructed implementation closure + +Reviewer: finish_sources_vscode_cline. Fresh independent final code GO at +5708e8616bf6aea740e293c5d5929d845c884683. + +The full production extraction review atfff4 stands. Exactfff..5708 closes the +moved-test field/selector qualification blocker without losing assertions. The +explicit decoded-map return retains UseNumber, trailing refusal and recursive +exact-integer conversion; remaining source edits are import grouping. Missing +parser/typed-nil/copy boundaries, preserved I/O/locks and four explicit concrete +composition boundaries remain sound. + +Actual unchanged closure PASS0.021s/full nativeconfig4.013s atfff4; corrected +full backend0.053s/Codex0.181s/ProjectCodex providers0.733s at478e; final helper +backend0.073s and both pinned lint gates0issues close current verification. +Initial compile/proxy/cache/lint failures are honestly retained. No guard, +snapshot, platform sweep or dependency policy was widened. No duplicate reviewer +Go/native execution. This GO qualifies architecture extraction only; exact C2 +authority, public SDK/CLI and current native/assembled qualification remain +separate required gates. diff --git a/docs/plans/project-install-e2e/codex-parser-recovery-review-r2.md b/docs/plans/project-install-e2e/codex-parser-recovery-review-r2.md new file mode 100644 index 000000000..015d94130 --- /dev/null +++ b/docs/plans/project-install-e2e/codex-parser-recovery-review-r2.md @@ -0,0 +1,33 @@ +# Fresh Codex parser recovery plan R2 + +Reviewer finish_grok_integration, independent of author. **GO** at +5500e56f01f99fa28c9db619b4263b5b8e803dfa before reconstructed source. +Full plan preserves TOML behavior/dependencies outside guarded closure, unchanged +guard/platform snapshot, explicit configured-copy seam retaining injected I/O/ +locks, missing parser before effects, nil/typed-nil/byte/raw/no-op isolation, +parser-free desired receipts, AST/foreign/whole-entry semantics and exact large +signed integers. No implicit/global backend or lost capability. Authority/SDK/ +CLI restoration remains explicitly separate. Actual architecture test package, +meaningful seam/backend/Codex/provider/lint gates and two fresh code GOs remain +required. No blocking finding, source edit, Go or native execution by reviewer. + +## Fresh final reconstructed implementation closure + +Reviewer: finish_grok_integration. Fresh independent final code GO at +5708e8616bf6aea740e293c5d5929d845c884683. + +The full production extraction review atfff4 stands. The concrete moved-test +namespace blocker is fixed with all old assertions retained. Explicit mapreturn +does not change exact decoding/refusal semantics. Configured kernel copies +preserve injected I/O/locks; missing parser and literal/typed-nil guards, input/ +raw/no-op copy isolation and pure DesiredReceipt remain coherent. All four +Codex composition boundaries supply the concrete backend explicitly. + +Actual unchanged closure PASS0.021s/full nativeconfig4.013s atfff4; corrected +full backend0.053s/Codex0.181s/ProjectCodex providers0.733s at478e; final helper +backend0.073s and both pinned lint gates0issues close current verification. +Initial compile/proxy/cache/lint failures are honestly retained. No guard, +snapshot, platform sweep or dependency policy was widened. No duplicate reviewer +Go/native execution. This GO qualifies architecture extraction only; exact C2 +authority, public SDK/CLI and current native/assembled qualification remain +separate required gates. diff --git a/install/integrationctl/agentplugins/adapters/codextoml/document.go b/install/integrationctl/agentplugins/adapters/codextoml/document.go new file mode 100644 index 000000000..186461b1d --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/codextoml/document.go @@ -0,0 +1,116 @@ +package codextoml + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "sort" + + "github.com/creachadair/tomledit" + "github.com/pelletier/go-toml/v2" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" +) + +type document struct { + body []byte + ast *tomledit.Document + original map[string]json.RawMessage + settings map[string]any +} + +// Parse supplies the retained concrete TOML backend at an explicit composition boundary. +func Parse(body []byte) (nativeconfig.CodexDocument, error) { + var values map[string]any + if err := toml.Unmarshal(body, &values); err != nil { + return nil, fmt.Errorf("%w: invalid Codex TOML", nativeconfig.ErrMalformed) + } + if values == nil { + values = map[string]any{} + } + servers, exists := values["mcp_servers"] + if !exists { + servers = map[string]any{} + } + collection, ok := servers.(map[string]any) + if !ok { + return nil, fmt.Errorf("%w: mcp_servers must be a table", nativeconfig.ErrMalformed) + } + entries := make(map[string]json.RawMessage, len(collection)) + for name, value := range collection { + entry, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("%w: invalid Codex MCP entry values", nativeconfig.ErrMalformed) + } + entries[name] = entry + } + ast, err := tomledit.Parse(bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("%w: invalid Codex TOML structure", nativeconfig.ErrMalformed) + } + delete(values, "mcp_servers") + return &document{body: bytes.Clone(body), ast: ast, original: entries, settings: values}, nil +} + +func (doc *document) MCPEntries() (map[string]json.RawMessage, error) { + return cloneEntries(doc.original), nil +} + +func (doc *document) RenderMCPEntries(current map[string]json.RawMessage) ([]byte, error) { + current = cloneEntries(current) + names := changedNames(doc.original, current) + if len(names) == 0 { + return bytes.Clone(doc.body), nil + } + for _, name := range names { + removeCodexTOMLEntry(doc.ast, name) + if body, exists := current[name]; exists { + if err := setCodexTOMLEntry(doc.ast, name, body); err != nil { + return nil, err + } + } + } + var result bytes.Buffer + if err := tomledit.Format(&result, doc.ast); err != nil { + return nil, fmt.Errorf("render Codex native config: %w", err) + } + parsed, err := Parse(result.Bytes()) + if err != nil { + return nil, err + } + observed := parsed.(*document) + if !reflect.DeepEqual(doc.settings, observed.settings) { + return nil, fmt.Errorf("rendered Codex config changed foreign settings") + } + if len(changedNames(current, observed.original)) != 0 { + return nil, fmt.Errorf("rendered Codex MCP entries differ from requested entries") + } + return bytes.Clone(result.Bytes()), nil +} + +func cloneEntries(entries map[string]json.RawMessage) map[string]json.RawMessage { + result := make(map[string]json.RawMessage, len(entries)) + for name, body := range entries { + result[name] = bytes.Clone(body) + } + return result +} + +func changedNames(previous, next map[string]json.RawMessage) []string { + changed := map[string]bool{} + for name, body := range previous { + changed[name] = !bytes.Equal(body, next[name]) + } + for name, body := range next { + changed[name] = !bytes.Equal(body, previous[name]) + } + result := make([]string, 0, len(changed)) + for name, differs := range changed { + if differs { + result = append(result, name) + } + } + sort.Strings(result) + return result +} diff --git a/install/integrationctl/agentplugins/adapters/codextoml/edit.go b/install/integrationctl/agentplugins/adapters/codextoml/edit.go new file mode 100644 index 000000000..de34b55f8 --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/codextoml/edit.go @@ -0,0 +1,67 @@ +package codextoml + +import ( + "bytes" + "fmt" + + "github.com/creachadair/tomledit" + "github.com/creachadair/tomledit/parser" + "github.com/creachadair/tomledit/transform" + "github.com/pelletier/go-toml/v2" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" +) + +func removeCodexTOMLEntry(doc *tomledit.Document, name string) { + var remove []*tomledit.Entry + prefix := parser.Key{"mcp_servers", name} + doc.Scan(func(key parser.Key, entry *tomledit.Entry) bool { + if prefix.IsPrefixOf(key) { + remove = append(remove, entry) + } + return true + }) + for _, entry := range remove { + entry.Remove() + } +} + +func setCodexTOMLEntry(doc *tomledit.Document, name string, body []byte) error { + value, err := decodeTOMLJSON(body) + if err != nil || value == nil { + return fmt.Errorf("%w: Codex MCP entry must be a table", nativeconfig.ErrMalformed) + } + var encoded bytes.Buffer + if err := toml.NewEncoder(&encoded).SetTablesInline(true).Encode(map[string]any{name: value}); err != nil { + return fmt.Errorf("encode Codex MCP entry: %w", err) + } + parsed, err := tomledit.Parse(&encoded) + if err != nil { + return fmt.Errorf("parse projected Codex MCP entry: %w", err) + } + entry := parsed.First(name) + if entry == nil || !entry.IsMapping() { + return fmt.Errorf("projected Codex MCP entry is not a mapping") + } + parent := doc.First("mcp_servers") + if parent != nil && parent.IsMapping() { + if inline, ok := parent.Value.X.(parser.Inline); ok { + inline = append(inline, entry.KeyValue) + parent.Value.X = inline + return nil + } + return fmt.Errorf("%w: Codex MCP collection is not a table", nativeconfig.ErrMalformed) + } + if parent != nil && parent.IsSection() { + transform.InsertMapping(parent.Section, entry.KeyValue, false) + return nil + } + // An implicit parent may come from dotted keys or child tables. Adding a + // dotted global member handles both without redefining an existing table. + if doc.Global == nil { + doc.Global = &tomledit.Section{} + } + entry.Name = parser.Key{"mcp_servers", name} + transform.InsertMapping(doc.Global, entry.KeyValue, false) + return nil +} diff --git a/install/integrationctl/agentplugins/adapters/codextoml/isolation_test.go b/install/integrationctl/agentplugins/adapters/codextoml/isolation_test.go new file mode 100644 index 000000000..5d671d737 --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/codextoml/isolation_test.go @@ -0,0 +1,55 @@ +package codextoml + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/pelletier/go-toml/v2" +) + +func TestProjectCodexConcreteCopiesAndLargeIntegers(t *testing.T) { + const original = "# foreign policy\n[mcp_servers.foreign]\nurl='https://inert.test/mcp'\nlarge=9007199254740993\n" + input := []byte(original) + doc, err := Parse(input) + if err != nil { + t.Fatal(err) + } + input[0] = '!' + entries, err := doc.MCPEntries() + if err != nil { + t.Fatal(err) + } + entries["foreign"][0] = '!' + independent, err := doc.MCPEntries() + if err != nil || !json.Valid(independent["foreign"]) { + t.Fatal("concrete entries alias retained document", err) + } + noop, err := doc.RenderMCPEntries(independent) + if err != nil || string(noop) != original { + t.Fatalf("no-op bytes changed: %q %v", noop, err) + } + noop[0] = '!' + again, err := doc.RenderMCPEntries(independent) + if err != nil || string(again) != original { + t.Fatal("no-op output aliases retained document", err) + } + independent["managed"] = json.RawMessage(`{"command":"TEST-not-executed","large":9007199254740993}`) + body, err := doc.RenderMCPEntries(independent) + if err != nil { + t.Fatal(err) + } + var native map[string]any + if err := toml.Unmarshal(body, &native); err != nil { + t.Fatal(err) + } + servers := native["mcp_servers"].(map[string]any) + for _, name := range []string{"foreign", "managed"} { + if value := servers[name].(map[string]any)["large"]; value != int64(9007199254740993) { + t.Fatalf("integer changed for %s: %v", name, value) + } + } + if !bytes.Contains(body, []byte("# foreign policy")) { + t.Fatal("foreign comment lost") + } +} diff --git a/install/integrationctl/agentplugins/adapters/codextoml/json.go b/install/integrationctl/agentplugins/adapters/codextoml/json.go new file mode 100644 index 000000000..b8399ad65 --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/codextoml/json.go @@ -0,0 +1,55 @@ +package codextoml + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" +) + +func decodeTOMLJSON(body []byte) (map[string]any, error) { + var result map[string]any + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + if err := decoder.Decode(&result); err != nil { + return nil, err + } + if _, err := decoder.Token(); err != io.EOF { + return nil, fmt.Errorf("codex MCP entry has trailing JSON") + } + converted, err := tomlJSONValue(result) + if err != nil { + return nil, err + } + result, _ = converted.(map[string]any) + return result, nil +} + +func tomlJSONValue(value any) (any, error) { + switch typed := value.(type) { + case json.Number: + if strings.ContainsAny(string(typed), ".eE") { + return strconv.ParseFloat(string(typed), 64) + } + return strconv.ParseInt(string(typed), 10, 64) + case map[string]any: + for key, child := range typed { + converted, err := tomlJSONValue(child) + if err != nil { + return nil, err + } + typed[key] = converted + } + case []any: + for i, child := range typed { + converted, err := tomlJSONValue(child) + if err != nil { + return nil, err + } + typed[i] = converted + } + } + return value, nil +} diff --git a/install/integrationctl/agentplugins/adapters/codextoml/lifecycle_test.go b/install/integrationctl/agentplugins/adapters/codextoml/lifecycle_test.go new file mode 100644 index 000000000..f81e66054 --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/codextoml/lifecycle_test.go @@ -0,0 +1,156 @@ +package codextoml + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/pelletier/go-toml/v2" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" +) + +func TestProjectCodexTOMLExactLifecyclePreservesForeignData(t *testing.T) { + for name, collection := range map[string]string{ + "inline": "mcp_servers = {foreign = {url = 'https://foreign.test/mcp'}} # inline policy\n", + "dotted": "mcp_servers.foreign.url = 'https://foreign.test/mcp' # dotted policy\n", + "parent": "[mcp_servers]\nforeign = {url = 'https://foreign.test/mcp'} # parent policy\n", + "child": "[mcp_servers.foreign]\nurl = 'https://foreign.test/mcp' # child policy\n", + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + before := "# preserve native settings\ncreated = 2026-10-23\n" + collection + "\n[foreign]\nkeep = [1, 2, 3] # foreign comment\n" + mustWrite(t, path, before) + kernel := newKernel() + request := nativeconfig.Request{Paths: nativeconfig.Paths{JSON: path}, Codec: nativeconfig.CodecCodex, Action: nativeconfig.ActionAdd, Name: "managed", Server: nativeconfig.Server{Type: "remote", URL: "https://managed.test/mcp", RemoteTransport: "streamable-http", Headers: map[string]string{"X-Fixture": "inert"}}} + receipt, err := kernel.Apply(request) + if err != nil { + t.Fatal(err) + } + present, owned, err := kernel.Inspect(request.Paths, request.Codec, request.Name, &receipt) + if err != nil || !present || !owned { + t.Fatalf("exact Codex entry = %v/%v, %v", present, owned, err) + } + projectCodexAssertForeignTOML(t, path, before) + request.Action, request.Owned = nativeconfig.ActionUpdate, &receipt + request.Server = nativeconfig.Server{Type: "stdio", Command: "TEST-never-executed", Args: []string{"fixture"}, Env: map[string]string{"FIXTURE": "inert"}, CWD: filepath.Dir(path)} + receipt, err = kernel.Apply(request) + if err != nil { + t.Fatal(err) + } + request.Owned = &receipt + unchanged, _ := os.ReadFile(path) + if _, err := kernel.Apply(request); err != nil { + t.Fatal(err) + } + assertBytes(t, path, string(unchanged)) + request.Action = nativeconfig.ActionRemove + if _, err := kernel.Apply(request); err != nil { + t.Fatal(err) + } + projectCodexAssertForeignTOML(t, path, before) + if present, _, err := kernel.Inspect(request.Paths, request.Codec, request.Name, nil); err != nil || present { + t.Fatalf("managed Codex entry survived: %v, %v", present, err) + } + }) + } +} + +func projectCodexAssertForeignTOML(t *testing.T, path, before string) { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var original, actual map[string]any + if err := toml.Unmarshal([]byte(before), &original); err != nil { + t.Fatal(err) + } + if err := toml.Unmarshal(body, &actual); err != nil { + t.Fatal(err) + } + delete(actual["mcp_servers"].(map[string]any), "managed") + if !reflect.DeepEqual(original, actual) { + t.Fatalf("foreign TOML data changed: %+v", actual) + } + for _, comment := range []string{"preserve native settings", "policy", "foreign comment"} { + if !strings.Contains(string(body), comment) { + t.Fatalf("foreign TOML comment %q lost", comment) + } + } +} + +func TestProjectCodexTOMLRejectsMalformedCollisionAndForeignEdit(t *testing.T) { + for name, body := range map[string]string{ + "duplicate": "[mcp_servers]\nx={command='one'}\nx={command='two'}\n", + "malformed": "[mcp_servers\n", + "scalar": "mcp_servers = false\n", + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + mustWrite(t, path, body) + request := nativeconfig.Request{Paths: nativeconfig.Paths{JSON: path}, Codec: nativeconfig.CodecCodex, Action: nativeconfig.ActionAdd, Name: "managed", Server: nativeconfig.Server{Type: "remote", URL: "https://managed.test/mcp"}} + if _, err := newKernel().Apply(request); !errors.Is(err, nativeconfig.ErrMalformed) { + t.Fatalf("invalid TOML admitted: %v", err) + } + assertBytes(t, path, body) + }) + } + path := filepath.Join(t.TempDir(), "config.toml") + request := nativeconfig.Request{Paths: nativeconfig.Paths{JSON: path}, Codec: nativeconfig.CodecCodex, Action: nativeconfig.ActionAdd, Name: "managed", Server: nativeconfig.Server{Type: "remote", URL: "https://managed.test/mcp"}} + receipt, err := newKernel().Apply(request) + if err != nil { + t.Fatal(err) + } + if _, err := newKernel().Apply(request); !errors.Is(err, nativeconfig.ErrCollision) { + t.Fatalf("foreign entry was adopted: %v", err) + } + const edited = "[mcp_servers.managed]\nurl='https://managed.test/mcp'\nenabled=false # foreign policy\n" + mustWrite(t, path, edited) + for _, action := range []nativeconfig.Action{nativeconfig.ActionUpdate, nativeconfig.ActionRemove} { + request.Action, request.Owned = action, &receipt + if _, err := newKernel().Apply(request); !errors.Is(err, nativeconfig.ErrNotOwned) { + t.Fatalf("edited native entry accepted by %s: %v", action, err) + } + assertBytes(t, path, edited) + } +} + +func TestProjectCodexTOMLRemovesCompleteNestedOwnedEntry(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + const body = "[mcp_servers.managed]\ncommand='TEST-never-executed'\n[mcp_servers.managed.env]\nFIXTURE='inert'\n[mcp_servers.foreign]\nurl='https://foreign.test/keep' # keep\n" + mustWrite(t, path, body) + server := nativeconfig.Server{Type: "stdio", Command: "TEST-never-executed", Env: map[string]string{"FIXTURE": "inert"}} + receipt, err := nativeconfig.DesiredReceipt(path, nativeconfig.CodecCodex, "managed", server, nativeconfig.Placeholders{}) + if err != nil { + t.Fatal(err) + } + if _, err := newKernel().Apply(nativeconfig.Request{Paths: nativeconfig.Paths{JSON: path}, Codec: nativeconfig.CodecCodex, Action: nativeconfig.ActionRemove, Name: "managed", Owned: &receipt}); err != nil { + t.Fatal(err) + } + actual, _ := os.ReadFile(path) + if strings.Contains(string(actual), "managed") || !strings.Contains(string(actual), "# keep") { + t.Fatalf("nested native removal changed foreign entry or left owned child: %s", actual) + } + if _, err := nativeconfig.DesiredReceipt(path, nativeconfig.CodecCodex, "sse", nativeconfig.Server{Type: "remote", URL: "https://managed.test/mcp", RemoteTransport: "sse"}, nativeconfig.Placeholders{}); err == nil { + t.Fatal("Codex silently accepted unsupported SSE transport") + } +} + +func newKernel() nativeconfig.Kernel { return nativeconfig.New().WithCodexDocumentParser(Parse) } +func mustWrite(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } +} +func assertBytes(t *testing.T, path, expected string) { + t.Helper() + body, err := os.ReadFile(path) + if err != nil || string(body) != expected { + t.Fatalf("bytes=%q, error=%v, expected=%q", body, err, expected) + } +} diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/codex_document.go b/install/integrationctl/agentplugins/adapters/nativeconfig/codex_document.go new file mode 100644 index 000000000..f9c003dcc --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/codex_document.go @@ -0,0 +1,112 @@ +package nativeconfig + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + + "github.com/tailscale/hujson" +) + +// CodexDocument retains concrete syntax outside the contract dependency closure. +// Implementations return independent entry/output byte slices. +type CodexDocument interface { + MCPEntries() (map[string]json.RawMessage, error) + RenderMCPEntries(map[string]json.RawMessage) ([]byte, error) +} + +type CodexDocumentParser func([]byte) (CodexDocument, error) + +// WithCodexDocumentParser retains this Kernel's exact FileIO and lock authority. +func (kernel Kernel) WithCodexDocumentParser(parser CodexDocumentParser) Kernel { + kernel.codexParser = parser + return kernel +} + +func (kernel Kernel) requireDocumentParser(codec Codec) error { + if codec == CodecCodex && kernel.codexParser == nil { + return fmt.Errorf("codex TOML document parser is required") + } + return nil +} + +func (kernel Kernel) parseDocument(body []byte, jsonc bool, codec Codec) (*document, error) { + if codec != CodecCodex { + return parseCodecDocument(body, jsonc, codec) + } + if err := kernel.requireDocumentParser(codec); err != nil { + return nil, err + } + if jsonc { + return nil, fmt.Errorf("%w: Codex requires one TOML config path", ErrMalformed) + } + native, err := kernel.codexParser(bytes.Clone(body)) + if err != nil { + return nil, err + } + if nilCodexDocument(native) { + return nil, fmt.Errorf("codex TOML parser returned no document") + } + entries, err := native.MCPEntries() + if err != nil { + return nil, err + } + neutral, err := json.Marshal(map[string]any{"mcp_servers": cloneCodexEntries(entries)}) + if err != nil { + return nil, err + } + doc, err := parseDocument(neutral, false) + if err != nil { + return nil, err + } + doc.codexDocument = native + return doc, nil +} + +func nilCodexDocument(value CodexDocument) bool { + if value == nil { + return true + } + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return v.IsNil() + default: + return false + } +} + +func cloneCodexEntries(entries map[string]json.RawMessage) map[string]json.RawMessage { + result := make(map[string]json.RawMessage, len(entries)) + for name, body := range entries { + result[name] = bytes.Clone(body) + } + return result +} + +func (doc *document) codexEntries() (map[string]json.RawMessage, error) { + entries, err := codecCollection(doc, CodecCodex, false) + if err != nil || entries == nil { + return nil, err + } + result := make(map[string]json.RawMessage, len(entries.Members)) + for i := range entries.Members { + member := &entries.Members[i] + body, err := entryCanonical(member) + if err != nil { + return nil, err + } + result[member.Name.Value.(hujson.Literal).String()] = bytes.Clone(body) + } + return result, nil +} + +func (doc *document) renderCodexDocument() ([]byte, error) { + entries, err := doc.codexEntries() + if err != nil { + return nil, err + } + body, err := doc.codexDocument.RenderMCPEntries(cloneCodexEntries(entries)) + return bytes.Clone(body), err +} diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/codex_document_test.go b/install/integrationctl/agentplugins/adapters/nativeconfig/codex_document_test.go new file mode 100644 index 000000000..101b9604b --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/codex_document_test.go @@ -0,0 +1,116 @@ +package nativeconfig + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +type codexParserTestIO struct { + body []byte + reads, writes int +} + +func (files *codexParserTestIO) ReadNoFollow(string) ([]byte, os.FileMode, bool, error) { + files.reads++ + return files.body, 0600, true, nil +} +func (files *codexParserTestIO) WriteAtomic(_ string, body []byte, _ os.FileMode) error { + files.writes++ + files.body = bytes.Clone(body) + return nil +} +func (*codexParserTestIO) RemoveNoFollow(string) error { return nil } + +type codexParserTestDocument struct{ entries map[string]json.RawMessage } + +func (doc *codexParserTestDocument) MCPEntries() (map[string]json.RawMessage, error) { + return doc.entries, nil +} +func (*codexParserTestDocument) RenderMCPEntries(entries map[string]json.RawMessage) ([]byte, error) { + return json.Marshal(entries) +} + +func codexParserRequest(t *testing.T) Request { + t.Helper() + return Request{Paths: Paths{JSON: filepath.Join(t.TempDir(), "config.toml")}, Codec: CodecCodex, + Action: ActionAdd, Name: "managed", Server: Server{Type: "remote", URL: "https://inert.test/mcp"}} +} + +func TestProjectCodexMissingParserRefusesBeforeIOOrLocks(t *testing.T) { + files := &codexParserTestIO{body: []byte("original")} + locks := 0 + kernel := NewWithFileIO(files) + kernel.acquireLocks = func(Paths, Codec) (func() error, error) { locks++; return func() error { return nil }, nil } + request := codexParserRequest(t) + if _, err := kernel.Apply(request); err == nil { + t.Fatal("missing parser admitted mutation") + } + if _, _, err := kernel.Inspect(request.Paths, request.Codec, request.Name, nil); err == nil { + t.Fatal("missing parser admitted inspection") + } + if _, err := DesiredReceipt(request.Paths.JSON, request.Codec, request.Name, request.Server, Placeholders{}); err != nil { + t.Fatal("pure receipt gained parser requirement", err) + } + if files.reads != 0 || files.writes != 0 || locks != 0 { + t.Fatalf("missing parser effects reads=%d writes=%d locks=%d", files.reads, files.writes, locks) + } +} + +func TestProjectCodexParserCopyPreservesInjectedAuthority(t *testing.T) { + files := &codexParserTestIO{body: []byte("original")} + locks, releases := 0, 0 + original := NewWithFileIO(files) + original.acquireLocks = func(Paths, Codec) (func() error, error) { locks++; return func() error { releases++; return nil }, nil } + kernel := original.WithCodexDocumentParser(func([]byte) (CodexDocument, error) { + return &codexParserTestDocument{entries: map[string]json.RawMessage{}}, nil + }) + if _, err := kernel.Apply(codexParserRequest(t)); err != nil { + t.Fatal(err) + } + if original.codexParser != nil || kernel.files != files || files.reads == 0 || files.writes != 1 || locks != 1 || releases != 1 { + t.Fatalf("configured copy lost authority reads=%d writes=%d locks=%d releases=%d", files.reads, files.writes, locks, releases) + } +} + +func TestProjectCodexNilDocumentsCannotMutateSnapshotsOrLeakLocks(t *testing.T) { + for _, typed := range []bool{false, true} { + t.Run(map[bool]string{false: "literal", true: "typed"}[typed], func(t *testing.T) { + files := &codexParserTestIO{body: []byte("original snapshot")} + locks, releases := 0, 0 + kernel := NewWithFileIO(files) + kernel.acquireLocks = func(Paths, Codec) (func() error, error) { locks++; return func() error { releases++; return nil }, nil } + kernel = kernel.WithCodexDocumentParser(func(body []byte) (CodexDocument, error) { + body[0] = '!' + if typed { + var doc *codexParserTestDocument + return doc, nil + } + return nil, nil + }) + if _, err := kernel.Apply(codexParserRequest(t)); err == nil { + t.Fatal("nil document admitted") + } + if string(files.body) != "original snapshot" || files.writes != 0 || locks != 1 || releases != 1 { + t.Fatalf("parser aliased snapshot or leaked lock: body=%q writes=%d lock=%d release=%d", files.body, files.writes, locks, releases) + } + }) + } +} + +func TestProjectCodexParserRawEntriesDoNotAliasNeutralDocument(t *testing.T) { + raw := json.RawMessage(`{"url":"https://inert.test/mcp","large":9007199254740993}`) + parser := &codexParserTestDocument{entries: map[string]json.RawMessage{"foreign": raw}} + kernel := New().WithCodexDocumentParser(func([]byte) (CodexDocument, error) { return parser, nil }) + doc, err := kernel.parseDocument(nil, false, CodecCodex) + if err != nil { + t.Fatal(err) + } + raw[0] = '!' + entries, err := doc.codexEntries() + if err != nil || !bytes.Contains(entries["foreign"], []byte("9007199254740993")) || !json.Valid(entries["foreign"]) { + t.Fatalf("raw parser alias/number loss: %q %v", entries["foreign"], err) + } +} diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/codex_server.go b/install/integrationctl/agentplugins/adapters/nativeconfig/codex_server.go new file mode 100644 index 000000000..2419ab390 --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/codex_server.go @@ -0,0 +1,35 @@ +package nativeconfig + +import ( + "fmt" + "strings" +) + +func projectCodexServer(server Server, placeholders Placeholders) (map[string]any, error) { + if server.OpenCodeV2 != nil { + return nil, fmt.Errorf("codex does not accept OpenCode V2 options") + } + if strings.EqualFold(strings.TrimSpace(server.Type), "remote") && server.RemoteTransport == "streamable-http" { + server.RemoteTransport = "" + } + projected, err := projectBaseServer(CodecMCPServers, server, placeholders) + if err != nil { + return nil, err + } + if headers, ok := projected["headers"]; ok { + delete(projected, "headers") + projected["http_headers"] = headers + } + if server.CodexAuth != nil { + if server.Type != "remote" { + return nil, fmt.Errorf("codex stdio servers do not accept remote authentication options") + } + if server.CodexAuth.BearerTokenEnvVar != "" { + projected["bearer_token_env_var"] = server.CodexAuth.BearerTokenEnvVar + } + if server.CodexAuth.OAuthResource != "" { + projected["oauth_resource"] = server.CodexAuth.OAuthResource + } + } + return projected, nil +} diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/document.go b/install/integrationctl/agentplugins/adapters/nativeconfig/document.go index 4f84a1f4f..895719597 100644 --- a/install/integrationctl/agentplugins/adapters/nativeconfig/document.go +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/document.go @@ -12,10 +12,11 @@ import ( ) type document struct { - ast hujson.Value - root *hujson.Object - jsonc bool - commentsOnly bool + ast hujson.Value + root *hujson.Object + jsonc bool + commentsOnly bool + codexDocument CodexDocument } func parseDocument(body []byte, jsonc bool) (*document, error) { @@ -36,8 +37,11 @@ func parseDocument(body []byte, jsonc bool) (*document, error) { return &document{ast: ast, root: root, jsonc: jsonc}, nil } -func newDocument(jsonc bool, codec Codec) (*document, error) { - return parseCodecDocument([]byte("{}\n"), jsonc, codec) +func (kernel Kernel) newDocument(jsonc bool, codec Codec) (*document, error) { + if codec == CodecCodex { + return kernel.parseDocument(nil, jsonc, codec) + } + return kernel.parseDocument([]byte("{}\n"), jsonc, codec) } func rejectDuplicateKeys(value *hujson.Value, path string) error { @@ -149,6 +153,9 @@ func removeEntry(obj *hujson.Object, name string) bool { } func (doc *document) render() ([]byte, error) { + if doc.codexDocument != nil { + return doc.renderCodexDocument() + } if doc.commentsOnly { geminiRemoveTrailingCommas(&doc.ast) } diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/gemini_document.go b/install/integrationctl/agentplugins/adapters/nativeconfig/gemini_document.go index 51c3da548..36d8d4de6 100644 --- a/install/integrationctl/agentplugins/adapters/nativeconfig/gemini_document.go +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/gemini_document.go @@ -10,6 +10,9 @@ import ( // 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 == CodecCodex { + return nil, fmt.Errorf("codex TOML document parser is required") + } if codec != CodecGemini || jsonc { return parseDocument(body, jsonc) } diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/kernel.go b/install/integrationctl/agentplugins/adapters/nativeconfig/kernel.go index 849234f93..9c585f511 100644 --- a/install/integrationctl/agentplugins/adapters/nativeconfig/kernel.go +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/kernel.go @@ -60,6 +60,9 @@ func (kernel Kernel) ApplyBatch(requests []Request) (receipts []Receipt, err err return nil, fmt.Errorf("batched native config requests must share paths and codec") } } + if err := kernel.requireDocumentParser(requests[0].Codec); err != nil { + return nil, err + } acquireLocks := kernel.acquireLocks if acquireLocks == nil { acquireLocks = kernel.acquireCandidateLocks @@ -92,9 +95,11 @@ func (kernel Kernel) ApplyBatch(requests []Request) (receipts []Receipt, err err if err != nil { return nil, err } - doc, err := newDocument(file.jsonc, requests[0].Codec) + var doc *document if file.exists { - doc, err = parseCodecDocument(file.body, file.jsonc, requests[0].Codec) + doc, err = kernel.parseDocument(file.body, file.jsonc, requests[0].Codec) + } else { + doc, err = kernel.newDocument(file.jsonc, requests[0].Codec) } if err != nil { return nil, err @@ -247,11 +252,14 @@ func (kernel Kernel) Inspect(paths Paths, codec Codec, name string, owned *Recei if err := validateRequest(probe); err != nil { return false, false, err } + if err := kernel.requireDocumentParser(codec); err != nil { + return false, false, err + } file, err := kernel.resolve(paths) if err != nil || !file.exists { return false, false, err } - doc, err := parseCodecDocument(file.body, file.jsonc, codec) + doc, err := kernel.parseDocument(file.body, file.jsonc, codec) if err != nil { return false, false, err } diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/project.go b/install/integrationctl/agentplugins/adapters/nativeconfig/project.go index b19769f80..5567bfda1 100644 --- a/install/integrationctl/agentplugins/adapters/nativeconfig/project.go +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/project.go @@ -37,12 +37,18 @@ func DesiredReceipt(resolvedPath string, codec Codec, name string, server Server } func projectServer(codec Codec, server Server, placeholders Placeholders) (map[string]any, error) { + if codec != CodecCodex && server.CodexAuth != nil { + return nil, fmt.Errorf("%s does not accept Codex authentication options", codec) + } if !supportedCodec(codec) { return nil, fmt.Errorf("unsupported native config codec %q", codec) } if codec == CodecOpenCodeV2 { return projectOpenCodeV2Server(server, placeholders) } + if codec == CodecCodex { + return projectCodexServer(server, placeholders) + } if server.OpenCodeV2 != nil { return nil, fmt.Errorf("%s does not accept OpenCode V2 options", codec) } diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/types.go b/install/integrationctl/agentplugins/adapters/nativeconfig/types.go index 52a07375a..3651fca87 100644 --- a/install/integrationctl/agentplugins/adapters/nativeconfig/types.go +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/types.go @@ -18,6 +18,7 @@ const ( // Clients with different native transport keys require their own codec. CodecMCPServers Codec = "mcpServers" CodecGemini Codec = "gemini-mcpServers" + CodecCodex Codec = "codex-mcp-servers" CodecOpenCode Codec = "opencode-mcp" CodecOpenCodeV2 Codec = "opencode-v2-mcp" CodecWindsurf Codec = "windsurf-mcpServers" @@ -84,6 +85,13 @@ type Server struct { // OpenCodeV2 carries only the pinned V2 disabled/timeout contract. Other // codecs reject it rather than silently discard dialect-specific intent. OpenCodeV2 *OpenCodeV2Options `json:"opencode_v2,omitempty"` + // CodexAuth contains only nonsecret references from compatibility hints. + CodexAuth *CodexAuthOptions `json:"codex_auth,omitempty"` +} + +type CodexAuthOptions struct { + BearerTokenEnvVar string `json:"bearer_token_env_var,omitempty"` + OAuthResource string `json:"oauth_resource,omitempty"` } // OpenCodeV2Options is a closed subset of the pinned native MCP entry schema. @@ -167,6 +175,7 @@ type lockAcquirer func(Paths, Codec) (func() error, error) type Kernel struct { files FileIO acquireLocks lockAcquirer + codexParser CodexDocumentParser } func New() Kernel { return Kernel{files: conditionalOSFiles{}} } @@ -178,6 +187,9 @@ func NewWithLockAcquirer(acquireLocks func(Paths, Codec) (func() error, error)) } func validateRequest(req Request) error { + if req.Codec == CodecCodex && req.Paths.JSONC != "" { + return fmt.Errorf("codex requires one TOML config path") + } if err := validateExactPath(req.Paths.JSON, "JSON native config path"); err != nil { return err } @@ -208,10 +220,13 @@ func validateRequest(req Request) error { } func supportedCodec(codec Codec) bool { - return codec == CodecMCPServers || codec == CodecGemini || codec == CodecOpenCode || codec == CodecOpenCodeV2 || codec == CodecWindsurf || codec == CodecCline + return codec == CodecMCPServers || codec == CodecGemini || codec == CodecCodex || codec == CodecOpenCode || codec == CodecOpenCodeV2 || codec == CodecWindsurf || codec == CodecCline } func codecCollectionKey(codec Codec) string { + if codec == CodecCodex { + return "mcp_servers" + } if codec == CodecOpenCode || codec == CodecOpenCodeV2 { return "mcp" } diff --git a/install/integrationctl/agentplugins/clients/codex/identity.go b/install/integrationctl/agentplugins/clients/codex/identity.go index f42aab7c3..1d9cb222f 100644 --- a/install/integrationctl/agentplugins/clients/codex/identity.go +++ b/install/integrationctl/agentplugins/clients/codex/identity.go @@ -27,6 +27,9 @@ func (*Adapter) UsesNativeRegistryExecutable() bool { return true } // by its CLI; scanning every sibling directory during a dry-run would turn an // unrelated fixture or installation into a false indeterminate result. func (*Adapter) InspectPreparedRegistry(plan domain.DeliveryPlan, name string, owned bool) (clients.RegistryFinding, error) { + if plan.Scope == domain.ScopeProject { + return inspectPreparedCodexProject(plan, owned) + } if strings.TrimSpace(plan.ActivePath) == "" { return clients.RegistryClear, nil } @@ -82,6 +85,12 @@ func (*Adapter) InspectNativeRegistry(ctx context.Context, env clients.Env, clie if err := ctx.Err(); err != nil { return clients.RegistryIndeterminate, err } + if plan.Scope == domain.ScopeProject || plan.TargetContext.Scope == domain.ScopeProject { + if _, err := codexProjectPlanRoot(client, plan); err != nil { + return clients.RegistryIndeterminate, err + } + return inspectCodexProject(plan, managed, env.NativeConfig) + } profileRoot := client.ConfigRoot if profileRoot == "" { profileRoot = plan.NativeRegistryRoot diff --git a/install/integrationctl/agentplugins/clients/codex/lifecycle.go b/install/integrationctl/agentplugins/clients/codex/lifecycle.go index 39c72fa7b..7afd88112 100644 --- a/install/integrationctl/agentplugins/clients/codex/lifecycle.go +++ b/install/integrationctl/agentplugins/clients/codex/lifecycle.go @@ -21,11 +21,17 @@ var ( // AutomaticallyActivates reports that Activate will drive the Codex CLI. func (*Adapter) AutomaticallyActivates(env clients.Env, request domain.ActivationRequest) bool { + if request.Plan.Scope == domain.ScopeProject { + return request.Plan.InstallIntent == domain.InstallIntentAutomatic && shared.OnlyNativeComponents(request.Plan.Components) + } return shared.HasClientCLI(env, request.BackendExecutable) } // VerifierAvailable reports that an exact Codex listing can be observed. -func (*Adapter) VerifierAvailable(_ domain.DetectedClient, _ domain.DeliveryPlan, backendExecutable string) bool { +func (*Adapter) VerifierAvailable(_ domain.DetectedClient, plan domain.DeliveryPlan, backendExecutable string) bool { + if plan.Scope == domain.ScopeProject { + return shared.OnlyNativeComponents(plan.Components) + } return shared.HasListingCLI(backendExecutable) } @@ -35,6 +41,9 @@ func (*Adapter) Activate(ctx context.Context, env clients.Env, request domain.Ac if err := shared.ActivationIdentityMismatch(request); err != nil { return domain.ActivationOutcome{}, err } + if request.Plan.Scope == domain.ScopeProject || request.Plan.TargetContext.Scope == domain.ScopeProject { + return activateCodexProject(ctx, env, request) + } profileRoot := request.Client.ConfigRoot if profileRoot == "" { profileRoot = request.Plan.NativeRegistryRoot diff --git a/install/integrationctl/agentplugins/clients/codex/lifecycle_deactivate.go b/install/integrationctl/agentplugins/clients/codex/lifecycle_deactivate.go index 83a7b5519..13ac6ab36 100644 --- a/install/integrationctl/agentplugins/clients/codex/lifecycle_deactivate.go +++ b/install/integrationctl/agentplugins/clients/codex/lifecycle_deactivate.go @@ -13,6 +13,9 @@ import ( // Deactivate removes the managed Codex plugin and marketplace after the // operator confirms, or records the manual steps when the CLI is missing. func (*Adapter) Deactivate(ctx context.Context, env clients.Env, request domain.DeactivationRequest) (domain.DeactivationOutcome, error) { + if request.TargetContext.Scope == domain.ScopeProject { + return deactivateCodexProject(ctx, env, request) + } outcome := shared.StartedDeactivation() if err := validateProfile(request.Client.ConfigRoot, ""); err != nil { return outcome, err diff --git a/install/integrationctl/agentplugins/clients/codex/plan.go b/install/integrationctl/agentplugins/clients/codex/plan.go index 8c699c7bb..0155514d2 100644 --- a/install/integrationctl/agentplugins/clients/codex/plan.go +++ b/install/integrationctl/agentplugins/clients/codex/plan.go @@ -14,10 +14,17 @@ var ( ) func (*Adapter) CheckPlanPrecondition(input clients.PlanInput, plan *domain.DeliveryPlan) error { + if plan.Scope == domain.ScopeProject || input.TargetContext.Scope == domain.ScopeProject { + _, err := codexProjectPlanRoot(input.Client, *plan) + return err + } return validateProfile(input.Client.ConfigRoot, plan.NativeRegistryRoot) } -func (*Adapter) RefinePlan(_ context.Context, _ clients.PlanInput, plan *domain.DeliveryPlan) error { +func (*Adapter) RefinePlan(_ context.Context, input clients.PlanInput, plan *domain.DeliveryPlan) error { + if plan.Scope == domain.ScopeProject { + return refineCodexProjectPlan(input, plan) + } if plan.Status != domain.PlanUnsupported && shared.HasListingCLI(plan.NativeRegistryExecutable) { plan.Status = domain.PlanReady plan.Activation = domain.ActivationActive diff --git a/install/integrationctl/agentplugins/clients/codex/profile.go b/install/integrationctl/agentplugins/clients/codex/profile.go index 2487a1724..b62234e0a 100644 --- a/install/integrationctl/agentplugins/clients/codex/profile.go +++ b/install/integrationctl/agentplugins/clients/codex/profile.go @@ -88,6 +88,12 @@ func ValidateBindingProfile(root string, binding domain.ClientBinding) error { if err := validateProfile(root, ""); err != nil { return err } + if binding.Scope == string(domain.ScopeProject) { + if binding.NativeProfileRoot != root { + return fmt.Errorf("recorded Codex project root differs from selected root") + } + return validateCodexProjectObjects(root, binding.NativeObjects) + } if binding.NativeProfileRoot != "" { if err := validateProfile(binding.NativeProfileRoot, root); err != nil { return fmt.Errorf("recorded Codex profile mismatch: %w", err) diff --git a/install/integrationctl/agentplugins/clients/codex/project.go b/install/integrationctl/agentplugins/clients/codex/project.go index 9f4dfc068..92de4214f 100644 --- a/install/integrationctl/agentplugins/clients/codex/project.go +++ b/install/integrationctl/agentplugins/clients/codex/project.go @@ -11,8 +11,9 @@ import ( ) var ( - _ clients.Projector = (*Adapter)(nil) - _ clients.SelectionReader = (*Adapter)(nil) + _ clients.Projector = (*Adapter)(nil) + _ clients.SelectionReader = (*Adapter)(nil) + _ clients.ActiveNativeProjector = (*Adapter)(nil) ) // ManagedMCPSelection reports Codex's OpenAI-shaped `.mcp.json`. @@ -22,6 +23,9 @@ func (*Adapter) ManagedMCPSelection() clients.SelectionLayout { // Project renders the OpenAI compatibility layout and the managed marketplace. func (*Adapter) Project(_ context.Context, in clients.ProjectionInput) ([]domain.NativeObjectOwnership, error) { + if in.Plan.Scope == domain.ScopeProject { + return projectCodexComponents(in) + } if in.Plan.PackageMode != domain.PackageProjection { return nil, nil } @@ -34,6 +38,16 @@ func (*Adapter) Project(_ context.Context, in clients.ProjectionInput) ([]domain return nil, nil } +func (*Adapter) ProjectActiveNative(_ context.Context, root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan, dataPath string) ([]domain.NativeObjectOwnership, error) { + if plan.Scope != domain.ScopeProject { + return nil, nil + } + if err := validateCodexProjectStdio(root, plan, dataPath); err != nil { + return nil, err + } + return codexProjectObjects(root, envelope, plan) +} + func projectOpenAI(root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan, hints domain.CompatibilityHints, dataPath string) error { manifest, err := shared.ProjectedOpenAIManifest(envelope) if err != nil { diff --git a/install/integrationctl/agentplugins/clients/codex/project_identity.go b/install/integrationctl/agentplugins/clients/codex/project_identity.go new file mode 100644 index 000000000..a88b71e32 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/codex/project_identity.go @@ -0,0 +1,43 @@ +package codex + +import ( + "fmt" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/codextoml" + "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/domain" +) + +func inspectPreparedCodexProject(plan domain.DeliveryPlan, owned bool) (clients.RegistryFinding, error) { + var managed *domain.ClientBinding + if owned { + managed = &domain.ClientBinding{Scope: string(domain.ScopeProject), NativeProfileRoot: plan.NativeRegistryRoot, NativeObjects: plan.PreviousNativeObjects} + } + return inspectCodexProject(plan, managed, nativeconfig.New()) +} + +func inspectCodexProject(plan domain.DeliveryPlan, managed *domain.ClientBinding, kernel nativeconfig.Kernel) (clients.RegistryFinding, error) { + if managed != nil { + if managed.PhysicalArtifact != "" && managed.PhysicalArtifact != plan.PhysicalArtifactID { + return clients.RegistryIndeterminate, fmt.Errorf("codex project binding artifact differs from plan") + } + if managed.Scope != string(domain.ScopeProject) { + return clients.RegistryIndeterminate, fmt.Errorf("codex project binding scope differs from plan") + } + if err := ValidateBindingProfile(plan.NativeRegistryRoot, *managed); err != nil { + return clients.RegistryIndeterminate, err + } + selected := *managed + selected.NativeObjects = codexProjectOwned(managed.NativeObjects) + managed = &selected + } + return codexProjectLayout(plan.NativeRegistryRoot).Inspect(plan.Components, managed, kernel.WithCodexDocumentParser(codextoml.Parse)) +} + +func verifyCodexProjectObjects(root string, objects []domain.NativeObjectOwnership, allowMissing bool, kernel nativeconfig.Kernel) error { + if err := validateCodexProjectObjects(root, objects); err != nil { + return err + } + return codexProjectLayout(root).Verify(codexProjectOwned(objects), allowMissing, kernel.WithCodexDocumentParser(codextoml.Parse)) +} diff --git a/install/integrationctl/agentplugins/clients/codex/project_lifecycle.go b/install/integrationctl/agentplugins/clients/codex/project_lifecycle.go new file mode 100644 index 000000000..6ce3cecd2 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/codex/project_lifecycle.go @@ -0,0 +1,88 @@ +package codex + +import ( + "context" + "fmt" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/codextoml" + "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" +) + +func activateCodexProject(ctx context.Context, env clients.Env, request domain.ActivationRequest) (domain.ActivationOutcome, error) { + root, err := codexProjectPlanRoot(request.Client, request.Plan) + if err != nil { + return domain.ActivationOutcome{}, err + } + if err := New().PreflightActivation(env, request); err != nil { + return domain.ActivationOutcome{}, err + } + outcome, err := shared.CompleteNativeConfigActivation(ctx, request, shared.NativeConfigActivation{ + Automatic: request.Plan.InstallIntent == domain.InstallIntentAutomatic && shared.OnlyNativeComponents(request.Plan.Components), + UnavailableAction: "prepare the supported Codex project skills and MCP components", + RepairAction: "repair the exact managed Codex project components", + RetryAction: "retry Codex project component installation", + CompletedAction: codexProjectDiscoveryAction, + Verify: func() error { + return verifyCodexProjectObjects(root, request.Delivery.NativeObjects, false, env.NativeConfig) + }, + Activate: func(ctx context.Context, request domain.ActivationRequest) error { + return mutateCodexProject(ctx, env, root, request.Delivery.ActivePath, request.PreviousNativeObjects, request.Delivery.NativeObjects) + }, + }) + if err != nil { + return outcome, err + } + outcome.Activation, outcome.ActivationAttested = domain.ActivationPrepared, false + conditions, err := codexProjectConditions(env.NativeConfig, request) + if err != nil { + return outcome, fmt.Errorf("codex project configuration was installed but restrictions could not be inspected: %w", err) + } + for _, condition := range conditions { + outcome.UserActions = shared.AppendUnique(outcome.UserActions, condition) + } + return outcome, nil +} + +func mutateCodexProject(ctx context.Context, env clients.Env, root, active string, previous, desired []domain.NativeObjectOwnership) error { + if err := validateCodexProjectObjects(root, previous); err != nil { + return err + } + if err := validateCodexProjectObjects(root, desired); err != nil { + return err + } + servers := make(map[string]nativeconfig.Server) + for _, object := range codexProjectOwned(desired) { + if object.Kind != codexProjectMCPKind { + continue + } + server, err := readCodexProjectServer(active, object.LogicalName) + if err != nil { + return err + } + servers[object.LogicalName] = server + } + return (shared.ProjectComponentMutation{Layout: codexProjectLayout(root), ActivePath: active, + Previous: codexProjectOwned(previous), Desired: codexProjectOwned(desired), Servers: servers, Kernel: env.NativeConfig.WithCodexDocumentParser(codextoml.Parse)}).Apply(ctx) +} + +func deactivateCodexProject(ctx context.Context, env clients.Env, request domain.DeactivationRequest) (domain.DeactivationOutcome, error) { + outcome := shared.StartedDeactivation() + root, err := codexProjectRoot(request.Client, request.TargetContext, request.NativeRegistryRoot) + if err != nil { + return outcome, err + } + if !request.Confirmed { + outcome.UserActions = append(outcome.UserActions, "agentplugins will remove only its exact managed Codex project skill directories and MCP entries") + return outcome, nil + } + if err := mutateCodexProject(ctx, env, root, "", request.NativeObjects, nil); err != nil { + if !shared.CommittedNativeDeactivationCleanup(&outcome, err) { + return outcome, err + } + } + outcome.ExternalRemovalComplete = true + return outcome, nil +} diff --git a/install/integrationctl/agentplugins/clients/codex/project_objects.go b/install/integrationctl/agentplugins/clients/codex/project_objects.go new file mode 100644 index 000000000..397dd9b33 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/codex/project_objects.go @@ -0,0 +1,100 @@ +package codex + +import ( + "fmt" + "path/filepath" + "sort" + + "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" +) + +const ( + codexProjectSkillKind = "codex_project_skill_directory" + codexProjectMCPKind = "codex_project_mcp_server" +) + +func projectCodexComponents(in clients.ProjectionInput) ([]domain.NativeObjectOwnership, error) { + if _, err := codexProjectPlanRoot(domain.DetectedClient{ConfigRoot: in.Plan.TargetContext.UserConfigRoot}, in.Plan); err != nil { + return nil, err + } + if err := shared.ProjectOpenAIMCP(in.StagingPath, in.Envelope, shared.SupportedMCPNames(in.Plan), in.Hints, in.Plan.ActivePath, in.PluginDataPath); err != nil { + return nil, err + } + return codexProjectObjects(in.StagingPath, in.Envelope, in.Plan) +} + +func codexProjectObjects(root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan) ([]domain.NativeObjectOwnership, error) { + if _, err := codexProjectPlanRoot(domain.DetectedClient{ConfigRoot: plan.TargetContext.UserConfigRoot}, plan); err != nil { + return nil, err + } + objects := make([]domain.NativeObjectOwnership, 0, len(plan.Components)) + for _, component := range plan.Components { + if component.Support == domain.SupportUnsupported { + continue + } + object, err := codexProjectObject(root, envelope, plan, component) + if err != nil { + return nil, err + } + objects = append(objects, object) + } + sort.Slice(objects, func(i, j int) bool { return objects[i].ObjectID < objects[j].ObjectID }) + return objects, validateCodexProjectObjects(plan.NativeRegistryRoot, objects) +} + +func codexProjectObject(root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan, component domain.ComponentDecision) (domain.NativeObjectOwnership, error) { + layout := codexProjectLayout(plan.NativeRegistryRoot) + switch component.Kind { + case domain.ComponentSkill: + skill, ok := envelope.Skills[component.Name] + if !ok { + return domain.NativeObjectOwnership{}, fmt.Errorf("planned Codex skill is missing") + } + relative := skill.RelativePath + if relative == "" { + relative = filepath.ToSlash(filepath.Join("skills", component.Name, "SKILL.md")) + } + return layout.SkillObject(component.Name, filepath.ToSlash(filepath.Dir(filepath.FromSlash(relative))), root) + case domain.ComponentMCPServer: + server, err := readCodexProjectServer(root, component.Name) + if err != nil { + return domain.NativeObjectOwnership{}, err + } + return layout.MCPObject(component.Name, server, codexProjectConfigPath(plan.NativeRegistryRoot)) + default: + return domain.NativeObjectOwnership{}, fmt.Errorf("unsupported Codex project component kind %q", component.Kind) + } +} + +func codexProjectLayout(root string) shared.ProjectComponents { + return shared.ProjectComponents{ProjectRoot: root, SkillsRoot: filepath.Join(root, ".agents", "skills"), + ConfigPaths: nativeconfig.Paths{JSON: codexProjectConfigPath(root)}, Codec: nativeconfig.CodecCodex, + SkillKind: codexProjectSkillKind, MCPKind: codexProjectMCPKind} +} + +func codexProjectConfigPath(root string) string { return filepath.Join(root, ".codex", "config.toml") } +func codexProjectSkillPath(root, name string) string { + return filepath.Join(root, ".agents", "skills", name) +} + +func codexProjectOwned(objects []domain.NativeObjectOwnership) []domain.NativeObjectOwnership { + var owned []domain.NativeObjectOwnership + for _, object := range objects { + if object.Kind == codexProjectSkillKind || object.Kind == codexProjectMCPKind { + owned = append(owned, object) + } + } + return owned +} + +func validateCodexProjectObjects(root string, objects []domain.NativeObjectOwnership) error { + for _, object := range objects { + if object.Kind != codexProjectSkillKind && object.Kind != codexProjectMCPKind && object.Kind != "managed_package_directory" { + return fmt.Errorf("codex project binding contains an unexpected native object kind") + } + } + return codexProjectLayout(root).ValidateObjects(codexProjectOwned(objects)) +} diff --git a/install/integrationctl/agentplugins/clients/codex/project_server.go b/install/integrationctl/agentplugins/clients/codex/project_server.go new file mode 100644 index 000000000..04a4364f0 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/codex/project_server.go @@ -0,0 +1,78 @@ +package codex + +import ( + "encoding/json" + "fmt" + "path/filepath" + + "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/shared" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +// Read the existing OpenAI projection so managed stdio paths/data and accepted +// compatibility auth references are materialized once by the shared projector. +func readCodexProjectServer(root, name string) (nativeconfig.Server, error) { + var result nativeconfig.Server + path := filepath.Join(root, ".mcp.json") + if err := pathpolicy.RequireContainedChild(root, path); err != nil { + return result, err + } + file, err := nativeconfig.New().ReadExactFile(path) + if err != nil { + return result, err + } + if !file.Exists || len(file.Body) > 1<<20 { + return result, fmt.Errorf("codex projected MCP document is absent or exceeds inspection limit") + } + body := file.Body + doc, err := shared.DecodeStrictJSONObject(body) + if err != nil { + return result, err + } + servers, ok := doc["mcpServers"].(map[string]any) + if !ok { + return result, fmt.Errorf("codex projection has no MCP server collection") + } + value, ok := servers[name].(map[string]any) + if !ok { + return result, fmt.Errorf("codex projected MCP server is missing") + } + body, err = json.Marshal(value) + if err != nil { + return result, err + } + if err := json.Unmarshal(body, &result); err != nil { + return result, fmt.Errorf("codex projected MCP server has invalid fields") + } + switch result.Type { + case "", "stdio": + result.Type, result.StdioValuesResolved, result.CWDResolved = "stdio", true, true + case "http": + result.Type, result.RemoteTransport = "remote", "streamable-http" + default: + return result, fmt.Errorf("codex project MCP requires stdio or streamable-http") + } + var auth nativeconfig.CodexAuthOptions + if err := json.Unmarshal(body, &auth); err != nil { + return result, fmt.Errorf("codex projected authentication references are invalid") + } + if auth != (nativeconfig.CodexAuthOptions{}) { + result.CodexAuth = &auth + } + return result, nil +} + +func validateCodexProjectStdio(root string, plan domain.DeliveryPlan, dataPath string) error { + for _, name := range shared.SupportedMCPNames(plan) { + server, err := readCodexProjectServer(root, name) + if err != nil { + return err + } + if server.Type == "stdio" && (server.Env["PLUGIN_ROOT"] != plan.ActivePath || dataPath != "" && server.Env["PLUGIN_DATA"] != dataPath) { + return fmt.Errorf("codex projected stdio package/data environment differs from bound locators") + } + } + return nil +} diff --git a/install/integrationctl/agentplugins/clients/codex/project_skill_settings.go b/install/integrationctl/agentplugins/clients/codex/project_skill_settings.go new file mode 100644 index 000000000..41d6e824e --- /dev/null +++ b/install/integrationctl/agentplugins/clients/codex/project_skill_settings.go @@ -0,0 +1,47 @@ +package codex + +import ( + "path/filepath" + "strings" +) + +type codexSkillSelector struct { + Name *string `toml:"name"` + Path *string `toml:"path"` + Enabled *bool `toml:"enabled"` +} + +// The pinned client accepts skill selectors only from user/session layers, not +// project layers. This checks the visible user file's ordered rules only; it +// does not infer the selected native profile or session's effective policy. +func codexUserSkillDisabled(selectors []codexSkillSelector, name, document string) bool { + // Native SkillsConfig decoding is all-or-nothing for the selected layer. + // Missing enabled or a nonabsolute typed path invalidates the whole section. + for _, selector := range selectors { + if selector.Enabled == nil || selector.Path != nil && !filepath.IsAbs(*selector.Path) { + return false + } + } + disabled := false + document = codexSkillDocumentIdentity(document) + for _, selector := range selectors { + matches := false + switch { + case selector.Name != nil && selector.Path == nil: + matches = strings.TrimSpace(*selector.Name) == name + case selector.Path != nil && selector.Name == nil: + matches = codexSkillDocumentIdentity(*selector.Path) == document + } + if matches { + disabled = !*selector.Enabled + } + } + return disabled +} + +func codexSkillDocumentIdentity(path string) string { + if canonical, err := filepath.EvalSymlinks(path); err == nil { + return canonical + } + return filepath.Clean(path) +} diff --git a/install/integrationctl/agentplugins/clients/codex/project_status.go b/install/integrationctl/agentplugins/clients/codex/project_status.go new file mode 100644 index 000000000..e156715aa --- /dev/null +++ b/install/integrationctl/agentplugins/clients/codex/project_status.go @@ -0,0 +1,121 @@ +package codex + +import ( + "fmt" + "path/filepath" + + "github.com/pelletier/go-toml/v2" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/codextoml" + "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) + +func (*Adapter) PreflightActivation(env clients.Env, request domain.ActivationRequest) error { + if request.Plan.Scope != domain.ScopeProject && request.Plan.TargetContext.Scope != domain.ScopeProject { + return nil + } + if _, err := codexProjectPlanRoot(request.Client, request.Plan); err != nil { + return err + } + if _, err := codexProjectConditions(env.NativeConfig, request); err != nil { + return err + } + if request.ActivationComplete { + return fmt.Errorf("codex project native discovery cannot be established by activation attestation; inspect skills and MCP in a fresh trusted project session") + } + return nil +} + +func refineCodexProjectPlan(input clients.PlanInput, plan *domain.DeliveryPlan) error { + if _, err := codexProjectPlanRoot(input.Client, *plan); err != nil { + return err + } + for i := range plan.Components { + component := &plan.Components[i] + if component.Kind == domain.ComponentMCPServer && input.Envelope.MCP.Servers[component.Name].Type == "sse" { + component.Support = domain.SupportUnsupported + component.Reason = "Codex project MCP supports stdio and streamable-http; SSE is unsupported" + } + } + if !shared.OnlyNativeComponents(plan.Components) { + plan.Status, plan.Activation = domain.PlanUnsupported, domain.ActivationNotRequired + return nil + } + shared.PromoteNativeReady(plan, plan.NativeRegistryRoot, true) + plan.UserActions = shared.AppendUnique(plan.UserActions, codexProjectDiscoveryAction) + return nil +} + +const codexProjectDiscoveryAction = "Codex project components are configured; native discovery, project trust, effective managed/session policy and MCP initialization remain unobserved; start Codex in the selected project and inspect skills and `codex mcp list --json`" + +type codexProjectSettings struct { + MCPServers map[string]struct { + Enabled *bool `toml:"enabled"` + } `toml:"mcp_servers"` + Skills struct { + IncludeInstructions *bool `toml:"include_instructions"` + Config []codexSkillSelector `toml:"config"` + } `toml:"skills"` +} + +func readCodexProjectSettings(kernel nativeconfig.Kernel, path string) (codexProjectSettings, error) { + kernel = kernel.WithCodexDocumentParser(codextoml.Parse) + var settings codexProjectSettings + if _, _, err := kernel.Inspect(nativeconfig.Paths{JSON: path}, nativeconfig.CodecCodex, "agentplugins-policy-inspection", nil); err != nil { + return settings, err + } + file, err := kernel.ReadExactFile(path) + if err != nil || !file.Exists { + return settings, err + } + if len(file.Body) > 1<<20 { + return settings, fmt.Errorf("codex config exceeds component inspection limit") + } + if err := toml.Unmarshal(file.Body, &settings); err != nil { + return settings, fmt.Errorf("codex component settings are invalid") + } + return settings, nil +} + +// These preserve visible user/project restrictions. They deliberately do not +// reconstruct native profile, trust, managed, ancestor or session precedence. +func codexProjectConditions(kernel nativeconfig.Kernel, request domain.ActivationRequest) ([]string, error) { + var conditions []string + paths := []string{filepath.Join(request.Client.ConfigRoot, "config.toml"), codexProjectConfigPath(request.Plan.NativeRegistryRoot)} + for i, path := range paths { + settings, err := readCodexProjectSettings(kernel, path) + if err != nil { + return nil, err + } + conditions = append(conditions, codexConfiguredRestrictions(settings, request, i == 0)...) + } + return conditions, nil +} + +func codexConfiguredRestrictions(settings codexProjectSettings, request domain.ActivationRequest, userLayer bool) []string { + var conditions []string + for _, component := range request.Plan.Components { + if component.Support == domain.SupportUnsupported { + continue + } + if component.Kind == domain.ComponentMCPServer { + if enabled := settings.MCPServers[component.Name].Enabled; enabled != nil && !*enabled { + conditions = append(conditions, fmt.Sprintf("preserved Codex settings disable MCP server %q; configuration does not prove native discovery", component.Name)) + } + } + if component.Kind == domain.ComponentSkill { + if settings.Skills.IncludeInstructions != nil && !*settings.Skills.IncludeInstructions { + conditions = append(conditions, "preserved Codex settings disable automatic skill instructions; explicit native discovery remains unobserved") + } + if userLayer && codexUserSkillDisabled(settings.Skills.Config, component.Name, filepath.Join(codexProjectSkillPath(request.Plan.NativeRegistryRoot, component.Name), "SKILL.md")) { + conditions = append(conditions, fmt.Sprintf("preserved Codex user settings disable skill %q; session overrides and native effective enablement remain unobserved", component.Name)) + } + } + } + return conditions +} diff --git a/install/integrationctl/agentplugins/clients/codex/scope.go b/install/integrationctl/agentplugins/clients/codex/scope.go new file mode 100644 index 000000000..a7444607a --- /dev/null +++ b/install/integrationctl/agentplugins/clients/codex/scope.go @@ -0,0 +1,65 @@ +package codex + +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) +) + +func (*Adapter) NativeRegistry(in clients.PlanInput) (string, string) { + if in.TargetContext.Scope == domain.ScopeProject { + return in.TargetContext.ProjectRoot, in.Client.ExecutablePath + } + return in.Client.ConfigRoot, in.Client.ExecutablePath +} + +func (*Adapter) ProjectTargetRoot(client domain.DetectedClient, mode domain.PackageMode, managedRoot string, target domain.TargetContext) (string, string, error) { + if target.Scope != domain.ScopeProject { + return "", "", fmt.Errorf("codex project layout requires project scope") + } + if _, err := codexProjectRoot(client, target, ""); err != nil { + return "", "", err + } + if err := pathpolicy.RequireContainedChild(target.ProjectRoot, managedRoot); err != nil { + return "", "", fmt.Errorf("codex prepared package escapes selected project: %w", err) + } + return shared.ManagedTargetRoot(client, mode, managedRoot) +} + +func codexProjectRoot(client domain.DetectedClient, target domain.TargetContext, planned string) (string, error) { + if target.Scope != domain.ScopeProject { + return "", fmt.Errorf("codex component delivery requires project target context") + } + validated, err := domain.NewTargetContext(target.Scope, target.ProjectRoot, target.UserConfigRoot) + if err != nil { + return "", err + } + if validated.UserConfigRoot != client.ConfigRoot { + return "", fmt.Errorf("codex original user profile differs from target context") + } + if err := validateProfile(validated.ProjectRoot, planned); err != nil { + return "", err + } + for _, relative := range []string{".codex", filepath.Join(".agents", "skills")} { + if err := pathpolicy.RequireContainedChild(validated.ProjectRoot, filepath.Join(validated.ProjectRoot, relative)); err != nil { + return "", err + } + } + return validated.ProjectRoot, nil +} + +func codexProjectPlanRoot(client domain.DetectedClient, plan domain.DeliveryPlan) (string, error) { + if plan.Scope != domain.ScopeProject { + return "", fmt.Errorf("codex plan scope differs from project context") + } + return codexProjectRoot(client, plan.TargetContext, plan.NativeRegistryRoot) +} diff --git a/install/integrationctl/agentplugins/domain/clients.go b/install/integrationctl/agentplugins/domain/clients.go index eaed3b52d..6d4867565 100644 --- a/install/integrationctl/agentplugins/domain/clients.go +++ b/install/integrationctl/agentplugins/domain/clients.go @@ -92,14 +92,14 @@ type ClientDefinition struct { } var clientDefinitions = []ClientDefinition{ - clientDefinition(ClientCodex, "OpenAI Codex", "codex", "managed", "projected", true, PackageProjection, SupportProjected, SupportProjected, SupportUnsupported, SupportUnsupported, SupportUnsupported, ClientTraits{ + withProjectScope(clientDefinition(ClientCodex, "OpenAI Codex", "codex", "managed", "projected", true, PackageProjection, SupportProjected, SupportProjected, SupportUnsupported, SupportUnsupported, SupportUnsupported, ClientTraits{ QualifiedLocalInstallerFacade: true, InstallIntents: []InstallIntent{InstallIntentAutomatic}, LifecycleKind: LifecycleCLIRegistry, HonorsOpenAIMCPAuthHints: true, SupportsPreparedRecovery: true, BindsNativeProfileRoot: true, - }), + }), LifecycleNativeConfig), withoutHostPresence(withDirectoryPreparation( clientDefinition(ClientChatGPT, "ChatGPT", "chatgpt", "manual_activation", "projected", false, PackageProjection, SupportProjected, SupportUnsupported, SupportUnsupported, SupportProjected, SupportUnsupported, ClientTraits{ InstallIntents: []InstallIntent{InstallIntentAutomatic, InstallIntentPrepare}, diff --git a/install/integrationctl/agentplugins/go.mod b/install/integrationctl/agentplugins/go.mod index c7b97bccd..b90e21266 100644 --- a/install/integrationctl/agentplugins/go.mod +++ b/install/integrationctl/agentplugins/go.mod @@ -8,6 +8,7 @@ replace github.com/777genius/plugin-kit-ai/install/integrationctl => ../ require ( github.com/777genius/plugin-kit-ai/install/integrationctl v0.0.0-20260919103549-148794ac5947 + github.com/creachadair/tomledit v0.0.29 github.com/dlclark/regexp2 v1.12.0 github.com/pelletier/go-toml/v2 v2.3.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 diff --git a/install/integrationctl/agentplugins/go.sum b/install/integrationctl/agentplugins/go.sum index b37433a50..ebbe763e3 100644 --- a/install/integrationctl/agentplugins/go.sum +++ b/install/integrationctl/agentplugins/go.sum @@ -1,3 +1,5 @@ +github.com/creachadair/tomledit v0.0.29 h1:dB5CbdwJMpn/fmfAPTAAleXF/KJwY0Ggc1eL/zvZRgk= +github.com/creachadair/tomledit v0.0.29/go.mod h1:4SoTXxzHgvzHRMIJPw+o6zK/yXii4VjLrb6/3gCQnyA= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= 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..8316a4099 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json @@ -135,7 +135,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..1064fe51c 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 @@ -203,7 +203,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..4fdc0b149 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json @@ -123,7 +123,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..8fe1ce763 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json @@ -148,7 +148,8 @@ }, "PackageMode": "compatibility_projection", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "projected" }, diff --git a/install/integrationctl/agentplugins/providers/project_codex_fixture_test.go b/install/integrationctl/agentplugins/providers/project_codex_fixture_test.go new file mode 100644 index 000000000..bc4569032 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_codex_fixture_test.go @@ -0,0 +1,110 @@ +package providers_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/codextoml" + "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/codex" + "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" +) + +type projectCodexFixture struct { + user, a, b, source string + registry *clients.Registry + kernel nativeconfig.Kernel + activator providers.Activator +} + +func projectCodexWrite(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 newProjectCodexFixture(t *testing.T) projectCodexFixture { + t.Helper() + base, err := os.MkdirTemp("", "uap-project-codex-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(base) }) + f := projectCodexFixture{user: filepath.Join(base, "user", ".codex"), a: filepath.Join(base, "A"), b: filepath.Join(base, "B"), source: filepath.Join(base, "source")} + for _, config := range []string{filepath.Join(f.user, "config.toml"), filepath.Join(f.a, ".codex", "config.toml"), filepath.Join(f.b, ".codex", "config.toml")} { + projectCodexWrite(t, config, "# foreign native policy\nmodel = 'foreign-model'\n[mcp_servers.foreign]\nurl='https://foreign.test/mcp' # keep server\n") + } + projectCodexWrite(t, filepath.Join(f.user, "plugins", "cache", "foreign", "keep.txt"), "user plugin cache\n") + for _, root := range []string{f.a, f.b} { + projectCodexWrite(t, filepath.Join(root, ".agents", "skills", "foreign", "SKILL.md"), "foreign repository skill\n") + } + projectCodexWrite(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"}`) + projectCodexWrite(t, filepath.Join(f.source, "skills", "docs", "SKILL.md"), "---\nname: docs\ndescription: Inert project fixture\n---\nUAP-CODEX-PROJECT\n") + projectCodexWrite(t, filepath.Join(f.source, filepath.FromSlash(commandext.RelativeDirectory), "echo.md"), "---\ndescription: Inert command\ndisable-model-invocation: false\n---\nUAP-CODEX-COMMAND\n") + projectCodexWrite(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/mcp","headers":{"X-Fixture":"inert"}},"local":{"type":"stdio","command":"go","args":["version"],"env":{"DATA":"${PLUGIN_DATA}"}}}}`) + f.registry, err = clients.NewRegistry(codex.New()) + if err != nil { + t.Fatal(err) + } + f.kernel = nativeconfig.New().WithCodexDocumentParser(codextoml.Parse) + f.activator = providers.Activator{Registry: f.registry, NativeConfig: &f.kernel} + return f +} + +func (f projectCodexFixture) stage(t *testing.T, hints domain.CompatibilityHints) domain.ActivationRequest { + t.Helper() + envelope := f.load(t) + client := domain.DetectedClient{ClientID: domain.ClientCodex, ConfigRoot: f.user, Version: "0.153.4", Status: domain.DetectionDetected} + plan, err := (planner.Planner{ProjectRoot: f.a, ManagedRoot: filepath.Join(f.a, ".uap", "managed"), Paths: pathpolicy.Policy{}, Registry: f.registry}).Plan(context.Background(), domain.PlanRequest{ + Envelope: envelope, Client: client, Scope: domain.ScopeProject, ProjectRoot: f.a, PhysicalArtifactID: "demo-0123456789ab"}) + if err != nil || plan.Status != domain.PlanReady || plan.Activation != domain.ActivationPrepared || plan.NativeRegistryRoot != f.a { + t.Fatalf("project Codex plan = %+v, %v", plan, err) + } + delivery, err := (providers.Stager{Registry: f.registry, Paths: pathpolicy.Policy{}, PluginDataRoot: filepath.Join(f.a, ".uap", "data")}).StageWithPluginData(t.Context(), envelope, plan, "project-codex-fixture", hints, filepath.Join(f.a, ".uap", "data", "owned-slot")) + if err != nil { + t.Fatal(err) + } + 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 = "" + return domain.ActivationRequest{Client: client, Plan: plan, Delivery: delivery, DeclaredName: "demo"} +} + +func (f projectCodexFixture) load(t *testing.T) domain.PackageEnvelope { + 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) + } + return envelope +} + +func projectCodexRead(t *testing.T, path string) string { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(body) +} diff --git a/install/integrationctl/agentplugins/providers/project_codex_guards_test.go b/install/integrationctl/agentplugins/providers/project_codex_guards_test.go new file mode 100644 index 000000000..481c4a0b4 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_codex_guards_test.go @@ -0,0 +1,90 @@ +package providers_test + +import ( + "errors" + "path/filepath" + "reflect" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/codex" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +func TestProjectCodexRejectsChangedContextBeforeNativeEffects(t *testing.T) { + for _, change := range []string{"user-registry", "original-user", "plan-scope", "context-scope"} { + t.Run(change, func(t *testing.T) { + f := newProjectCodexFixture(t) + r := f.stage(t, domain.CompatibilityHints{}) + switch change { + case "user-registry": + r.Plan.NativeRegistryRoot = f.user + case "original-user": + r.Plan.TargetContext.UserConfigRoot = f.b + case "plan-scope": + r.Plan.Scope = domain.ScopeUser + case "context-scope": + r.Plan.TargetContext.Scope = domain.ScopeUser + } + before, user := projectGeminiInventory(t, f.a), projectGeminiInventory(t, f.user) + if _, err := f.activator.Activate(t.Context(), r); err == nil { + t.Fatal("changed project context reached native activation") + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, f.a)) || !reflect.DeepEqual(user, projectGeminiInventory(t, f.user)) { + t.Fatal("refused context changed project or user files") + } + }) + } +} + +func TestProjectCodexMalformedCollisionAndWriterFailurePreserveNativeFiles(t *testing.T) { + for _, name := range []string{"malformed", "foreign-disabled", "writer-failure"} { + t.Run(name, func(t *testing.T) { + f := newProjectCodexFixture(t) + r := f.stage(t, domain.CompatibilityHints{}) + actor := f.activator + switch name { + case "malformed": + projectCodexWrite(t, filepath.Join(f.a, ".codex", "config.toml"), "[mcp_servers\n") + case "foreign-disabled": + projectCodexWrite(t, filepath.Join(f.a, ".codex", "config.toml"), "[mcp_servers.docs]\nurl='https://foreign.test/keep'\nenabled=false\n") + case "writer-failure": + kernel := nativeconfig.NewWithLockAcquirer(func(nativeconfig.Paths, nativeconfig.Codec) (func() error, error) { + return nil, errors.New("TEST writer unavailable") + }) + actor.NativeConfig = &kernel + } + before, user, b := projectGeminiInventory(t, f.a), projectGeminiInventory(t, f.user), projectGeminiInventory(t, f.b) + if _, err := actor.Activate(t.Context(), r); err == nil { + t.Fatal("invalid/failed project native mutation succeeded") + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, f.a)) || !reflect.DeepEqual(user, projectGeminiInventory(t, f.user)) || !reflect.DeepEqual(b, projectGeminiInventory(t, f.b)) { + t.Fatal("failed project mutation left native effects or consumed foreign files") + } + }) + } +} + +func TestProjectCodexRegistryUsesProjectComponentsAndExactBinding(t *testing.T) { + f := newProjectCodexFixture(t) + r := f.stage(t, domain.CompatibilityHints{}) + outcome, err := f.activator.Activate(t.Context(), r) + if err != nil { + t.Fatal(err) + } + r.Plan.PreviousNativeObjects = outcome.NativeObjects + binding := domain.ClientBinding{Scope: string(domain.ScopeProject), NativeProfileRoot: f.a, PhysicalArtifact: r.Plan.PhysicalArtifactID, NativeObjects: outcome.NativeObjects} + adapter := codex.New() + finding, err := adapter.InspectNativeRegistry(t.Context(), clients.Env{NativeConfig: f.kernel}, r.Client, r.Plan, &binding) + if err != nil || finding != clients.RegistryExpected { + t.Fatalf("project component registry = %v, %v", finding, err) + } + if finding, err := adapter.InspectPreparedRegistry(r.Plan, "demo", true); err != nil || finding != clients.RegistryExpected { + t.Fatalf("read-only prepared project registry = %v, %v", finding, err) + } + binding.PhysicalArtifact = "different-artifact" + if _, err := adapter.InspectNativeRegistry(t.Context(), clients.Env{NativeConfig: f.kernel}, r.Client, r.Plan, &binding); err == nil { + t.Fatal("another artifact supplied project ownership") + } +} diff --git a/install/integrationctl/agentplugins/providers/project_codex_scope_test.go b/install/integrationctl/agentplugins/providers/project_codex_scope_test.go new file mode 100644 index 000000000..ff8be7679 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_codex_scope_test.go @@ -0,0 +1,89 @@ +package providers_test + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +// Production standard loader, planner, renderer, stager, provider and native +// ownership kernel. No Codex session, model or configured MCP is executed. +func TestProjectCodexComponentsUseNativePathsAndPreserveOtherScopes(t *testing.T) { + f := newProjectCodexFixture(t) + request := f.stage(t, domain.CompatibilityHints{OpenAIMCPAuth: map[string]domain.OpenAIMCPAuthHint{"docs": {BearerTokenEnvVar: "TEST_TOKEN_REFERENCE", OAuthResource: "https://managed.test/resource"}}}) + userBefore, bBefore := projectGeminiInventory(t, f.user), projectGeminiInventory(t, f.b) + for pass := range 3 { + outcome, err := f.activator.Activate(t.Context(), request) + if err != nil || outcome.Activation != domain.ActivationPrepared || outcome.Verification != domain.VerificationInstalled || outcome.ActivationAttested || len(outcome.NativeObjects) != 5 { + t.Fatalf("pass %d project activation = %+v, %v", pass, outcome, err) + } + request.PreviousNativeObjects = outcome.NativeObjects + request.VerifyOnly = pass == 0 + if pass == 1 { + if err := os.RemoveAll(filepath.Join(f.a, ".agents", "skills", "docs")); err != nil { + t.Fatal(err) + } + } + } + if body := projectCodexRead(t, filepath.Join(f.a, ".agents", "skills", "docs", "SKILL.md")); !strings.Contains(body, "UAP-CODEX-PROJECT") { + t.Fatal("same-pin missing skill was not repaired") + } + if body := projectCodexRead(t, filepath.Join(f.a, ".agents", "skills", "demo-echo", "SKILL.md")); !strings.Contains(body, "UAP-CODEX-COMMAND") { + t.Fatal("rendered command skill lost its preserved instructions") + } + settings := projectCodexRead(t, filepath.Join(f.a, ".codex", "config.toml")) + for _, value := range []string{"foreign native policy", "foreign.test", "http_headers", "TEST_TOKEN_REFERENCE", "oauth_resource"} { + if !strings.Contains(settings, value) { + t.Fatalf("native TOML lost %q: %s", value, settings) + } + } + remove := domain.DeactivationRequest{Client: request.Client, TargetContext: request.Plan.TargetContext, NativeRegistryRoot: f.a, Confirmed: true, NativeObjects: request.PreviousNativeObjects} + for range 2 { + outcome, err := f.activator.Deactivate(t.Context(), remove) + if err != nil || !outcome.ExternalRemovalComplete { + t.Fatalf("project Codex exact remove = %+v, %v", outcome, err) + } + } + if _, err := os.Stat(filepath.Join(f.a, ".agents", "skills", "docs")); !os.IsNotExist(err) { + t.Fatal("managed skill survived removal") + } + if !reflect.DeepEqual(userBefore, projectGeminiInventory(t, f.user)) || !reflect.DeepEqual(bBefore, projectGeminiInventory(t, f.b)) { + t.Fatal("Codex project lifecycle changed user profile/cache or project B") + } + if strings.Contains(projectCodexRead(t, filepath.Join(f.a, ".codex", "config.toml")), "managed.test") { + t.Fatal("managed MCP registration survived removal") + } +} + +func TestProjectCodexRestrictionsAndAttestationRemainPrepared(t *testing.T) { + f := newProjectCodexFixture(t) + request := f.stage(t, domain.CompatibilityHints{}) + projectCodexWrite(t, filepath.Join(f.user, "config.toml"), "[mcp_servers.docs]\nenabled=false\n[[skills.config]]\nname='docs'\nenabled=false\n") + outcome, err := f.activator.Activate(t.Context(), request) + if err != nil || outcome.Activation != domain.ActivationPrepared { + t.Fatalf("restricted project configuration = %+v, %v", outcome, err) + } + actions := strings.Join(outcome.UserActions, "\n") + for _, value := range []string{`disable skill "docs"`, `disable MCP server "docs"`, "project trust", "remain unobserved"} { + if !strings.Contains(actions, value) { + t.Fatalf("project restriction/proof boundary missing %q: %s", value, actions) + } + } + before := projectGeminiInventory(t, f.a) + for _, automatic := range []bool{true, false} { + request.ActivationComplete = true + if !automatic { + request.Plan.Components[0].Support = domain.SupportPrepared + } + if _, err := f.activator.Activate(t.Context(), request); err == nil || !strings.Contains(err.Error(), "cannot be established by activation attestation") { + t.Fatalf("project attestation bypassed preflight: %v", err) + } + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, f.a)) { + t.Fatal("attestation changed project native objects") + } +} diff --git a/install/integrationctl/agentplugins/providers/project_codex_skill_policy_test.go b/install/integrationctl/agentplugins/providers/project_codex_skill_policy_test.go new file mode 100644 index 000000000..0e5371695 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_codex_skill_policy_test.go @@ -0,0 +1,85 @@ +package providers_test + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +// These production provider observations report only visible user-file rules. +// Project selectors are not native skill policy; session overrides remain unknown. +func TestProjectCodexSkillWarningsRespectPinnedUserSelectorContract(t *testing.T) { + for _, name := range []string{"disabled-document", "disabled-trimmed-name", "later-name-enable", "later-path-enable", "both-selectors-ignored", "directory-not-document", "project-selector-ignored", "missing-required-enabled", "relative-path-invalidates-section"} { + t.Run(name, func(t *testing.T) { + f := newProjectCodexFixture(t) + request := f.stage(t, domain.CompatibilityHints{}) + document := filepath.Join(f.a, ".agents", "skills", "docs", "SKILL.md") + pathRule := fmt.Sprintf("[[skills.config]]\npath=%q\nenabled=false\n", document) + user, project, want := pathRule, "", true + switch name { + case "disabled-trimmed-name": + user = "[[skills.config]]\nname=' docs '\nenabled=false\n" + case "later-name-enable": + user += "[[skills.config]]\nname='docs'\nenabled=true\n" + want = false + case "later-path-enable": + user = "[[skills.config]]\nname='docs'\nenabled=false\n" + strings.Replace(pathRule, "enabled=false", "enabled=true", 1) + want = false + case "both-selectors-ignored": + user = strings.Replace(pathRule, "enabled=false", "name='docs'\nenabled=false", 1) + want = false + case "directory-not-document": + user = fmt.Sprintf("[[skills.config]]\npath=%q\nenabled=false\n", filepath.Dir(document)) + want = false + case "project-selector-ignored": + user, project, want = "", pathRule, false + case "missing-required-enabled": + user += "[[skills.config]]\nname='unrelated'\n" + want = false + case "relative-path-invalidates-section": + user += "[[skills.config]]\npath='relative/SKILL.md'\nname='unrelated'\nenabled=true\n" + want = false + } + projectCodexWrite(t, filepath.Join(f.user, "config.toml"), user) + if project != "" { + projectCodexWrite(t, filepath.Join(f.a, ".codex", "config.toml"), project) + } + before := projectGeminiInventory(t, f.user) + outcome, err := f.activator.Activate(t.Context(), request) + if err != nil || outcome.Activation != domain.ActivationPrepared || outcome.Verification != domain.VerificationInstalled { + t.Fatalf("configured project = %+v, %v", outcome, err) + } + if got := strings.Contains(strings.Join(outcome.UserActions, "\n"), `disable skill "docs"`); got != want { + t.Fatalf("disabled warning = %t, want %t: %q", got, want, outcome.UserActions) + } + if got := projectGeminiInventory(t, f.user); !reflect.DeepEqual(got, before) { + t.Fatal("selector observation changed user settings") + } + }) + } +} + +func TestProjectCodexSkillWarningRecognizesCanonicalDocumentAlias(t *testing.T) { + f := newProjectCodexFixture(t) + request := f.stage(t, domain.CompatibilityHints{}) + outcome, err := f.activator.Activate(t.Context(), request) + if err != nil { + t.Fatal(err) + } + document := filepath.Join(f.a, ".agents", "skills", "docs", "SKILL.md") + alias := filepath.Join(f.a, "inert-skill-document-alias") + if err := os.Symlink(document, alias); err != nil { + t.Skipf("document alias unavailable: %v", err) + } + projectCodexWrite(t, filepath.Join(f.user, "config.toml"), fmt.Sprintf("[[skills.config]]\npath=%q\nenabled=false\n", alias)) + request.PreviousNativeObjects, request.VerifyOnly = outcome.NativeObjects, true + outcome, err = f.activator.Activate(t.Context(), request) + if err != nil || !strings.Contains(strings.Join(outcome.UserActions, "\n"), `disable skill "docs"`) || outcome.Activation != domain.ActivationPrepared { + t.Fatalf("canonical document selector = %+v, %v", outcome, err) + } +} diff --git a/install/integrationctl/agentplugins/providers/project_codex_update_test.go b/install/integrationctl/agentplugins/providers/project_codex_update_test.go new file mode 100644 index 000000000..6f3ecd981 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_codex_update_test.go @@ -0,0 +1,59 @@ +package providers_test + +import ( + "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/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/providers" +) + +func TestProjectCodexUpdateKeepsNativeOwnershipAndDataBindings(t *testing.T) { + f := newProjectCodexFixture(t) + r := f.stage(t, domain.CompatibilityHints{}) + old, err := f.activator.Activate(t.Context(), r) + if err != nil { + t.Fatal(err) + } + user, b := projectGeminiInventory(t, f.user), projectGeminiInventory(t, f.b) + projectCodexWrite(t, filepath.Join(f.source, "skills", "docs", "SKILL.md"), "---\nname: docs\ndescription: Updated inert project fixture\n---\nUAP-CODEX-UPDATED\n") + mcp := projectCodexRead(t, filepath.Join(f.source, "mcp.json")) + projectCodexWrite(t, filepath.Join(f.source, "mcp.json"), strings.ReplaceAll(mcp, "https://managed.test/mcp", "https://managed.test/v2")) + if err := os.Rename(r.Delivery.ActivePath, r.Delivery.ActivePath+"-previous"); err != nil { + t.Fatal(err) + } + next := f.stage(t, domain.CompatibilityHints{}) + next.PreviousNativeObjects, next.Replacing = old.NativeObjects, true + updated, err := f.activator.Activate(t.Context(), next) + if err != nil || updated.Activation != domain.ActivationPrepared || updated.Verification != domain.VerificationInstalled { + t.Fatalf("project Codex update = %+v, %v", updated, err) + } + if !strings.Contains(projectCodexRead(t, filepath.Join(f.a, ".agents", "skills", "docs", "SKILL.md")), "UAP-CODEX-UPDATED") || !strings.Contains(projectCodexRead(t, filepath.Join(f.a, ".codex", "config.toml")), "managed.test/v2") { + t.Fatal("native components did not receive the selected updated bytes") + } + if !reflect.DeepEqual(user, projectGeminiInventory(t, f.user)) || !reflect.DeepEqual(b, projectGeminiInventory(t, f.b)) { + t.Fatal("project update changed another scope") + } +} + +func TestProjectCodexActiveProjectionChecksExactDataLocatorWithoutWrites(t *testing.T) { + f := newProjectCodexFixture(t) + r := f.stage(t, domain.CompatibilityHints{}) + stager := providers.Stager{Registry: f.registry, Paths: pathpolicy.Policy{}} + before := projectGeminiInventory(t, r.Delivery.ActivePath) + data := filepath.Join(f.a, ".uap", "data", "owned-slot") + objects, err := stager.ProjectActiveNative(t.Context(), f.load(t), r.Plan, r.Delivery.ArtifactDigest, data) + if err != nil || len(objects) != 4 { + t.Fatalf("read-only active native projection = %+v, %v", objects, err) + } + if _, err := stager.ProjectActiveNative(t.Context(), f.load(t), r.Plan, r.Delivery.ArtifactDigest, filepath.Join(f.a, "wrong-data")); err == nil { + t.Fatal("active projection accepted another persistent data locator") + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, r.Delivery.ActivePath)) { + t.Fatal("read-only active projection changed digest-covered package") + } +} diff --git a/repotests/agentplugins_codex_project_native_e2e_test.go b/repotests/agentplugins_codex_project_native_e2e_test.go new file mode 100644 index 000000000..461b126ea --- /dev/null +++ b/repotests/agentplugins_codex_project_native_e2e_test.go @@ -0,0 +1,120 @@ +package pluginkitairepo_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +// This source-only suite uses genuine Codex skills/list after production UAP +// project operations. It does not attest activation, establish native trust, +// initialize MCP, start a thread or issue a model request. +func TestAgentpluginsCodexNativeProjectComponentsLifecycle(t *testing.T) { + if os.Getenv("AGENTPLUGINS_CODEX_PROJECT_NATIVE_E2E") != "1" { + t.Skip("opt-in source project native client execution") + } + nativeRequireDisposable(t) + if _, err := os.Lstat("/etc/codex"); !os.IsNotExist(err) { + t.Fatal("system Codex config must be absent in disposable image") + } + f := newCodexProjectNativeFixture(t) + stages := map[string]nativeStage{} + for _, name := range []string{"project_install", "project_update", "project_missing_skill_repair", "project_remove", "project_foreign_preservation"} { + stages[name] = nativeStage{Status: "not_evaluated", Reason: "not reached"} + } + stages["native_mcp_initialization"] = nativeStage{Status: "not_evaluated", Reason: "skills-only fixture; no trust mutation or MCP initialization"} + stages["model_use"] = nativeStage{Status: "not_evaluated", Reason: "discovery-only RPC; no thread or model request"} + evidence := f.evidence(t, stages) + defer func() { + f.writeEvidence(t, evidence) + if err := nativeStageFailures(stages); err != nil { + t.Error(err) + } + }() + codexNativeProjectInstall(t, f, stages) + codexNativeProjectUpdate(t, f, stages) + codexNativeProjectRepair(t, f, stages) + codexNativeProjectRemove(t, f, stages) + f.requireForeign(t) + stages["project_foreign_preservation"] = nativeStage{Status: "passed", Reason: "user config/cache and both projects' foreign configuration and skills retain exact bytes"} +} + +func codexNativeProjectPrepared(t *testing.T, result map[string]any) { + t.Helper() + outcome, err := commandsTargetOutcome(result) + if err != nil { + t.Fatal(err) + } + activation, _ := outcome["activation"].(map[string]any) + if activation["activation"] != string(domain.ActivationPrepared) || activation["verification"] != string(domain.VerificationInstalled) || activation["activation_attested"] == true { + t.Fatalf("project file outcome manufactured activation proof: %v", activation) + } +} + +func codexNativeProjectSkills(t *testing.T, f *codexProjectNativeFixture, label, command string) { + t.Helper() + if err := commandsCheckSkills(f.skills(t, label+"-project-A", f.base.Project), command); err != nil { + t.Fatal(err) + } + if leaked := f.skills(t, label+"-project-B", f.otherProject); len(leaked) != 0 { + t.Fatalf("project A skills leaked into project B: %v", leaked) + } +} + +func codexNativeProjectInstall(t *testing.T, f *codexProjectNativeFixture, stages map[string]nativeStage) { + t.Helper() + nativeAttempt(stages, "project_install", "project-add-installer.json") + added := f.run(t, "project-add", "add", f.base.Package, "--no-save") + codexNativeProjectPrepared(t, added) + if err := commandsPlanSelection(added, "echo", "wizard"); err != nil { + t.Fatal(err) + } + codexNativeProjectSkills(t, f, "skills-after-project-add", "echo") + stages["project_install"] = nativeStage{Status: "passed", Reason: "UAP project add remains prepared; genuine Codex discovers exact project-A repository skill paths and rendered bytes, with no fixture skills in project B"} +} + +func codexNativeProjectUpdate(t *testing.T, f *codexProjectNativeFixture, stages map[string]nativeStage) { + t.Helper() + nativeAttempt(stages, "project_update", "project-update-installer.json") + commandsSwapInvocation(t, f.base.Package) + updated := f.run(t, "project-update", "update", commandsPluginName) + codexNativeProjectPrepared(t, updated) + commandsRequireMutated(t, "project update", updated, true) + if err := commandsPlanSelection(updated, "wizard", "echo"); err != nil { + t.Fatal(err) + } + codexNativeProjectSkills(t, f, "skills-after-project-update", "wizard") + stages["project_update"] = nativeStage{Status: "passed", Reason: "UAP update replaces the command skill after invocation rules swap; fresh Codex discovery sees only the newly ordinary command with exact bytes"} +} + +func codexNativeProjectRepair(t *testing.T, f *codexProjectNativeFixture, stages map[string]nativeStage) { + t.Helper() + nativeAttempt(stages, "project_missing_skill_repair", "project-repair-installer.json") + missing := filepath.Join(f.base.Project, ".agents", "skills", commandsSkill("wizard")) + nativeRequireContained(t, f.base.Project, missing) + if err := os.RemoveAll(missing); err != nil { + t.Fatal(err) + } + if found := f.skills(t, "skills-with-project-command-missing", f.base.Project); len(found) != 1 || found[commandsGuideSkill] == "" { + t.Fatalf("native discovery did not observe the missing owned command skill: %v", found) + } + repaired := f.run(t, "project-repair", "repair", commandsPluginName) + codexNativeProjectPrepared(t, repaired) + codexNativeProjectSkills(t, f, "skills-after-project-repair", "wizard") + stages["project_missing_skill_repair"] = nativeStage{Status: "passed", Reason: "fresh Codex observes the missing owned directory before repair and exact restored command skill after UAP repair"} +} + +func codexNativeProjectRemove(t *testing.T, f *codexProjectNativeFixture, stages map[string]nativeStage) { + t.Helper() + nativeAttempt(stages, "project_remove", "project-remove-installer.json") + f.run(t, "project-remove", "remove", commandsPluginName) + for _, project := range []string{f.base.Project, f.otherProject} { + label := "skills-after-project-remove-" + filepath.Base(project) + if remaining := f.skills(t, label, project); len(remaining) != 0 { + t.Fatalf("native Codex still discovers removed fixture skills: %v", remaining) + } + } + stages["project_remove"] = nativeStage{Status: "passed", Reason: "fresh native Codex processes list no removed fixture skills in either project after UAP removal"} +} diff --git a/repotests/agentplugins_codex_project_native_fixture_test.go b/repotests/agentplugins_codex_project_native_fixture_test.go new file mode 100644 index 000000000..293b1d93f --- /dev/null +++ b/repotests/agentplugins_codex_project_native_fixture_test.go @@ -0,0 +1,122 @@ +package pluginkitairepo_test + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +type codexProjectNativeFixture struct { + base *nativeFixture + client, installer string + otherProject string + unchanged map[string]string +} + +func newCodexProjectNativeFixture(t *testing.T) *codexProjectNativeFixture { + t.Helper() + client := nativeBinary(t, "AGENTPLUGINS_CODEX_BIN") + installer := nativeBinary(t, "AGENTPLUGINS_INSTALLER_BIN") + if expected := os.Getenv("AGENTPLUGINS_CODEX_SHA256"); len(expected) != 64 || nativeSHA(t, client) != expected { + t.Fatal("Codex binary pin mismatch") + } + f := &codexProjectNativeFixture{base: newNativeFixture(t, true), client: client, installer: installer, unchanged: map[string]string{}} + f.base.ClientBinDir, f.otherProject = filepath.Dir(client), filepath.Join(f.base.Root, "project-B") + for _, project := range []string{f.base.Project, f.otherProject} { + if err := os.MkdirAll(filepath.Join(project, ".git"), 0700); err != nil { + t.Fatal(err) + } + } + config := "cli_auth_credentials_store = \"file\"\nmcp_oauth_credentials_store = \"file\"\n[analytics]\nenabled = false\n[feedback]\nenabled = false\n" + f.foreign(t, filepath.Join(f.base.CodexHome, "config.toml"), config) + f.foreign(t, filepath.Join(f.base.Project, ".codex", "config.toml"), "# preserve project preferences\nmodel = 'unobserved-model'\n") + f.foreign(t, filepath.Join(f.otherProject, ".codex", "config.toml"), "# preserve project B\nmodel = 'project-B-model'\n") + f.foreign(t, filepath.Join(f.base.CodexHome, "plugins", "cache", "foreign", "keep.txt"), "foreign user plugin cache\n") + for _, root := range []string{f.base.Home, f.base.Project, f.otherProject} { + f.foreign(t, filepath.Join(root, ".agents", "skills", "foreign-codex-project", "SKILL.md"), "---\nname: foreign-codex-project\ndescription: Inert foreign fixture\n---\nPreserve foreign bytes.\n") + } + copyTree(t, commandsFixtureSource(t), f.base.Package) + return f +} + +func (f *codexProjectNativeFixture) foreign(t *testing.T, path, body string) { + t.Helper() + nativeWrite(t, path, []byte(body), 0600) + f.unchanged[path] = nativeSHA(t, path) +} + +func (f *codexProjectNativeFixture) requireForeign(t *testing.T) { + t.Helper() + for path, digest := range f.unchanged { + if nativeSHA(t, path) != digest { + t.Fatalf("foreign user/project-A/project-B bytes changed: %s", path) + } + } +} + +func (f *codexProjectNativeFixture) run(t *testing.T, label string, args ...string) map[string]any { + t.Helper() + args = append(args, "--target", "codex", "--scope", "project", "--project-root", f.base.Project) + result, err := commandsRunInstaller(t, f.base.Root, f.base.Project, f.base.env(filepath.Dir(f.client)), f.installer, label, args...) + if err != nil || result["result"] != "success" { + t.Fatalf("%s failed: %v; %v", label, err, result) + } + f.requireForeign(t) + return result +} + +func (f *codexProjectNativeFixture) skills(t *testing.T, label, project string) map[string]string { + t.Helper() + selected := *f.base + selected.Project = project + r := startNativeRPC(t, &selected, f.client, label) + defer r.close() + r.must(t, "initialize", map[string]any{"clientInfo": map[string]string{"name": "uap_project_fixture", "version": "1.0.0"}, "capabilities": map[string]any{"experimentalApi": true}}) + if err := json.NewEncoder(r.in).Encode(map[string]any{"method": "initialized"}); err != nil { + t.Fatal(err) + } + found, err := codexProjectDiscoveredSkills(r.must(t, "skills/list", map[string]any{"cwds": []string{project}, "forceReload": true}), project) + if err != nil { + t.Fatal(err) + } + f.requireForeign(t) + return found +} + +func (f *codexProjectNativeFixture) evidence(t *testing.T, stages map[string]nativeStage) 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, err := nativeCommand(t, f.base, f.client, "client-version", "--version") + if want := os.Getenv("AGENTPLUGINS_CODEX_VERSION"); err != nil || want == "" || strings.TrimSpace(string(version)) != "codex-cli "+want { + t.Fatalf("wrong pinned client: %s %v", version, err) + } + return map[string]any{"started_utc": time.Now().UTC().Format(time.RFC3339Nano), "os": runtime.GOOS, "arch": runtime.GOARCH, + "client": "codex", "client_version_measured": strings.TrimSpace(string(version)), "client_sha256": nativeSHA(t, f.client), + "installer_sha256": nativeSHA(t, f.installer), "source_identity": identity, "acquisition": "local_directory", + "native_surface": "fresh app-server skills/list with exact selected-project repository paths; no thread/model/trust/MCP initialization", + "scanner": nativeProvisionScanner(t, f.base), "stages": stages} +} + +func (f *codexProjectNativeFixture) writeEvidence(t *testing.T, evidence map[string]any) { + t.Helper() + evidence["finished_utc"] = time.Now().UTC().Format(time.RFC3339Nano) + 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["artifact_sha256"] = hashes + nativeJSON(t, filepath.Join(f.base.Root, "codex-project-components-evidence.json"), evidence) +} diff --git a/repotests/agentplugins_codex_project_native_skills_test.go b/repotests/agentplugins_codex_project_native_skills_test.go new file mode 100644 index 000000000..0071510dd --- /dev/null +++ b/repotests/agentplugins_codex_project_native_skills_test.go @@ -0,0 +1,81 @@ +package pluginkitairepo_test + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + "testing" +) + +// codexProjectDiscoveredSkills accepts the exact repository paths returned by +// native skills/list. A plugin, disabled item, duplicate, wrong scope or other +// project's same-named skill cannot satisfy project discovery. +func codexProjectDiscoveredSkills(listing []byte, project string) (map[string]string, error) { + var decoded struct { + Data []struct { + CWD string `json:"cwd"` + Errors []any `json:"errors"` + Skills []struct { + Name, Path, Scope string + PluginID string `json:"pluginId"` + Enabled bool + } `json:"skills"` + } `json:"data"` + } + if err := json.Unmarshal(listing, &decoded); err != nil { + return nil, err + } + if len(decoded.Data) != 1 || !openCodeSamePath(decoded.Data[0].CWD, project) || len(decoded.Data[0].Errors) != 0 { + return nil, fmt.Errorf("Codex skills/list has a different cwd or discovery errors") + } + found := map[string]string{} + for _, skill := range decoded.Data[0].Skills { + if skill.Name != commandsGuideSkill && !strings.HasPrefix(skill.Name, commandsPluginName+"-") { + continue + } + expected := filepath.Join(project, ".agents", "skills", skill.Name, "SKILL.md") + if skill.PluginID != "" || skill.Scope != "repo" || !skill.Enabled || found[skill.Name] != "" || !openCodeSamePath(skill.Path, expected) { + return nil, fmt.Errorf("unexpected Codex project skill %q", skill.Name) + } + found[skill.Name] = skill.Path + } + return found, nil +} + +func TestCodexProjectDiscoveredSkills(t *testing.T) { + project := filepath.Join(t.TempDir(), "project") + entry := func(name string) map[string]any { + return map[string]any{"name": name, "path": filepath.Join(project, ".agents", "skills", name, "SKILL.md"), "scope": "repo", "enabled": true} + } + for _, name := range []string{"valid", "wrong-cwd", "errors", "disabled", "plugin", "scope", "path", "duplicate"} { + t.Run(name, func(t *testing.T) { + skill := entry(commandsGuideSkill) + group := map[string]any{"cwd": project, "skills": []any{skill, entry(commandsSkill("echo"))}} + switch name { + case "wrong-cwd": + group["cwd"] = project + "-other" + case "errors": + group["errors"] = []any{map[string]any{"message": "unreadable skill"}} + case "disabled": + skill["enabled"] = false + case "plugin": + skill["pluginId"] = "foreign@marketplace" + case "scope": + skill["scope"] = "user" + case "path": + skill["path"] = filepath.Join(project+"-other", ".agents", "skills", commandsGuideSkill, "SKILL.md") + case "duplicate": + group["skills"] = []any{skill, skill} + } + body, err := json.Marshal(map[string]any{"data": []any{group}}) + if err != nil { + t.Fatal(err) + } + found, err := codexProjectDiscoveredSkills(body, project) + if name == "valid" && (err != nil || len(found) != 2) || name != "valid" && err == nil { + t.Fatalf("found = %v, error = %v", found, err) + } + }) + } +} diff --git a/scripts/run-native-client-matrix.py b/scripts/run-native-client-matrix.py index 6947dc0d1..c29ca389c 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 = { + "codex": {"TestAgentpluginsCodexNativeProjectComponentsLifecycle"}, +} + + +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 component proof requires current-source Codex, 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..738f94e78 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("codex", 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["codex"] | matrix.PROJECT_TESTS["codex"]) + self.assertEqual({name for name in declared if re.search(pattern, name)}, required) + self.assertNotIn("TestAgentpluginsCodexNativeProjectComponentsLifecycle", released.CLIENT_TESTS["codex"]) + + 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"):