diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b2a20d6..02af1d1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -20,7 +20,7 @@ { "name": "agentic-engineering", "description": "The autonomous engineering system for repository portfolios and everything its agents need — engineer, read-only surveyor, meta-engineer and Flux troubleshooter agents; portfolio, product, spend and improvement workflows; GitHub, CodeRabbit review, security, library documentation, Go, Kubernetes, Flux, Talos, SvelteKit frontend, Stripe payments, engineering-practice, design, product-management, marketing, finance and productivity skills; and the MCP servers they use; configured by the consumer AGENTS.md", - "version": "7.2.0", + "version": "7.2.1", "source": "./plugins/agentic-engineering" }, { diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index b2a20d6..02af1d1 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -20,7 +20,7 @@ { "name": "agentic-engineering", "description": "The autonomous engineering system for repository portfolios and everything its agents need — engineer, read-only surveyor, meta-engineer and Flux troubleshooter agents; portfolio, product, spend and improvement workflows; GitHub, CodeRabbit review, security, library documentation, Go, Kubernetes, Flux, Talos, SvelteKit frontend, Stripe payments, engineering-practice, design, product-management, marketing, finance and productivity skills; and the MCP servers they use; configured by the consumer AGENTS.md", - "version": "7.2.0", + "version": "7.2.1", "source": "./plugins/agentic-engineering" }, { diff --git a/docs/resources.md b/docs/resources.md index a822510..0e83014 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -92,7 +92,8 @@ Every path invokes the same `flux-operator-mcp` binary, so install it first — `brew install controlplaneio-fluxcd/tap/flux-operator-mcp` or `go install github.com/controlplaneio-fluxcd/flux-operator/cmd/mcp@latest`. It refuses to start unless the `KUBECONFIG` environment variable is set, which is why the hand-written entry sets it; the bundled -plugin's `sh` wrapper sets it to `$HOME/.kube/config` when it is unset or empty. It can read every +plugin starts it through its `scripts/flux-operator-mcp.sh` launcher, which sets it to +`$HOME/.kube/config` when it is unset or empty. It can read every context in that kubeconfig, starting at the current one, because its read-only `set_kubeconfig_context` tool switches between them. See the [Flux MCP docs](https://fluxcd.control-plane.io/operator/mcp/) for read-only mode and remote diff --git a/plugins/agentic-engineering/.claude-plugin/icon.png b/plugins/agentic-engineering/.claude-plugin/icon.png new file mode 100644 index 0000000..3d88df5 Binary files /dev/null and b/plugins/agentic-engineering/.claude-plugin/icon.png differ diff --git a/plugins/agentic-engineering/.claude-plugin/plugin.json b/plugins/agentic-engineering/.claude-plugin/plugin.json index 8b9e663..e465263 100644 --- a/plugins/agentic-engineering/.claude-plugin/plugin.json +++ b/plugins/agentic-engineering/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agentic-engineering", "description": "The autonomous engineering system for repository portfolios and everything its agents need — engineer, read-only surveyor, meta-engineer and Flux troubleshooter agents; portfolio, product, spend and improvement workflows; GitHub, CodeRabbit review, security, library documentation, Go, Kubernetes, Flux, Talos, SvelteKit frontend, Stripe payments, engineering-practice, design, product-management, marketing, finance and productivity skills; and the MCP servers they use; configured by the consumer AGENTS.md", - "version": "7.2.0", + "version": "7.2.1", "author": { "name": "devantler-tech", "url": "https://github.com/devantler-tech" diff --git a/plugins/agentic-engineering/.codex-plugin/mcp.json b/plugins/agentic-engineering/.codex-plugin/mcp.json new file mode 100644 index 0000000..b6cc961 --- /dev/null +++ b/plugins/agentic-engineering/.codex-plugin/mcp.json @@ -0,0 +1,73 @@ +{ + "mcpServers": { + "aws-knowledge": { + "type": "http", + "url": "https://knowledge-mcp.global.api.aws" + }, + "cloudflare-docs": { + "type": "http", + "url": "https://docs.mcp.cloudflare.com/mcp" + }, + "context7": { + "type": "http", + "url": "https://mcp.context7.com/mcp" + }, + "coroot": { + "type": "http", + "url": "${COROOT_URL}/mcp", + "headersHelper": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/coroot-mcp-headers.sh\" \"${CLAUDE_PLUGIN_DATA}\"" + }, + "flux-operator-mcp": { + "command": "sh", + "args": [ + "scripts/flux-operator-mcp.sh" + ], + "cwd": "." + }, + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "X-MCP-Toolsets": "context,repos,issues,pull_requests,users,actions,labels,projects", + "X-MCP-Exclude-Tools": "merge_pull_request,create_repository,fork_repository,push_files,create_or_update_file,delete_file,update_pull_request_branch,update_pull_request,pull_request_review_write,add_comment_to_pending_review,actions_run_trigger,delete_repository" + }, + "headersHelper": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/github-mcp-headers.sh\" \"${CLAUDE_PLUGIN_DATA}\"" + }, + "github-ghas-tools": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/readonly", + "headers": { + "X-MCP-Toolsets": "code_security,secret_protection,security_advisories,dependabot", + "X-MCP-Readonly": "true" + }, + "headersHelper": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/github-mcp-headers.sh\" \"${CLAUDE_PLUGIN_DATA}\"" + }, + "kubescape": { + "command": "sh", + "args": [ + "scripts/kubescape-mcp.sh" + ], + "cwd": "." + }, + "siderolabs-docs": { + "type": "http", + "url": "https://docs.siderolabs.com/mcp" + }, + "stripe": { + "type": "http", + "url": "https://mcp.stripe.com" + }, + "svelte": { + "type": "http", + "url": "https://mcp.svelte.dev/mcp" + }, + "terraform-registry": { + "command": "terraform-mcp-server", + "args": [ + "stdio", + "--toolsets", + "registry" + ] + } + } +} diff --git a/plugins/agentic-engineering/.codex-plugin/plugin.json b/plugins/agentic-engineering/.codex-plugin/plugin.json index 01d20d8..ee9420d 100644 --- a/plugins/agentic-engineering/.codex-plugin/plugin.json +++ b/plugins/agentic-engineering/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agentic-engineering", "description": "The autonomous engineering system for repository portfolios and everything its agents need — engineer, read-only surveyor, meta-engineer and Flux troubleshooter agents; portfolio, product, spend and improvement workflows; GitHub, CodeRabbit review, security, library documentation, Go, Kubernetes, Flux, Talos, SvelteKit frontend, Stripe payments, engineering-practice, design, product-management, marketing, finance and productivity skills; and the MCP servers they use; configured by the consumer AGENTS.md", - "version": "7.2.0", + "version": "7.2.1", "author": { "name": "devantler-tech", "url": "https://github.com/devantler-tech" @@ -45,7 +45,7 @@ "connectors" ], "skills": "./skills/", - "mcpServers": "./.mcp.json", + "mcpServers": "./.codex-plugin/mcp.json", "apps": "./.app.json", "interface": { "displayName": "Agentic Engineering", diff --git a/plugins/agentic-engineering/.mcp.json b/plugins/agentic-engineering/.mcp.json index d84834d..6a9e049 100644 --- a/plugins/agentic-engineering/.mcp.json +++ b/plugins/agentic-engineering/.mcp.json @@ -20,8 +20,7 @@ "flux-operator-mcp": { "command": "sh", "args": [ - "-c", - "[ -n \"$KUBECONFIG\" ] || { [ -n \"$HOME\" ] || { echo 'flux-operator-mcp: set KUBECONFIG or HOME' >&2; exit 1; }; KUBECONFIG=\"$HOME/.kube/config\"; }; export KUBECONFIG; exec flux-operator-mcp serve --read-only" + "${CLAUDE_PLUGIN_ROOT}/scripts/flux-operator-mcp.sh" ] }, "github": { @@ -45,8 +44,7 @@ "kubescape": { "command": "sh", "args": [ - "-c", - "[ -n \"$KUBECONFIG\" ] || { [ -n \"$HOME\" ] || { echo 'kubescape: set KUBECONFIG or HOME' >&2; exit 1; }; KUBECONFIG=\"$HOME/.kube/config\"; }; export KUBECONFIG; exec kubescape mcpserver" + "${CLAUDE_PLUGIN_ROOT}/scripts/kubescape-mcp.sh" ] }, "siderolabs-docs": { diff --git a/plugins/agentic-engineering/CHANGELOG.md b/plugins/agentic-engineering/CHANGELOG.md index a270d35..40f176b 100644 --- a/plugins/agentic-engineering/CHANGELOG.md +++ b/plugins/agentic-engineering/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog — `agentic-engineering` +## 7.2.1 — 2026-11-07 + +The plugin now passes the Claude plugin directory's blocking checks and carries the listing +details it asks for. Nothing a consumer configures changes. + +**Fixed** + +- `flux-operator-mcp` and `kubescape` start from POSIX `sh` scripts the plugin ships, + [`scripts/flux-operator-mcp.sh`](scripts/flux-operator-mcp.sh) and + [`scripts/kubescape-mcp.sh`](scripts/kubescape-mcp.sh), instead of an inline `sh -c` program the + directory's validator cannot follow. They resolve `KUBECONFIG` exactly as before: + `$HOME/.kube/config` when it is unset or empty, any other value unchanged. `.mcp.json` names them + by `${CLAUDE_PLUGIN_ROOT}`, which Claude Code and VS Code substitute. Codex does not, so it now + loads [`.codex-plugin/mcp.json`](.codex-plugin/mcp.json), a mirror of `.mcp.json` that runs the + two scripts by their plugin-relative paths from the plugin's directory. Copilot CLI's handling of + `${CLAUDE_PLUGIN_ROOT}` there is not verified. +- The `github-mcp-headers.sh` self-test no longer ships stub values shaped like GitHub tokens, which + the directory's secret scan reported as a secret in a shipped file. + +**Changed** + +- `kubescape` scans no longer send Kubescape's version check, which carries a cluster ID and the + node count, to Kubescape's update service: the launcher sets `KS_SKIP_UPDATE_CHECK=true` unless + you set it yourself. + +**Added** + +- [`PRIVACY.md`](PRIVACY.md): the plugin collects no data itself; the policy lists what each bundled + server sends and to whom. The README links it. +- `.claude-plugin/icon.png`, the Devantler Tech orb at 512 × 512, for the directory listing. +- The README's Claude Code install line. + ## 7.2.0 — 2026-11-05 **Added** — `coroot`, the MCP endpoint at `/mcp` that Coroot serves itself, which accepts diff --git a/plugins/agentic-engineering/PRIVACY.md b/plugins/agentic-engineering/PRIVACY.md new file mode 100644 index 0000000..cf235c5 --- /dev/null +++ b/plugins/agentic-engineering/PRIVACY.md @@ -0,0 +1,74 @@ +# Privacy + +The `agentic-engineering` plugin is a set of files that runs inside your own AI coding tool: +skills, agents, helper scripts and MCP server definitions. Devantler Tech runs no service for it. +The plugin has no backend and sends no telemetry, and Devantler Tech collects, stores, shares and +sells no personal data through it. + +What the plugin's servers send, and to whom, is set out below. Your conversation, including what +those servers return, also goes to the model provider your AI tool uses, under that provider's +terms rather than this policy. + +## On your machine + +- **Skills and agents** are instructions your tool loads. Anything they do, such as running `gh` + or `git`, runs through your tool's own tools and permissions. +- **GitHub sign-in (Claude Code only).** + [`scripts/github-mcp-headers.sh`](scripts/github-mcp-headers.sh) reads a GitHub token from a + `github-token` file you create in the plugin's data directory, or else from your GitHub CLI + login. It hands the token to Claude Code, which sends it only to GitHub's MCP server as the + `Authorization` header of the bundled `github` and `github-ghas-tools` servers. The script stores + the token nowhere and never writes it to an error message or a log. +- **Cluster servers.** [`scripts/flux-operator-mcp.sh`](scripts/flux-operator-mcp.sh) and + [`scripts/kubescape-mcp.sh`](scripts/kubescape-mcp.sh) start the Flux and Kubescape servers against + the clusters in your kubeconfig. They talk to your clusters, and Kubescape also downloads its + released policy bundle from GitHub each time it starts. Kubescape's scans would also send a + version check to Kubescape's update service, carrying a cluster ID, the node count, the + frameworks scanned and the client version. The launcher turns that check off by setting + `KS_SKIP_UPDATE_CHECK=true`, unless you set that variable yourself. +- **Coroot (Claude Code only).** `coroot` connects to your own Coroot instance at the address in + `COROOT_URL`. [`scripts/coroot-mcp-headers.sh`](scripts/coroot-mcp-headers.sh) reads that + instance's address from `coroot-url` and a service-account API key from `coroot-api-key`, both + files you create in the plugin's data directory. It hands the key to Claude Code as a bearer + token only when Claude Code is connecting to that pinned address followed by `/mcp`. What the + agents ask Coroot, and what it answers, stays between your tool and your Coroot. +- **Terraform Registry server.** `terraform-registry` runs locally and looks providers and modules + up in HashiCorp's public Terraform Registry. + +## Hosted servers + +Every other bundled server is hosted by a third party. When an agent calls one of its tools, the +request goes to that service, together with what the agent passes it: a search, a repository or +package name, a version, or code. Each service handles it under its own privacy policy. + +| Server | Operated by | +|---|---| +| `aws-knowledge` | Amazon Web Services | +| `cloudflare-docs` | Cloudflare | +| `context7` | Upstash | +| `github`, `github-ghas-tools` | GitHub | +| `siderolabs-docs` | Sidero Labs | +| `stripe` | Stripe, once you sign in to it | +| `svelte` | The Svelte maintainers | + +In Codex, the plugin also offers optional app connectors for iCloud Mail, iCloud Calendar, +LinkedIn and Codex Security. None connects unless you choose it. Each then works under its +operator's terms. + +## Your choices + +- Disable any server in your tool's MCP settings to stop it contacting its service. Denying single + tools does not stop the connection. +- The only files the plugin reads that you create are the optional `github-token`, `coroot-url` + and `coroot-api-key` in its data directory. Delete them at any time. Claude Code removes that + directory when you uninstall the plugin from the last place it is installed, unless you pass + `--keep-data`. +- Uninstalling the plugin stops all of the above. + +## Changes and contact + +Changes to this policy ship with the plugin and are listed in [`CHANGELOG.md`](CHANGELOG.md). +Ask questions in the repository's +[issues](https://github.com/devantler-tech/agent-plugins/issues). Report a privacy or security +problem privately through the repository's +[security advisories](https://github.com/devantler-tech/agent-plugins/security/advisories/new). diff --git a/plugins/agentic-engineering/README.md b/plugins/agentic-engineering/README.md index 5a6d3ff..f215a48 100644 --- a/plugins/agentic-engineering/README.md +++ b/plugins/agentic-engineering/README.md @@ -7,11 +7,22 @@ the [engineering toolbox](#the-engineering-toolbox) and [MCP servers](#bundled-m agents work with. The generic role lives here; each consumer supplies its organization-specific configuration through its canonical `AGENTS.md`. +Install it in Claude Code (2.1.275 or later) with: + +```text +/plugin install agentic-engineering --marketplace devantler-tech/agent-plugins +``` + +The [installation guide](../../docs/installation.md) covers Copilot CLI, VS Code and Codex. + Released versions and the upgrade steps each breaking release needs are in the [changelog](CHANGELOG.md). A plugin's version is its cache key, so an install that never moves off an old version keeps serving that version's definitions — check what a deployment actually loaded before assuming it has a change the changelog lists. +[Privacy](https://github.com/devantler-tech/agent-plugins/blob/main/plugins/agentic-engineering/PRIVACY.md): +the plugin collects no data itself; that policy lists what each bundled server sends and to whom. + ## What it includes Four agents: @@ -230,8 +241,12 @@ Until all three are set, Claude Code shows a missing-variable warning or a faile Claude-only `headersHelper`, which is not yet verified on each tool; configure Coroot for them with their own MCP settings. -`kubescape` runs `kubescape mcpserver` from the Kubescape CLI, through the same `sh` wrapper as -the Flux server, so it reads every cluster in your kubeconfig the same way. Its tools read the +`kubescape` runs `kubescape mcpserver` from the Kubescape CLI, through +[`scripts/kubescape-mcp.sh`](scripts/kubescape-mcp.sh), which resolves the kubeconfig the same way +as the Flux server's launcher, so it reads every cluster in your kubeconfig the same way. The +launcher also sets `KS_SKIP_UPDATE_CHECK=true` unless you set it yourself, so the agents' scans +send no version check to Kubescape's update service; that check would carry a cluster ID, the node +count, the frameworks scanned and the client version. Its tools read the cluster and the results the in-cluster Kubescape operator stores. Its only write-shaped calls are server-side dry-run patches, which change nothing; despite its name, `apply_remediation` never applies a fix (Kubescape v4.0.17 sends every patch it makes as a server-side dry run), and @@ -273,8 +288,18 @@ A local server whose binary is missing reports a failed connection and leaves th The Flux server starts with `--read-only`, so it can read, trace and diff but never apply, delete, reconcile, suspend or resume; hand fixes to your GitOps delivery instead. `flux-operator-mcp` refuses to start unless the `KUBECONFIG` environment variable is set, so the plugin starts it -through `sh`, which sets `KUBECONFIG` to `$HOME/.kube/config` when your environment leaves it -unset or empty and passes any non-empty value unchanged. It can read every cluster in that +through [`scripts/flux-operator-mcp.sh`](scripts/flux-operator-mcp.sh), which sets `KUBECONFIG` to +`$HOME/.kube/config` when your environment leaves it unset or empty and passes any non-empty value +unchanged. Both cluster servers start from a POSIX `sh` script the plugin ships rather than from an +inline shell program, so a plugin directory's validator can read what they run. `.mcp.json` names +each script by `${CLAUDE_PLUGIN_ROOT}`, which Claude Code and VS Code substitute. Codex substitutes +no such variable, so it loads [`.codex-plugin/mcp.json`](.codex-plugin/mcp.json) instead: the same +servers, with the two scripts named relative to the plugin, run from the plugin's own directory. +`validate-manifests.sh` keeps that file a mirror of `.mcp.json`. Copilot CLI's handling of +`${CLAUDE_PLUGIN_ROOT}` in a plugin's `.mcp.json` is not verified; a tool that does not substitute +it reports both cluster servers as failed connections and leaves the rest working. Under Codex, +`kubescape` resolves a relative path you give its local-scan tools against the plugin's directory, +so give those tools absolute paths. `flux-operator-mcp` can read every cluster in that kubeconfig, not only the current context: its read-only `set_kubeconfig_context` tool switches between them, and it can read workload logs (Secret values are masked by default). Every session that loads this plugin gets that read access, including unattended runs. To narrow it, point diff --git a/plugins/agentic-engineering/plugin.json b/plugins/agentic-engineering/plugin.json index 8b9e663..e465263 100644 --- a/plugins/agentic-engineering/plugin.json +++ b/plugins/agentic-engineering/plugin.json @@ -1,7 +1,7 @@ { "name": "agentic-engineering", "description": "The autonomous engineering system for repository portfolios and everything its agents need — engineer, read-only surveyor, meta-engineer and Flux troubleshooter agents; portfolio, product, spend and improvement workflows; GitHub, CodeRabbit review, security, library documentation, Go, Kubernetes, Flux, Talos, SvelteKit frontend, Stripe payments, engineering-practice, design, product-management, marketing, finance and productivity skills; and the MCP servers they use; configured by the consumer AGENTS.md", - "version": "7.2.0", + "version": "7.2.1", "author": { "name": "devantler-tech", "url": "https://github.com/devantler-tech" diff --git a/plugins/agentic-engineering/scripts/flux-operator-mcp.sh b/plugins/agentic-engineering/scripts/flux-operator-mcp.sh new file mode 100755 index 0000000..fbe7658 --- /dev/null +++ b/plugins/agentic-engineering/scripts/flux-operator-mcp.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Start the bundled `flux-operator-mcp` server read-only. ../.mcp.json runs this file with `sh` as +# the server's command, and ../.codex-plugin/mcp.json runs it by its plugin-relative path, so a +# plugin directory's validator can read exactly what the plugin starts instead of an inline `sh -c` +# program it cannot follow. +# +# flux-operator-mcp refuses to start unless the KUBECONFIG environment variable is set. This sets +# it to $HOME/.kube/config when the environment leaves it unset or empty, and passes any +# non-empty value through unchanged. `--read-only` keeps the server from applying, deleting, +# reconciling, suspending or resuming anything. +# +# The server speaks MCP over stdin and stdout, so this script writes nothing to stdout itself and +# replaces itself with the server. It is POSIX sh, run by `sh`, which reads no BASH_ENV file that +# could print onto that stream first. +# +# Usage: flux-operator-mcp.sh (no arguments) +# Exit codes: the server's own; 1 neither KUBECONFIG nor HOME is set; 2 usage error; 127 the +# flux-operator-mcp binary is not on PATH. +set -eu + +if [ $# -gt 0 ]; then + echo 'usage: flux-operator-mcp.sh' >&2 + exit 2 +fi + +if [ -z "${KUBECONFIG:-}" ]; then + if [ -z "${HOME:-}" ]; then + echo 'flux-operator-mcp: set KUBECONFIG or HOME' >&2 + exit 1 + fi + KUBECONFIG="$HOME/.kube/config" +fi +export KUBECONFIG + +exec flux-operator-mcp serve --read-only diff --git a/plugins/agentic-engineering/scripts/flux-operator-mcp.test.sh b/plugins/agentic-engineering/scripts/flux-operator-mcp.test.sh new file mode 100755 index 0000000..954babe --- /dev/null +++ b/plugins/agentic-engineering/scripts/flux-operator-mcp.test.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Hermetic contract for flux-operator-mcp.sh: a stub `flux-operator-mcp` stands in for the server, +# so no cluster and no real kubeconfig is used. The launcher runs under `sh`, as both MCP files +# start it. +set -euo pipefail +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +plugin_root=$(cd "$here/.." && pwd) +launcher="$here/flux-operator-mcp.sh" +bash_bin=$(command -v bash) +sh_bin=$(command -v sh) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" "$work/empty" "$work/home" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +# The stub records its arguments and the KUBECONFIG it received, then echoes stdin to stdout the +# way a stdio MCP server answers, so a test can prove the launcher passes the protocol streams +# through untouched. It uses bash builtins only: the hermetic PATH holds nothing but the stub. It runs +# as `bash -p`, which reads no BASH_ENV file, so anything that file prints came from the launcher. +cat > "$work/bin/flux-operator-mcp" < "$work/args" +printf '%s' "\${KUBECONFIG-}" > "$work/kubeconfig" +while IFS= read -r line || [ -n "\$line" ]; do printf '%s\n' "\$line"; done +STUB +chmod +x "$work/bin/flux-operator-mcp" + +run() { # expected-exit launcher-args... -- env-assignments... + local expected=$1 status=0 args=() + shift + while [ $# -gt 0 ] && [ "$1" != -- ]; do args+=("$1"); shift; done + [ $# -gt 0 ] && shift + rm -f "$work/args" "$work/kubeconfig" + printf '{"jsonrpc":"2.0","id":1,"method":"ping"}\n' | + env -i "$@" "$sh_bin" "$launcher" ${args[@]+"${args[@]}"} > "$work/out" 2> "$work/err" || + status=$? + [ "$status" -eq "$expected" ] || fail "exit $status, expected $expected: $(cat "$work/err")" +} + +expect_server() { # kubeconfig description + [ "$(cat "$work/args")" = "serve --read-only" ] || + fail "$2: the server did not start with exactly 'serve --read-only': $(cat "$work/args")" + [ "$(cat "$work/kubeconfig")" = "$1" ] || fail "$2: KUBECONFIG was $(cat "$work/kubeconfig"), expected $1" + [ "$(cat "$work/out")" = '{"jsonrpc":"2.0","id":1,"method":"ping"}' ] || + fail "$2: stdout carried more than the server's own output: $(cat "$work/out")" + [ ! -s "$work/err" ] || fail "$2: wrote to stderr: $(cat "$work/err")" +} + +# 1. With KUBECONFIG unset, the server reads $HOME/.kube/config. +run 0 -- PATH="$work/bin" HOME="$work/home" +expect_server "$work/home/.kube/config" "unset KUBECONFIG" + +# 2. An empty KUBECONFIG counts as unset, as the server itself would refuse it. +run 0 -- PATH="$work/bin" HOME="$work/home" KUBECONFIG= +expect_server "$work/home/.kube/config" "empty KUBECONFIG" + +# 3. A non-empty KUBECONFIG, including a list of files, passes through unchanged. +run 0 -- PATH="$work/bin" HOME="$work/home" KUBECONFIG="$work/a.yaml:$work/b yaml" +expect_server "$work/a.yaml:$work/b yaml" "explicit KUBECONFIG" + +# 4. Neither KUBECONFIG nor HOME: refuse, name the fix, and never start the server. +run 1 -- PATH="$work/bin" +[ ! -e "$work/args" ] || fail "started the server without KUBECONFIG or HOME" +[ ! -s "$work/out" ] || fail "wrote to stdout without KUBECONFIG or HOME" +grep -q 'set KUBECONFIG or HOME' "$work/err" || fail "the refusal does not name the fix: $(cat "$work/err")" + +# 5. Arguments are a usage error, so nothing can drop or override --read-only. +run 2 --read-write -- PATH="$work/bin" HOME="$work/home" +[ ! -e "$work/args" ] || fail "started the server despite a usage error" +[ ! -s "$work/out" ] || fail "wrote to stdout for a usage error" + +# 6. A missing server binary fails with the shell's not-found status and no stdout. +run 127 -- PATH="$work/empty" HOME="$work/home" +[ ! -s "$work/out" ] || fail "wrote to stdout without a server binary" + +# 7. A BASH_ENV file that prints must not reach the protocol stream: `sh` never reads it. +printf '%s\n' 'echo from-bash-env' > "$work/bash-env" +run 0 -- PATH="$work/bin" HOME="$work/home" BASH_ENV="$work/bash-env" +expect_server "$work/home/.kube/config" "BASH_ENV set" + +# 8. Both MCP files start this launcher with `sh`: Claude Code and VS Code by its +# ${CLAUDE_PLUGIN_ROOT} path, Codex by its plugin-relative path from cwd ".", which Codex joins +# onto the plugin root. Each spelling must name this file. +jq -e '.mcpServers["flux-operator-mcp"] == {command: "sh", args: ["${CLAUDE_PLUGIN_ROOT}/scripts/flux-operator-mcp.sh"]}' \ + "$plugin_root/.mcp.json" > /dev/null || fail ".mcp.json does not start flux-operator-mcp through this launcher" +jq -e '.mcpServers["flux-operator-mcp"] == {command: "sh", args: ["scripts/flux-operator-mcp.sh"], cwd: "."}' \ + "$plugin_root/.codex-plugin/mcp.json" > /dev/null || fail ".codex-plugin/mcp.json does not start flux-operator-mcp through this launcher" +[ "$plugin_root/scripts/flux-operator-mcp.sh" -ef "$launcher" ] || fail "the configured path is not this launcher" +rm -f "$work/args" +(cd "$plugin_root" && printf '\n' | env -i PATH="$work/bin" HOME="$work/home" "$sh_bin" scripts/flux-operator-mcp.sh) \ + > /dev/null 2>&1 || fail "the Codex spelling does not start the server from the plugin root" +[ "$(cat "$work/args")" = "serve --read-only" ] || fail "the Codex spelling started something else" + +printf 'flux-operator-mcp: PASS\n' diff --git a/plugins/agentic-engineering/scripts/github-mcp-headers.test.sh b/plugins/agentic-engineering/scripts/github-mcp-headers.test.sh index 3ed0122..d79aeec 100755 --- a/plugins/agentic-engineering/scripts/github-mcp-headers.test.sh +++ b/plugins/agentic-engineering/scripts/github-mcp-headers.test.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash # Hermetic contract for github-mcp-headers.sh: a stub `gh` stands in for the GitHub CLI, so no # network and no real credential is used. +# +# The stub tokens are deliberately not shaped like GitHub tokens (no `gho_`, `ghp_` or +# `github_pat_` prefix). The helper accepts any token of letters, digits and underscores, so the +# shape proves nothing here, while a realistic one makes secret scanners, the Claude plugin +# directory's among them, report this shipped file as holding a secret. set -euo pipefail here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) helper="$here/github-mcp-headers.sh" @@ -43,25 +48,25 @@ expect_header() { # token description } # 1. The GitHub CLI's github.com login supplies the token when no data directory is given. -run 0 -- PATH="$work/bin" STUB_TOKEN=gho_cliToken123 -expect_header gho_cliToken123 "gh token header" +run 0 -- PATH="$work/bin" STUB_TOKEN=stubCliLoginToken +expect_header stubCliLoginToken "gh token header" [ "$(cat "$work/gh-calls")" = "auth token --hostname github.com" ] || fail "gh was not asked for exactly the github.com token: $(cat "$work/gh-calls")" # 2. A github-token file in the data directory wins, and gh is never consulted. -printf 'github_pat_Narrow_42\n' > "$work/data/github-token" -run 0 "$work/data" -- PATH="$work/bin" STUB_TOKEN=gho_cliToken123 -expect_header github_pat_Narrow_42 "token file header" +printf 'stubNarrowFileToken\n' > "$work/data/github-token" +run 0 "$work/data" -- PATH="$work/bin" STUB_TOKEN=stubCliLoginToken +expect_header stubNarrowFileToken "token file header" [ ! -e "$work/gh-calls" ] || fail "gh was called although the token file exists" # 3. A data directory without the file falls back to the github.com login. -run 0 "$work/nodata" -- PATH="$work/bin" STUB_TOKEN=gho_cliToken123 -expect_header gho_cliToken123 "fallback header" +run 0 "$work/nodata" -- PATH="$work/bin" STUB_TOKEN=stubCliLoginToken +expect_header stubCliLoginToken "fallback header" # 4. A credential-named variable is never read: Claude Code strips those from a plugin helper, so # relying on one would silently send the broader login instead. -run 0 -- PATH="$work/bin" STUB_TOKEN=gho_cliToken123 GITHUB_SECURITY_MCP_TOKEN=github_pat_Ignored_1 -expect_header gho_cliToken123 "credential variable ignored" +run 0 -- PATH="$work/bin" STUB_TOKEN=stubCliLoginToken GITHUB_SECURITY_MCP_TOKEN=stubIgnoredEnvToken +expect_header stubCliLoginToken "credential variable ignored" # 5. A failing GitHub CLI login yields no output and exit 1, never an empty bearer. run 1 -- PATH="$work/bin" STUB_FAIL=1 @@ -75,7 +80,7 @@ run 1 "$work/nodata" -- PATH="$work/empty" # A configured token file that cannot be used must fail without consulting gh, so a working gh is # on PATH for these cases: a helper that fell back to it would exit 0 and be caught. refused_without_fallback() { # description helper-arg - run 1 "$2" -- PATH="$work/bin" STUB_TOKEN=gho_cliToken123 + run 1 "$2" -- PATH="$work/bin" STUB_TOKEN=stubCliLoginToken [ ! -s "$work/out" ] || fail "printed output for $1" [ ! -e "$work/gh-calls" ] || fail "fell back to gh for $1" } @@ -105,22 +110,22 @@ refused_without_fallback "a data path that is not a directory" "$work/notadir" # 9. An unreadable token file is a failure, not a silent fallback to the broader login. if [ "$(id -u)" -ne 0 ]; then - printf 'github_pat_Narrow_42\n' > "$work/data/github-token" + printf 'stubNarrowFileToken\n' > "$work/data/github-token" chmod 000 "$work/data/github-token" - run 1 "$work/data" -- PATH="$work/bin" STUB_TOKEN=gho_cliToken123 + run 1 "$work/data" -- PATH="$work/bin" STUB_TOKEN=stubCliLoginToken [ ! -s "$work/out" ] || fail "printed output for an unreadable token file" [ ! -e "$work/gh-calls" ] || fail "fell back to gh for an unreadable token file" chmod 600 "$work/data/github-token" # 9a. A data directory that cannot be searched hides the token file, so it fails closed. mkdir -p "$work/locked" - printf 'github_pat_Narrow_42\n' > "$work/locked/github-token" + printf 'stubNarrowFileToken\n' > "$work/locked/github-token" chmod 600 "$work/locked" refused_without_fallback "an unsearchable data directory" "$work/locked" chmod 700 "$work/locked" fi # 10. More than one argument is a usage error. -run 2 "$work/data" extra -- PATH="$work/bin" STUB_TOKEN=gho_cliToken123 +run 2 "$work/data" extra -- PATH="$work/bin" STUB_TOKEN=stubCliLoginToken [ ! -s "$work/out" ] || fail "printed output for a usage error" # 11. The two servers this helper signs in keep their limits. github's exclusions remove the diff --git a/plugins/agentic-engineering/scripts/kubescape-mcp.sh b/plugins/agentic-engineering/scripts/kubescape-mcp.sh new file mode 100755 index 0000000..9fe9af1 --- /dev/null +++ b/plugins/agentic-engineering/scripts/kubescape-mcp.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# Start the bundled `kubescape` MCP server (`kubescape mcpserver`). ../.mcp.json runs this file with +# `sh` as the server's command, and ../.codex-plugin/mcp.json runs it by its plugin-relative path, +# so a plugin directory's validator can read exactly what the plugin starts instead of an inline +# `sh -c` program it cannot follow. +# +# It resolves the kubeconfig the same way as ./flux-operator-mcp.sh, so both servers read the same +# clusters: KUBECONFIG is set to $HOME/.kube/config when the environment leaves it unset or empty, +# and any non-empty value passes through unchanged. +# +# Kubescape's scans otherwise send a version check to Kubescape's update service carrying a cluster +# ID, the node count, the frameworks scanned and the client version. This sets +# KS_SKIP_UPDATE_CHECK=true when the environment leaves it unset or empty, so the agents' scans send +# none; set it yourself to choose otherwise. +# +# The server speaks MCP over stdin and stdout, so this script writes nothing to stdout itself and +# replaces itself with the server. It is POSIX sh, run by `sh`, which reads no BASH_ENV file that +# could print onto that stream first. +# +# Usage: kubescape-mcp.sh (no arguments) +# Exit codes: the server's own; 1 neither KUBECONFIG nor HOME is set; 2 usage error; 127 the +# kubescape binary is not on PATH. +set -eu + +if [ $# -gt 0 ]; then + echo 'usage: kubescape-mcp.sh' >&2 + exit 2 +fi + +if [ -z "${KUBECONFIG:-}" ]; then + if [ -z "${HOME:-}" ]; then + echo 'kubescape: set KUBECONFIG or HOME' >&2 + exit 1 + fi + KUBECONFIG="$HOME/.kube/config" +fi +export KUBECONFIG + +if [ -z "${KS_SKIP_UPDATE_CHECK:-}" ]; then + KS_SKIP_UPDATE_CHECK=true +fi +export KS_SKIP_UPDATE_CHECK + +exec kubescape mcpserver diff --git a/plugins/agentic-engineering/scripts/kubescape-mcp.test.sh b/plugins/agentic-engineering/scripts/kubescape-mcp.test.sh new file mode 100755 index 0000000..6d1e43f --- /dev/null +++ b/plugins/agentic-engineering/scripts/kubescape-mcp.test.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Hermetic contract for kubescape-mcp.sh: a stub `kubescape` stands in for the server, so no cluster +# and no real kubeconfig is used. The launcher runs under `sh`, as both MCP files start it. +set -euo pipefail +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +plugin_root=$(cd "$here/.." && pwd) +launcher="$here/kubescape-mcp.sh" +bash_bin=$(command -v bash) +sh_bin=$(command -v sh) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" "$work/empty" "$work/home" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +# The stub records its arguments, the KUBECONFIG and the KS_SKIP_UPDATE_CHECK it received, then +# echoes stdin to stdout the way a stdio MCP server answers, so a test can prove the launcher passes +# the protocol streams through untouched. It uses bash builtins only: the hermetic PATH holds +# nothing but the stub. It runs as `bash -p`, which reads no BASH_ENV file, so anything that file +# prints came from the launcher. +cat > "$work/bin/kubescape" < "$work/args" +printf '%s' "\${KUBECONFIG-}" > "$work/kubeconfig" +printf '%s' "\${KS_SKIP_UPDATE_CHECK-}" > "$work/skip" +while IFS= read -r line || [ -n "\$line" ]; do printf '%s\n' "\$line"; done +STUB +chmod +x "$work/bin/kubescape" + +run() { # expected-exit launcher-args... -- env-assignments... + local expected=$1 status=0 args=() + shift + while [ $# -gt 0 ] && [ "$1" != -- ]; do args+=("$1"); shift; done + [ $# -gt 0 ] && shift + rm -f "$work/args" "$work/kubeconfig" "$work/skip" + printf '{"jsonrpc":"2.0","id":1,"method":"ping"}\n' | + env -i "$@" "$sh_bin" "$launcher" ${args[@]+"${args[@]}"} > "$work/out" 2> "$work/err" || + status=$? + [ "$status" -eq "$expected" ] || fail "exit $status, expected $expected: $(cat "$work/err")" +} + +expect_server() { # kubeconfig skip-update-check description + [ "$(cat "$work/args")" = "mcpserver" ] || + fail "$3: the server did not start with exactly 'mcpserver': $(cat "$work/args")" + [ "$(cat "$work/kubeconfig")" = "$1" ] || fail "$3: KUBECONFIG was $(cat "$work/kubeconfig"), expected $1" + [ "$(cat "$work/skip")" = "$2" ] || fail "$3: KS_SKIP_UPDATE_CHECK was $(cat "$work/skip"), expected $2" + [ "$(cat "$work/out")" = '{"jsonrpc":"2.0","id":1,"method":"ping"}' ] || + fail "$3: stdout carried more than the server's own output: $(cat "$work/out")" + [ ! -s "$work/err" ] || fail "$3: wrote to stderr: $(cat "$work/err")" +} + +# 1. With KUBECONFIG unset, the server reads $HOME/.kube/config, and sends no version check. +run 0 -- PATH="$work/bin" HOME="$work/home" +expect_server "$work/home/.kube/config" true "unset KUBECONFIG" + +# 2. An empty KUBECONFIG counts as unset, as the Flux server's launcher treats it. +run 0 -- PATH="$work/bin" HOME="$work/home" KUBECONFIG= +expect_server "$work/home/.kube/config" true "empty KUBECONFIG" + +# 3. A non-empty KUBECONFIG, including a list of files, passes through unchanged. +run 0 -- PATH="$work/bin" HOME="$work/home" KUBECONFIG="$work/a.yaml:$work/b yaml" +expect_server "$work/a.yaml:$work/b yaml" true "explicit KUBECONFIG" + +# 4. A user's own KS_SKIP_UPDATE_CHECK wins; an empty one counts as unset. +run 0 -- PATH="$work/bin" HOME="$work/home" KS_SKIP_UPDATE_CHECK=false +expect_server "$work/home/.kube/config" false "explicit KS_SKIP_UPDATE_CHECK" +run 0 -- PATH="$work/bin" HOME="$work/home" KS_SKIP_UPDATE_CHECK= +expect_server "$work/home/.kube/config" true "empty KS_SKIP_UPDATE_CHECK" + +# 5. Neither KUBECONFIG nor HOME: refuse, name the fix, and never start the server. +run 1 -- PATH="$work/bin" +[ ! -e "$work/args" ] || fail "started the server without KUBECONFIG or HOME" +[ ! -s "$work/out" ] || fail "wrote to stdout without KUBECONFIG or HOME" +grep -q 'set KUBECONFIG or HOME' "$work/err" || fail "the refusal does not name the fix: $(cat "$work/err")" + +# 6. Arguments are a usage error, so nothing can change what the server is started with. +run 2 --verbose -- PATH="$work/bin" HOME="$work/home" +[ ! -e "$work/args" ] || fail "started the server despite a usage error" +[ ! -s "$work/out" ] || fail "wrote to stdout for a usage error" + +# 7. A missing server binary fails with the shell's not-found status and no stdout. +run 127 -- PATH="$work/empty" HOME="$work/home" +[ ! -s "$work/out" ] || fail "wrote to stdout without a server binary" + +# 8. A BASH_ENV file that prints must not reach the protocol stream: `sh` never reads it. +printf '%s\n' 'echo from-bash-env' > "$work/bash-env" +run 0 -- PATH="$work/bin" HOME="$work/home" BASH_ENV="$work/bash-env" +expect_server "$work/home/.kube/config" true "BASH_ENV set" + +# 9. Both MCP files start this launcher with `sh`: Claude Code and VS Code by its +# ${CLAUDE_PLUGIN_ROOT} path, Codex by its plugin-relative path from cwd ".", which Codex joins +# onto the plugin root. Each spelling must name this file. +jq -e '.mcpServers.kubescape == {command: "sh", args: ["${CLAUDE_PLUGIN_ROOT}/scripts/kubescape-mcp.sh"]}' \ + "$plugin_root/.mcp.json" > /dev/null || fail ".mcp.json does not start kubescape through this launcher" +jq -e '.mcpServers.kubescape == {command: "sh", args: ["scripts/kubescape-mcp.sh"], cwd: "."}' \ + "$plugin_root/.codex-plugin/mcp.json" > /dev/null || fail ".codex-plugin/mcp.json does not start kubescape through this launcher" +[ "$plugin_root/scripts/kubescape-mcp.sh" -ef "$launcher" ] || fail "the configured path is not this launcher" +rm -f "$work/args" +(cd "$plugin_root" && printf '\n' | env -i PATH="$work/bin" HOME="$work/home" "$sh_bin" scripts/kubescape-mcp.sh) \ + > /dev/null 2>&1 || fail "the Codex spelling does not start the server from the plugin root" +[ "$(cat "$work/args")" = "mcpserver" ] || fail "the Codex spelling started something else" + +printf 'kubescape-mcp: PASS\n' diff --git a/scripts/validate-manifests.sh b/scripts/validate-manifests.sh index b396cd7..795918d 100755 --- a/scripts/validate-manifests.sh +++ b/scripts/validate-manifests.sh @@ -422,6 +422,36 @@ validate_component_paths() { done } +# Codex's own MCP file mirrors .mcp.json server for server. A stdio server whose command or arguments +# use ${CLAUDE_PLUGIN_ROOT} is the only difference allowed: Codex runs it from cwd "." (which it joins +# onto the plugin root) with every argument's leading "${CLAUDE_PLUGIN_ROOT}/" removed. Nothing in the +# Codex file may still name the variable, since Codex would pass it through literally. +validate_codex_mcp_mirror() { + local claude_mcp="$1" codex_mcp="$2" mirrors + if ! regular_packaged_file "$codex_mcp"; then + echo "::error::$codex_mcp: the Codex MCP file must be a regular packaged file"; return 1 + fi + if ! json_object_unique "$codex_mcp"; then echo "::error::Invalid $codex_mcp"; return 1; fi + validate_mcp_json "$codex_mcp" || return 1 + if ! mirrors=$(jq -r --slurpfile claude "$claude_mcp" ' + def names_root: type == "string" and contains("${CLAUDE_PLUGIN_ROOT}"); + def starts_from_root: ((.command // "") | names_root) or any((.args // [])[]; names_root); + def codex_form: + if starts_from_root then + . + {cwd: ".", args: [(.args // [])[] | if type == "string" then sub("^\\$\\{CLAUDE_PLUGIN_ROOT\\}/"; "") else . end]} + else . end; + (($claude[0] | del(.mcpServers)) == del(.mcpServers)) and + (($claude[0].mcpServers | map_values(codex_form)) == .mcpServers) and + all(.mcpServers[]; starts_from_root | not)' "$codex_mcp" 2>/dev/null) || + { [ "$mirrors" != true ] && [ "$mirrors" != false ]; }; then + echo "::error::$codex_mcp: cannot compare the Codex MCP file with $claude_mcp"; return 1 + fi + if [ "$mirrors" != true ]; then + echo "::error::$codex_mcp must mirror $claude_mcp, with each \${CLAUDE_PLUGIN_ROOT} launch path made plugin-relative under cwd \".\"" + return 1 + fi +} + # An optional Codex manifest (ADR 0019). Codex reads .codex-plugin/plugin.json before # .claude-plugin/plugin.json and resolves only the component paths it declares, so it must repeat # the portable manifest exactly, name the plugin's own skills/ and .mcp.json, and add nothing else @@ -454,11 +484,32 @@ validate_codex_manifest() { elif ! jq -e 'has("skills") | not' "$codex_pj" > /dev/null; then echo "::error::$codex_pj: 'skills' must be omitted when the plugin has no skills/"; ok=0 fi + # Codex substitutes no ${CLAUDE_PLUGIN_ROOT} in a server's command or arguments; it only joins a + # relative cwd onto the plugin root. A plugin whose .mcp.json starts a server from that variable + # therefore gives Codex its own .codex-plugin/mcp.json, which must mirror .mcp.json exactly except + # that those launch paths are plugin-relative and run from cwd ".". + local codex_mcp="$plugin_dir/.codex-plugin/mcp.json" uses_root if [ -e "$plugin_dir/.mcp.json" ] || [ -L "$plugin_dir/.mcp.json" ]; then - jq -e '.mcpServers == "./.mcp.json"' "$codex_pj" > /dev/null || - { echo "::error::$codex_pj: 'mcpServers' must be \"./.mcp.json\" so Codex loads the bundled servers"; ok=0; } + if [ -e "$codex_mcp" ] || [ -L "$codex_mcp" ]; then + jq -e '.mcpServers == "./.codex-plugin/mcp.json"' "$codex_pj" > /dev/null || + { echo "::error::$codex_pj: 'mcpServers' must be \"./.codex-plugin/mcp.json\" so Codex loads its own launch paths"; ok=0; } + validate_codex_mcp_mirror "$plugin_dir/.mcp.json" "$codex_mcp" || ok=0 + else + jq -e '.mcpServers == "./.mcp.json"' "$codex_pj" > /dev/null || + { echo "::error::$codex_pj: 'mcpServers' must be \"./.mcp.json\" so Codex loads the bundled servers"; ok=0; } + if ! uses_root=$(jq -r '[.mcpServers[]? | ((.command // ""), (.args // [])[]) | strings + | select(contains("${CLAUDE_PLUGIN_ROOT}"))] | length > 0' "$plugin_dir/.mcp.json" 2>/dev/null) || + { [ "$uses_root" != true ] && [ "$uses_root" != false ]; }; then + echo "::error::$plugin_dir/.mcp.json: cannot tell whether a server starts from \${CLAUDE_PLUGIN_ROOT}"; ok=0 + elif [ "$uses_root" = true ]; then + echo "::error::$plugin_dir/.mcp.json starts a server from \${CLAUDE_PLUGIN_ROOT}, which Codex does not substitute; add .codex-plugin/mcp.json with plugin-relative launch paths" + ok=0 + fi + fi elif ! jq -e 'has("mcpServers") | not' "$codex_pj" > /dev/null; then echo "::error::$codex_pj: 'mcpServers' must be omitted when the plugin has no .mcp.json"; ok=0 + elif [ -e "$codex_mcp" ] || [ -L "$codex_mcp" ]; then + echo "::error::$codex_mcp: a Codex MCP file needs the .mcp.json it mirrors"; ok=0 fi if ! jq -e '(has("interface") | not) or (.interface | type == "object")' "$codex_pj" > /dev/null; then echo "::error::$codex_pj: 'interface' must be an object"; ok=0 diff --git a/scripts/validate-manifests.test.sh b/scripts/validate-manifests.test.sh index 36422d6..0642bba 100755 --- a/scripts/validate-manifests.test.sh +++ b/scripts/validate-manifests.test.sh @@ -2362,6 +2362,65 @@ check_fail "a symlinked Codex manifest fails" \ d=$(codex_fixture); printf '%s\n' '{"name":"alpha","name":"other"}' > "$d/plugins/alpha/.codex-plugin/plugin.json" check_fail "an ambiguous Codex manifest fails" "Invalid plugins/alpha/.codex-plugin/plugin.json" "$d" +# Codex substitutes no ${CLAUDE_PLUGIN_ROOT} in a server's command or arguments; it only joins a +# relative cwd onto the plugin root. A plugin that starts a server from that variable gives Codex its +# own .codex-plugin/mcp.json, mirroring .mcp.json with those launch paths made plugin-relative. +# shellcheck disable=SC2016 +root_launch='{"mcpServers":{"local":{"command":"sh","args":["${CLAUDE_PLUGIN_ROOT}/scripts/run.sh"]},"remote":{"type":"http","url":"https://example.com/mcp","headersHelper":"sh \"${CLAUDE_PLUGIN_ROOT}/h.sh\""}}}' +codex_mcp_fixture() { # Codex fixture whose .mcp.json starts a server from ${CLAUDE_PLUGIN_ROOT}, with a correct mirror + local d p + d=$(codex_fixture); p=$d/plugins/alpha + printf '%s\n' "$root_launch" > "$p/.mcp.json" + # shellcheck disable=SC2016 + sed 's/`example-skill` | Alpha plugin/`example-skill`, `local`, `remote` | Alpha plugin/' "$d/docs/plugins.md" > "$d/tmp" && mv "$d/tmp" "$d/docs/plugins.md" + # shellcheck disable=SC2016 + printf '%s\n' '{"mcpServers":{"local":{"command":"sh","args":["scripts/run.sh"],"cwd":"."},"remote":{"type":"http","url":"https://example.com/mcp","headersHelper":"sh \"${CLAUDE_PLUGIN_ROOT}/h.sh\""}}}' \ + > "$p/.codex-plugin/mcp.json" + codex_edit "$d" '.mcpServers = "./.codex-plugin/mcp.json"' .codex-plugin/plugin.json + printf '%s' "$d" +} +check_pass "a Codex MCP mirror with plugin-relative launch paths passes" "$(codex_mcp_fixture)" + +d=$(codex_mcp_fixture); rm "$d/plugins/alpha/.codex-plugin/mcp.json" +codex_edit "$d" '.mcpServers = "./.mcp.json"' .codex-plugin/plugin.json +check_fail "Codex loading a \${CLAUDE_PLUGIN_ROOT} launch path fails" \ + "which Codex does not substitute" "$d" + +d=$(codex_mcp_fixture); codex_edit "$d" '.mcpServers = "./.mcp.json"' .codex-plugin/plugin.json +check_fail "a Codex manifest that ignores its MCP mirror fails" \ + "'mcpServers' must be \"./.codex-plugin/mcp.json\"" "$d" + +# shellcheck disable=SC2016 +for edit in 'del(.mcpServers.remote)' '.mcpServers.local.args = ["${CLAUDE_PLUGIN_ROOT}/scripts/run.sh"]' \ + 'del(.mcpServers.local.cwd)' '.mcpServers.local.cwd = "scripts"' '.mcpServers.local.args = ["scripts/other.sh"]' \ + '.mcpServers.local.command = "bash"' '.mcpServers.local.env = {"X": "1"}' '.mcpServers.remote.url = "https://example.com/other"' \ + '.extra = true'; do + d=$(codex_mcp_fixture); codex_edit "$d" "$edit" .codex-plugin/mcp.json + check_fail "a Codex MCP mirror that drifts ($edit) fails" \ + "plugins/alpha/.codex-plugin/mcp.json must mirror plugins/alpha/.mcp.json" "$d" +done + +# A command that names the variable cannot be made plugin-relative, so no mirror can carry it. +d=$(codex_mcp_fixture) +# shellcheck disable=SC2016 +codex_edit "$d" '.mcpServers.local = {"command": "${CLAUDE_PLUGIN_ROOT}/bin/run"}' .mcp.json +# shellcheck disable=SC2016 +codex_edit "$d" '.mcpServers.local = {"command": "${CLAUDE_PLUGIN_ROOT}/bin/run", "cwd": ".", "args": []}' .codex-plugin/mcp.json +check_fail "a mirror still naming \${CLAUDE_PLUGIN_ROOT} fails" \ + "plugins/alpha/.codex-plugin/mcp.json must mirror plugins/alpha/.mcp.json" "$d" + +d=$(codex_mcp_fixture); printf '%s\n' 'not json' > "$d/plugins/alpha/.codex-plugin/mcp.json" +check_fail "an unreadable Codex MCP mirror fails" "plugins/alpha/.codex-plugin/mcp.json" "$d" + +d=$(codex_mcp_fixture); mv "$d/plugins/alpha/.codex-plugin/mcp.json" "$d/mirror.json" +ln -s ../../../mirror.json "$d/plugins/alpha/.codex-plugin/mcp.json" +check_fail "a symlinked Codex MCP mirror fails" \ + "plugins/alpha/.codex-plugin/mcp.json: the Codex MCP file must be a regular packaged file" "$d" + +d=$(codex_fixture); printf '%s\n' '{"mcpServers":{"local":{"command":"sh","args":["scripts/run.sh"],"cwd":"."}}}' \ + > "$d/plugins/alpha/.codex-plugin/mcp.json" +check_fail "a Codex MCP mirror without .mcp.json fails" "a Codex MCP file needs the .mcp.json it mirrors" "$d" + echo "-----------------------------------------" echo "validate-manifests.sh self-test: $pass passed, $fail failed" [ "$fail" -eq 0 ]