From d72fb80fc44a0c6d544337bb2c55150185ae4faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:31:59 +0200 Subject: [PATCH 1/5] Add Joomla MCP to PHP images --- CHANGELOG.md | 15 ++ README.md | 126 +++++---- VERSION | 2 +- docs/credentials-and-brokers.md | 75 +++++- docs/image-variants.md | 20 +- docs/joomla-mcp-study.md | 251 ++++++++++++++++-- docs/known-limitations.md | 24 +- docs/local-image-builds.md | 16 +- docs/mcp-catalog.md | 24 +- docs/quick-start.md | 94 ++++++- docs/roadmap.md | 36 ++- docs/versioning-and-promotion.md | 15 +- .../joomla-mcp/sites.readonly.example.json | 24 ++ .../usr/local/libexec/vdm-opencode-session | 45 +++- image/provision/finalize.sh | 2 + image/provision/joomla-mcp.sh | 117 ++++++++ incus/profiles/generated/images/full.yaml | 2 +- incus/profiles/generated/images/php.yaml | 2 +- manifest/generated/platform.env | 7 +- manifest/images.yaml | 16 +- manifest/mcp-catalog.yaml | 14 +- manifest/sources.lock.yaml | 10 +- manifest/toolchain.env | 5 +- scripts/build-image.sh | 2 + scripts/configure-joomla-mcp.sh | 185 +++++++++++++ scripts/create-runtime-env.sh | 34 ++- scripts/lib/joomla-mcp.sh | 55 ++++ scripts/lib/runtime-credentials.sh | 87 ++++++ scripts/mcp-toggle.sh | 25 +- scripts/occtl.sh | 9 +- scripts/start-session.sh | 44 +-- scripts/test-joomla-mcp.sh | 85 ++++++ scripts/verify-image.sh | 25 ++ tests/fixtures/joomla/incus | 31 +++ tests/fixtures/session/incus | 5 + tests/joomla-mcp-configure.sh | 46 ++++ tests/joomla-mcp-integration.sh | 65 +++++ tests/runtime-credentials.sh | 55 ++++ tests/security-static.py | 26 ++ tests/session-secret-transport.sh | 3 +- tests/test_platform_manifest.py | 19 +- tests/validate-repository.sh | 12 +- 42 files changed, 1532 insertions(+), 223 deletions(-) create mode 100644 image/files/etc/joomla-mcp/sites.readonly.example.json create mode 100755 image/provision/joomla-mcp.sh create mode 100755 scripts/configure-joomla-mcp.sh create mode 100755 scripts/lib/joomla-mcp.sh create mode 100755 scripts/lib/runtime-credentials.sh create mode 100755 scripts/test-joomla-mcp.sh create mode 100755 tests/fixtures/joomla/incus create mode 100755 tests/joomla-mcp-configure.sh create mode 100755 tests/joomla-mcp-integration.sh create mode 100755 tests/runtime-credentials.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ebde63..b743095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.3.0-rc.2 - 2026-07-27 + +- Added `joomla-mcp` as a manifest-owned composable capability for PHP and full images. +- Pinned and verified `@joomengine/joomla-mcp@0.7.0` from the public JoomEngine repository. +- Added stable stdio, HTTP, live-test and configuration-check wrappers while keeping Joomla disabled and + unconfigured in every published image. +- Added HTTPS-only single-site profile generation with read-only defaults, bounded write profiles, separate update + credentials and indefinite grants disabled. +- Extended the systemd credential path with Joomla site, approval and update values, duplicate-key rejection and + required-credential preflight. +- Added a non-mutating in-image read test that retains redacted evidence under the workspace without placing + credential values in Incus arguments. +- Added build verification, manifest/source-lock tests, stale-repository detection and complete operator + documentation for deployment, use, testing, network boundaries and promotion. + ## 0.3.0-rc.1 - 2026-07-22 - Made `manifest/images.yaml` the schema-validated authority for composition, diff --git a/README.md b/README.md index 099736f..0f9ec86 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,99 @@ # VDM OpenCode Platform -A company-wide, version-controlled platform definition for hardened OpenCode agent virtual machines on Incus. +A version-controlled platform definition for hardened OpenCode agent virtual machines on Incus. The repository is the authority; Incus images and Gitea packages are generated artifacts. -The repository is the authority. Incus images and Gitea packages are generated artifacts. +## Images -## What this repository provides +The platform publishes six Ubuntu 24.04 image variants: `base`, `php`, `python`, `cpp`, `typescript`, and `full`. `manifest/images.yaml` is the single authority for composition, policies, sizes, architectures, profiles, and CI matrices. -- A common Ubuntu 24.04 VM base and five image variants: `base`, `php`, `python`, `cpp`, `typescript`, and `full`. -- Repeatable Incus projects, networks, ACL scaffolding, profiles, resource limits, image builds and launches. -- OpenCode agents for orchestration, architecture, implementation, testing, review, security, browser QA, - documentation, PHP/Joomla, Python, C/C++, and TypeScript. -- Local Git MCP and Playwright browser MCP integration. -- Disabled-by-default remote MCP definitions for GitHub, Gitea, Nextcloud and speech-to-text. -- Runtime-only credential handling under the guest's `/run` tmpfs. -- A reference external broker stack for OpenBao, an LLM gateway and a TLS reverse proxy. -- Provider-correct GitHub Actions and Gitea Actions workflows. -- Downloadable GitHub workflow artifacts, fully local image releases, and durable Gitea Generic Package publication. -- Image sanitisation and secret-scanning tests. -- Host-side voice recording and transcription through any OpenAI-compatible transcription endpoint. +The `php` and `full` images contain the exact public package `@joomengine/joomla-mcp@0.7.0`. Other images do not contain Joomla MCP. The integration is registered as a local stdio MCP server and remains disabled and unconfigured in every published image. -## Recommended image strategy - -Use specialised images for normal work and the full image only when a project genuinely crosses languages. - -| Image | Primary use | Browser MCP | -|---|---|---| -| `base` | Repository analysis, documentation, light automation | Disabled | -| `php` | Joomla, JCB and PHP services | Enabled | -| `python` | APIs, automation, MCP services and data tooling | Disabled | -| `cpp` | Native Linux, graphics and systems development | Disabled | -| `typescript` | Web applications, Node.js and browser automation | Enabled | -| `full` | Mixed-language platform work | Enabled | - -Every image inherits the same security policy and agent framework. - -## First deployment +## Build and launch the PHP image ```bash cp .env.example .env -# Edit only host settings here. Do not add secrets. +# Edit only host settings. Do not add secrets. ./tests/validate-repository.sh ./scripts/bootstrap-host.sh ./scripts/apply-incus.sh + export LOCAL_BUILD_VARIANTS=php ./scripts/local-image-release.sh -./scripts/launch-vm.sh php opencode-llewellyn -./scripts/start-session.sh opencode-llewellyn +./scripts/launch-vm.sh php opencode-joomla +``` + +An existing VM created from an older image does not gain newly installed packages. Launch or replace it from the new versioned PHP image. + +## Configure Joomla MCP + +Configure one fixed public HTTPS Joomla origin. The default profile is read-only: + +```bash +./scripts/occtl.sh joomla-configure \ + opencode-joomla \ + https://www.example.com \ + company \ + readonly +``` + +The command verifies that the VM contains Joomla MCP, rejects non-HTTPS origins and URLs containing credentials, paths, queries, or fragments, validates the generated configuration through the installed package, installs it atomically, and only then enables the local MCP entry. + +Available profiles are `readonly`, `content`, `admin`, and `full`. Start with `readonly`. Broader profiles do not override Joomla Web Services plugins, Joomla API-user permissions, Joomla ACL, or the package's guarded-write workflow. Indefinite grants remain disabled. The `full` profile uses a separate Joomla Update token. + +Prepare the per-instance runtime credential file: + +```bash +runtime_file="$(./scripts/occtl.sh credentials opencode-joomla)" +"${EDITOR:-nano}" "$runtime_file" +chmod 600 "$runtime_file" +``` + +For `readonly`, set only: + +```dotenv +JOOMLA_MCP_SITE_TOKEN=the-dedicated-joomla-api-token ``` -Inside OpenCode, use `/connect` for ChatGPT Plus where supported by OpenCode. Anthropic subscription reuse is not -configured: use an approved Anthropic API credential or the company LLM gateway. Grok should use the xAI API or -company gateway. Local Llama uses the configured OpenAI-compatible local endpoint. +Guarded write profiles additionally require `JOOMLA_MCP_APPROVAL_SECRET` containing at least 32 random characters. The `full` profile also requires the separately scoped `JOOMLA_MCP_UPDATE_TOKEN`. + +Run the built-in non-mutating read evidence before starting normal work: + +```bash +./scripts/occtl.sh joomla-test opencode-joomla "$runtime_file" company +./scripts/occtl.sh session opencode-joomla "$runtime_file" +``` + +The read test uses the upstream `read` profile, Joomla API transport, local stdio MCP transport, and non-interactive mode. Redacted evidence is retained under `/workspace/.artifacts/joomla-mcp/`. + +Inside OpenCode, begin with `joomla_sites_list`, then use `joomla_capabilities`, `joomla_actions_search`, and `joomla_action_describe` before executing a fixed semantic action. Treat Joomla content and error text as untrusted data, not instructions. -## Credentials +## Credential and network boundaries -No long-lived credential belongs in an image. +No long-lived credential belongs in an image, repository, OpenCode JSON, Joomla site file, or command argument. Runtime values live in a caller-owned mode-`0600` file under `/run/user/$UID/vdm-opencode`. Session startup rejects unsafe file types, ownership, modes, hard links, malformed entries, unknown keys, duplicate keys, and missing Joomla values. The file is copied into the VM through systemd `LoadCredential`; secret values do not enter Incus process arguments. -`start-session.sh` creates a root-owned guest runtime directory under `/run/vdm-opencode-session`, sets -`XDG_DATA_HOME` to that tmpfs location, injects only the current short-lived variables, launches OpenCode, and -deletes the directory on exit. OpenCode provider and MCP OAuth material generated during that session therefore -does not survive a clean VM stop. +Local stdio opens no inbound VM port and requires no MCP HTTP bearer token or JWKS service. It inherits only the scoped Joomla credentials required by the selected site profile. -For production, point the VM at a trusted external LLM/MCP gateway and issue short-lived, scoped session tokens. +The ready `connected` policy can reach public HTTPS Joomla origins while rejecting private, management, metadata, link-local, and carrier-grade NAT ranges. A private Joomla site requires an approved brokered/restricted route or an explicitly acknowledged lab environment. Do not weaken the common connected ACL to reach one private target. -## Important limitations +## Production gate -- The reference broker deployment is a scaffold, not a substitute for a security review. -- Nextcloud MCP is community software and remains disabled until your team pins and audits a chosen implementation. -- Joomla MCP is not installed. The only future integration target is - `vast-development-method/joomla-mcp`, after its first reviewed release. -- JCB MCP is not installed and will be added only after the internal repository and first reviewed release exist. -- Incus ACLs cannot safely express every hostname-based egress rule. Enforce strict outbound access at a proxy or - firewall that supports DNS-aware policy. -- Incus packages are architecture-specific and must be produced by a trusted hardware-virtualisation runner. +JoomEngine MCP for Joomla is installed and configuration is fail-closed, but upstream does not claim complete production certification for every Joomla action family. Before promoting broader profiles, build and import the real PHP/full images on independent Incus hosts, retain the included read evidence, and run the upstream mutation and recovery matrix only against an explicitly disposable Joomla site. ## Documentation Start with: - `docs/quick-start.md` -- `docs/architecture.md` -- `docs/security-model.md` - `docs/image-variants.md` - `docs/mcp-catalog.md` -- `docs/provider-integration.md` -- `docs/browser-testing.md` +- `docs/joomla-mcp-study.md` - `docs/credentials-and-brokers.md` -- `docs/scaling-and-operations.md` - `docs/local-image-builds.md` -- `docs/gitea-packages.md` -- `docs/github-and-gitea.md` -- `docs/backup-and-migration.md` +- `docs/versioning-and-promotion.md` ## Licence and ownership Copyright (C) 2026 Vast Development Method. -The original repository source is licensed under the GNU General Public -License version 3 only (`GPL-3.0-only`). See `LICENSE` and `COPYRIGHT`. -Generated images contain independently licensed third-party software whose -licences are recorded by the release SBOM. +The repository source is licensed under GNU GPL version 3 only (`GPL-3.0-only`). Generated images contain independently licensed third-party software whose licences are recorded by the release SBOM. diff --git a/VERSION b/VERSION index 8f2e859..c23d448 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0-rc.1 +0.3.0-rc.2 diff --git a/docs/credentials-and-brokers.md b/docs/credentials-and-brokers.md index 9bf1710..2424b44 100644 --- a/docs/credentials-and-brokers.md +++ b/docs/credentials-and-brokers.md @@ -2,17 +2,50 @@ ## Initial mode -`start-session.sh` injects environment values into one OpenCode process and redirects OpenCode's data directory to -guest tmpfs. On exit it deletes that guest runtime directory. +`start-session.sh` injects an allowlisted environment file into one bounded +systemd unit and redirects OpenCode's data directory to guest tmpfs. On exit it +deletes the guest credential and runtime directory. -This reduces persistence but does not hide a token from the agent process. Therefore all injected tokens must be: +The host file is created by: -- short-lived; +```bash +./scripts/occtl.sh credentials INSTANCE +``` + +It lives under `/run/user/$UID/vdm-opencode`, must be a regular single-link file +owned by the caller with mode `0600`, and is never passed as a string of secret +values to `incus exec`. + +This reduces persistence but does not hide a token from the process that must +use it. Therefore all injected tokens must be: + +- short-lived where the upstream supports leases; - narrowly scoped; - revocable; - budget-limited where applicable; - unusable outside approved services. +Unknown names, duplicate names, carriage returns and malformed lines fail +closed on both the host and guest side. + +## Local Joomla MCP path + +The PHP and full images launch JoomEngine MCP for Joomla over local stdio. That +path does not expose an inbound MCP endpoint and does not use +`JOOMLA_MCP_URL`/`JOOMLA_MCP_TOKEN`. + +The site definition contains only the fixed HTTPS origin, alias, toolsets and +credential variable names. The protected session supplies: + +- `JOOMLA_MCP_SITE_TOKEN` for the dedicated Joomla Web Services API identity; +- `JOOMLA_MCP_APPROVAL_SECRET` only when guarded writes are configured; +- `JOOMLA_MCP_UPDATE_TOKEN` only when the explicit full/core-update profile is configured. + +`start-session.sh` reads the non-secret site configuration and refuses to start +when a referenced credential is absent. The built-in read-only live test uses +the identical systemd credential path. Neither helper accepts a token as a +command-line argument. + ## Corporate mode A trusted broker host should retain long-lived upstream credentials and expose only capabilities: @@ -20,7 +53,9 @@ A trusted broker host should retain long-lived upstream credentials and expose o - LLM gateway virtual keys with model, rate and spend limits; - GitHub/Gitea MCP sessions limited to selected repositories and operations; - Nextcloud MCP sessions limited to selected users, apps and tagged folders; -- future VDM-owned Joomla/JCB integrations, added only after their release gates pass. +- scoped Joomla API identities or short-lived equivalents limited to approved + sites and component permissions; +- future JCB integration only after its release gates pass. The VM must never receive a Vaultwarden or OpenBao identity capable of reading all upstream secrets. @@ -41,30 +76,42 @@ Autonomous sessions use one route for each provider: repositories and operations. - Gitea and remote MCP: the gateway retains the service credential and checks session identity, repository, tool, read/write level and expiry per request. +- Joomla: the present local stdio path receives a dedicated Joomla bearer token + through the bounded session. A future broker may exchange site/session + identity for a shorter-lived site capability, but must not broaden Joomla ACL. - VPS access: the VM creates an ephemeral key; OpenBao SSH CA signs the public key for 10–15 minutes; the network policy permits only the audited bastion. -The current environment-file bridge is transitional. It now uses systemd +The current environment-file bridge is transitional. It uses systemd credentials and avoids secret values in host process arguments, but child commands can still exercise or inspect exported session variables. Release -promotion requires the tokenless root-owned relay described above. +promotion requires the tokenless root-owned relay described above for provider +paths where such a relay is practical. ## OpenBao -OpenBao is suitable for machine identity, short leases, revocation and audit. The agent should not query secret -paths directly. A broker exchanges an OpenBao-authenticated service identity for downstream capability tokens. +OpenBao is suitable for machine identity, short leases, revocation and audit. +The agent should not query broad secret paths directly. A broker exchanges an +OpenBao-authenticated service identity for downstream capability tokens. + +For Joomla, preserve separate authorities for the normal API identity and +Joomla Update. Do not issue a single universal token merely because the +platform supports several toolsets. ## Vaultwarden -Vaultwarden remains useful for human-controlled bootstrap and recovery secrets. Do not give the autonomous VM a -universal Vaultwarden account. +Vaultwarden remains useful for human-controlled bootstrap and recovery secrets. +Do not give the autonomous VM a universal Vaultwarden account. ## Runtime file -Create it under host tmpfs: +Create or upgrade it under host tmpfs: ```bash -./scripts/create-runtime-env.sh INSTANCE +runtime_file="$(./scripts/occtl.sh credentials INSTANCE)" +"${EDITOR:-nano}" "$runtime_file" +chmod 600 "$runtime_file" ``` -The script refuses to use a file not protected by mode `0600`. +The script refuses unsafe file types and preserves existing values while adding +new allowlisted Joomla keys during a platform upgrade. diff --git a/docs/image-variants.md b/docs/image-variants.md index e7e20a9..c81ad03 100644 --- a/docs/image-variants.md +++ b/docs/image-variants.md @@ -8,11 +8,11 @@ matrix is stale. ## Release images - `base`: shell-oriented analysis and documentation. -- `php`: PHP/Joomla/JCB plus Node, TypeScript, browser and database clients. +- `php`: PHP/Joomla/JCB plus Node, TypeScript, browser, database clients and JoomEngine MCP for Joomla. - `python`: Python development, quality tools and MCP services. - `cpp`: C/C++ compilers, debuggers, build tools and analysis. - `typescript`: Node, TypeScript, browser automation and Playwright MCP. -- `full`: the union of the ready language and packaged capabilities. +- `full`: the union of the ready language and packaged capabilities, including Joomla MCP. Java, Go and Rust are intentionally absent. Android is a planned capability whose future SDK may require a JDK internally; that will not create a general @@ -20,9 +20,15 @@ Java development image. ## Build-time capabilities -Browser and database are ready composable components. Android, GPU, rootless -containers, GUI, audio and security tooling are catalogued as planned so the -schema can grow without inventing combinations or claiming unfinished support. +Browser, database and `joomla-mcp` are ready composable components. The Joomla +capability installs an exact public npm release, stable local wrappers, a +configuration validator and a disabled OpenCode stdio entry. It belongs only to +the PHP and full images; adding it to another image requires an intentional +manifest change and regenerated evidence. + +Android, GPU, rootless containers, GUI, audio and security tooling are +catalogued as planned so the schema can grow without inventing combinations or +claiming unfinished support. ## Runtime policies @@ -33,6 +39,10 @@ schema can grow without inventing combinations or claiming unfinished support. gateway/proxy is deployed and tested. - `lab`: experimental hardware policy requiring explicit acknowledgement. +A public HTTPS Joomla origin works with `connected`. A private Joomla origin +does not; use an approved gateway policy or a deliberate lab environment +instead of weakening the common ACL. + ## Resource sizes Tiny, small, medium, standard, large, xlarge and builder profiles are selected diff --git a/docs/joomla-mcp-study.md b/docs/joomla-mcp-study.md index ea84a1f..c0b22ee 100644 --- a/docs/joomla-mcp-study.md +++ b/docs/joomla-mcp-study.md @@ -1,26 +1,241 @@ -# Joomla MCP integration gate +# JoomEngine MCP for Joomla integration -Reviewed: 2026-07-22 +Reviewed: 2026-07-27 -The platform will use only the VDM-owned Joomla MCP: +## Status and authority -`https://github.com/vast-development-method/joomla-mcp` +The approved source is: -The repository does not yet contain a reviewed release, so Joomla MCP is deliberately absent from the OpenCode -runtime configuration, session environment template and install process. No third-party Joomla MCP is approved as a -fallback. +`https://github.com/joomengine/joomla-mcp` -## Activation gate +The PHP and full images install the public npm release `@joomengine/joomla-mcp@0.7.0`. The package version is exact, +the inspected source commit is recorded in `manifest/sources.lock.yaml`, the image build verifies the installed +version, and the release SBOM records the resulting dependency graph. -Add the integration only when all of the following are true: +`manifest/images.yaml` remains the composition authority. The ready `joomla-mcp` capability resolves to +`image/provision/joomla-mcp.sh`; it is not hidden inside the general PHP provisioner. -- The repository exists and publishes a versioned release. -- The release exposes a documented capability and permission model. -- Joomla service users use ordinary ACLs and are not Super Users by default. -- Read-only and destructive tool groups are independently enforceable. -- Authentication, token rotation, audit redaction and rate limits are tested. -- Joomla 6 compatibility and upgrade behaviour are verified. -- The package reference is immutable and recorded in `manifest/sources.lock.yaml`. -- The MCP entry is added to the image only after the release review passes. +Upstream currently describes broad source-backed Joomla 6.1 route coverage while retaining live-production +evidence gates for action families. This platform therefore installs and hardens the integration without claiming +that every privileged action is production-certified. Promotion still requires the real image and live-site +evidence described below. -Until then, Joomla work continues through the PHP image, the Joomla CLI/API, ordinary browser testing and Git. +## Runtime architecture + +```text +OpenCode + -> local stdio command /usr/local/bin/vdm-joomla-mcp + -> root-owned /etc/joomla-mcp/sites.json (origin, alias, toolsets; no secret) + -> session-only JOOMLA_MCP_SITE_TOKEN + -> HTTPS Joomla Web Services API +``` + +The normal container path uses local stdio: + +- no inbound VM port; +- no HTTP MCP listener; +- no MCP bearer token or JWKS endpoint; +- no site origin supplied by a model; +- no credential in OpenCode JSON or the Joomla site file; +- no arbitrary HTTP, CLI, PHP, SQL, filesystem, model, method or URL passthrough. + +The configured alias and fixed toolsets bound what the server can expose. Joomla's own Web Services plugins, API +user permissions and ACL remain authoritative beneath that boundary. + +The package's authenticated Streamable HTTP entry point remains available as +`/usr/local/bin/vdm-joomla-mcp-http`, but it is not enabled or exposed by this image integration. Deploy it only as +a separately reviewed service with TLS termination, issuer/audience/scope validation and a trusted JWKS endpoint. + +## Build and deploy + +Build a new PHP image after checking out the platform release that contains the integration: + +```bash +./tests/validate-repository.sh +export LOCAL_BUILD_VARIANTS=php +./scripts/local-image-release.sh +./scripts/launch-vm.sh php opencode-joomla +``` + +An already-running instance built from an older image does not acquire Joomla MCP automatically. Launch from the +new versioned image alias or follow the normal instance replacement workflow. + +Configure a public HTTPS Joomla origin: + +```bash +./scripts/occtl.sh joomla-configure \ + opencode-joomla \ + https://www.example.com \ + company \ + readonly +``` + +The command: + +1. verifies that the instance contains the Joomla MCP capability; +2. accepts one HTTPS origin only and rejects credentials, paths, queries and fragments; +3. renders one fixed site alias with a named profile; +4. validates the result through the installed package's own configuration loader; +5. installs it as `/etc/joomla-mcp/sites.json`, owned by `root:opencode` with mode `0640`; +6. enables only the local `joomla` OpenCode entry; +7. creates or upgrades the protected per-instance runtime credential template. + +The command never accepts a token argument. + +## Joomla preparation + +For the API path: + +1. enable **API Authentication - Web Services Joomla Token**; +2. enable **User - Joomla API Token**; +3. create a dedicated automation user and group; +4. grant `core.login.api` plus only the component permissions required by the selected profile; +5. enable only the Web Services plugins required by those toolsets; +6. generate that user's API token; +7. verify the intended API endpoint over HTTPS before enabling broader tools. + +Do not use a Super User token by default. Do not reuse a human administrator token. A read-only platform profile +does not compensate for an unnecessarily privileged Joomla identity. + +## Runtime credentials + +Create or locate the per-instance tmpfs file: + +```bash +runtime_file="$(./scripts/occtl.sh credentials opencode-joomla)" +"${EDITOR:-nano}" "$runtime_file" +chmod 600 "$runtime_file" +``` + +Supported local Joomla keys are: + +| Variable | Required by | Purpose | +|---|---|---| +| `JOOMLA_MCP_SITE_TOKEN` | every API profile | Dedicated Joomla Web Services bearer token | +| `JOOMLA_MCP_APPROVAL_SECRET` | `content`, `admin`, `full` | Signs short-lived, one-time guarded write plans; use at least 32 random characters | +| `JOOMLA_MCP_UPDATE_TOKEN` | `full` | Separately scoped Joomla Update token | + +Generate an approval secret without placing it in a command argument to the platform: + +```bash +openssl rand -hex 32 +``` + +Paste the result into the protected file. The file lives below `/run/user/$UID`, is never committed and is removed +at logout or reboot. `start-session.sh` rejects symlinks, foreign ownership, non-`0600` mode, hard links, malformed +names, unknown keys and duplicate keys. It then copies the file as a root-owned systemd credential. Secret values +do not enter Incus process arguments. + +Before OpenCode starts, the platform reads the non-secret Joomla configuration, enumerates the credential variable +names it references and fails closed when any required value is absent. The same validation is used by the +in-image read test. + +## Profiles + +`joomla-configure` supports four deliberate profiles. + +### `readonly` + +Enables discovery plus content, structure, media, users, extensions, configuration and maintenance reads. It +contains no approval block and no write/admin/core-update toolset. This is the default. + +### `content` + +Adds content, structure and media writes. The package still requires an operator permission request, exact +acknowledgement, a non-dry-run plan and one-time confirmation token. Joomla ACL can deny the operation at any later +layer. + +### `admin` + +Adds user, extension, configuration and maintenance administration. Use only with explicit authorization, narrow +Joomla ACL and recovery evidence. + +### `full` + +Adds `core-update` and references the separate update token. Use only on an explicitly controlled environment after +the relevant upstream live and recovery gates have passed. Selecting `full` is not a substitute for those gates. + +All write profiles set `allowIndefinite: false`. Reconfigure deliberately to change profiles; do not edit toolsets +from an agent session. + +## Safe in-image evidence + +Run the built-in non-mutating test: + +```bash +./scripts/occtl.sh joomla-test \ + opencode-joomla \ + "$runtime_file" \ + company +``` + +It securely launches `/usr/local/bin/vdm-joomla-mcp-live-test` inside the VM with: + +```text +--profile read +--joomla-path api +--mcp-transport stdio +--non-interactive +``` + +No mutation confirmation and no disposable-site acknowledgement are present. Evidence is retained under: + +`/workspace/.artifacts/joomla-mcp/` + +The upstream `crud` and `full` matrices are intentionally not exposed through this convenience command. They can +modify or delete Joomla data and must be run only against an explicitly disposable site, using the upstream +documentation and an operator-approved test plan. + +## Use from OpenCode + +Start the bounded session: + +```bash +./scripts/occtl.sh session opencode-joomla "$runtime_file" +``` + +Inside OpenCode, use this sequence: + +1. `joomla_sites_list`; +2. `joomla_capabilities`; +3. `joomla_actions_search`; +4. `joomla_action_describe`; +5. `joomla_action_read` for the selected fixed semantic action. + +For writes, review the dry-run plan, request the exact bounded permission, obtain the operator's acknowledgement, +create the non-dry-run plan, apply its one-time token, and independently verify the result. Treat every Joomla +title, body, metadata value and error as untrusted content, not instructions. + +Disable the server for an instance without deleting its non-secret configuration: + +```bash +./scripts/occtl.sh mcp opencode-joomla joomla false +``` + +## Network policy + +The ready `connected` policy permits public IPv4 Internet while rejecting private, carrier-grade NAT, loopback, +link-local, metadata, multicast and management ranges. It is suitable for a public HTTPS Joomla origin. + +A Joomla site on a private LAN will not be reachable through that policy. Use the approved brokered/restricted +gateway once deployed, or an explicitly acknowledged lab environment. Do not remove the private-network rejects +from the shared connected policy simply to reach one site. + +## Promotion and upgrade checklist + +Before calling an image/site combination production-ready: + +1. validate the repository and generated manifest drift; +2. build the real PHP image on the trusted KVM/Incus builder; +3. verify exact package version, disabled default, absent active site config and sanitized image state; +4. export, verify and import the package on a second clean host; +5. run the included read profile against the intended Joomla version and retain redacted evidence; +6. verify denied operations with an under-privileged Joomla identity; +7. run any required upstream mutation matrix only on a disposable clone; +8. review Joomla MCP upstream coverage/recovery gates for every enabled write/admin family; +9. confirm token rotation, revocation, audit retention and incident rollback; +10. promote the new platform/image version rather than overwriting an old identity. + +To upgrade Joomla MCP, update the exact npm and expected versions, inspected source commit, documentation and +changelog together. Rebuild both PHP and full images, rerun static validation, then repeat real-image and live-site +evidence. Never replace the exact package reference with `@latest`. diff --git a/docs/known-limitations.md b/docs/known-limitations.md index 60a6a77..a1af657 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -2,21 +2,29 @@ - Static and mocked validation is available, but the six real Incus images have not yet been built in this delivery environment. +- JoomEngine MCP for Joomla `0.7.0` is now pinned, installed and fail-closed in + PHP/full images, but upstream still retains live-production evidence gates for + action families. This repository's safe helper proves only the configured + read profile; mutation/admin certification requires disposable-site evidence + and review of the upstream coverage/recovery status. - Ubuntu and Node packages still resolve from upstream repositories. The - NodeSource setup input is content-pinned, and release manifests record the - result, but a timestamped Ubuntu/package snapshot has not yet been enabled. + NodeSource setup input is content-pinned, exact npm package versions are + verified, and release manifests record the result, but a timestamped + Ubuntu/npm package snapshot has not yet been enabled. - Syft, Grype and Cosign hooks fail closed in release mode, but a trusted builder still needs those pinned tools and a short-lived OpenBao signing identity. - AMD64 is the only promotable architecture. ARM64 identities are collision-safe but remain disabled until native ARM64 build/import evidence exists. - Connected policy denies private, metadata and management IPv4 ranges while - retaining public Internet. Host-side DNS-aware Squid policy is still required - before brokered, restricted and release policies can be unblocked. -- Session secrets no longer appear in Incus command arguments and the process - tree has a systemd-enforced TTL. The transitional session process still - exports scoped credentials to OpenCode and its children; the tokenless - root-owned relay remains a release gate. + retaining public Internet. A public HTTPS Joomla site is reachable; a private + Joomla site requires the still-gated brokered/restricted path or an explicit + lab policy. +- Session secrets no longer appear in Incus command arguments, required Joomla + credential names are preflighted, and the process tree has a + systemd-enforced TTL. The session process and its authorised children can + still inspect credentials they must use; the tokenless root-owned relay + remains a broader release gate. - OpenBao/LiteLLM policies and routes are defined but the external broker, authorization gateway, audit device, unseal, backup and revocation tests are not deployed by this repository. diff --git a/docs/local-image-builds.md b/docs/local-image-builds.md index 86b42c6..31af73e 100644 --- a/docs/local-image-builds.md +++ b/docs/local-image-builds.md @@ -36,6 +36,10 @@ Packages remain under `build/packages////`. Every package contains the Incus export, manifest, provenance and checksums; release mode additionally requires an SBOM, vulnerability scan and signature. +The PHP and full images include the exact Joomla MCP package recorded in +`manifest/toolchain.env`. They contain only a read-only example and a disabled +OpenCode entry; no active Joomla origin or credential is part of a package. + ## Publish directly to Gitea Create a dedicated Gitea package publisher account and a package-write-only token. Keep the token out of `.env`, shell history, Git, and the built images. @@ -55,7 +59,9 @@ export GITEA_PACKAGE_USER='package-publisher' unset GITEA_PACKAGE_TOKEN ``` -The package names are `vdm-opencode-`, and the package version is read from both `VERSION` and `manifest/platform.env`. The command refuses to run if those versions disagree. +The package names are `vdm-opencode-`, and the package version is read +from `VERSION` and `manifest/generated/platform.env`. The command refuses to +run if those versions disagree. ## Production tagged release @@ -63,7 +69,7 @@ For a reproducible production release, build the exact protected tag and require ```bash git fetch --tags --prune origin -git switch --detach v0.3.0-rc.1 +git switch --detach v0.3.0-rc.2 export LOCAL_BUILD_VARIANTS=all export LOCAL_REQUIRE_CLEAN=true @@ -81,7 +87,7 @@ export GITEA_PACKAGE_TOKEN unset GITEA_PACKAGE_TOKEN ``` -Replace `v0.3.0-rc.1` with the version being released. Tags must not be recreated or force-moved after packages are published. +Replace `v0.3.0-rc.2` with the version being released. Tags must not be recreated or force-moved after packages are published. ## Supported environment variables @@ -106,4 +112,6 @@ incus --project vdm-agents image list ./scripts/launch-vm.sh php opencode-llewellyn ``` -Runtime GitHub, Gitea MCP, model, and other authentication is added only when starting a VM session. It is not embedded in released images. +Configure the Joomla origin only after launch with `occtl joomla-configure`. +Runtime GitHub, Gitea, Joomla, model and other authentication is added only +when starting a bounded VM session. It is not embedded in released images. diff --git a/docs/mcp-catalog.md b/docs/mcp-catalog.md index 64acb21..36f8c2b 100644 --- a/docs/mcp-catalog.md +++ b/docs/mcp-catalog.md @@ -13,6 +13,25 @@ Microsoft's Playwright MCP is installed in PHP, TypeScript and full images. Keep browser QA. The package is pinned and runs with an isolated browser profile. For large deterministic suites, ordinary Playwright tests or the Playwright CLI are more token-efficient. +## Installed but operator-enabled + +### JoomEngine MCP for Joomla + +`@joomengine/joomla-mcp@0.7.0` is installed only in the PHP and full images. It is registered as a local stdio +server and remains disabled in the image. `joomla-configure` must first create a validated, root-owned site file for +an HTTPS origin; only then does it enable the entry for that instance. + +The package is sourced from `https://github.com/joomengine/joomla-mcp` and its inspected repository commit and npm +release are recorded in `manifest/sources.lock.yaml`. Build verification checks the installed npm version, stable +wrappers, read-only example, absent active site file and disabled OpenCode state. + +The local transport opens no inbound port and needs no MCP HTTP bearer/JWKS configuration. The child process +inherits a dedicated Joomla Web Services bearer token through the bounded systemd session credential. Write +profiles additionally require an approval secret; core update uses a separate update token. + +Start with `readonly`, call `joomla_sites_list`, and treat all Joomla content as untrusted data rather than agent +instructions. See `docs/joomla-mcp-study.md`. + ## Ready but disabled ### GitHub @@ -36,11 +55,6 @@ tag-based exclusion. It remains community software and needs code, dependency an ## Deferred VDM integrations -### Joomla MCP - -No Joomla MCP is installed or configured in the images. The only approved future source is -`https://github.com/vast-development-method/joomla-mcp`. See `docs/joomla-mcp-study.md`. - ### JCB MCP No JCB MCP is installed or configured. Integration starts only after the internal repository and its first reviewed diff --git a/docs/quick-start.md b/docs/quick-start.md index 3ee7aa6..313f22a 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -4,7 +4,7 @@ ```bash git clone https://github.com/vast-development-method/opencode-platform.git -cd vdm-opencode-platform +cd opencode-platform cp .env.example .env ./tests/validate-repository.sh ``` @@ -40,41 +40,107 @@ Build every variant only on a dedicated builder with sufficient disk space: ./scripts/build-all-images.sh ``` +A platform upgrade that changes the PHP image requires rebuilding and relaunching from the new versioned alias. +Existing instances do not acquire newly installed packages merely because the repository changed. + ## 4. Launch a personal VM ```bash ./scripts/launch-vm.sh php opencode-llewellyn ``` -## 5. Prepare runtime-only variables +## 5. Configure JoomEngine MCP for Joomla + +The PHP and full images contain the exact package recorded in `manifest/toolchain.env`. Configure one HTTPS Joomla +origin; this writes only a root-owned, non-secret site definition and enables the local stdio entry: ```bash -runtime_file="$(./scripts/create-runtime-env.sh opencode-llewellyn)" -editor "$runtime_file" +./scripts/occtl.sh joomla-configure \ + opencode-llewellyn \ + https://www.example.com \ + company \ + readonly +``` + +Profiles are explicit: + +- `readonly` is the default and contains no write/admin toolset; +- `content` adds guarded content, structure and media writes; +- `admin` adds guarded user, extension, configuration and maintenance administration; +- `full` additionally exposes `core-update` and requires a separately scoped update token. + +Do not select `content`, `admin`, or `full` merely to make a read test pass. Joomla ACLs, enabled Web Services +plugins, the package's permission grants and its one-time plans remain independent controls. + +## 6. Prepare runtime-only credentials + +```bash +runtime_file="$(./scripts/occtl.sh credentials opencode-llewellyn)" +"${EDITOR:-nano}" "$runtime_file" chmod 600 "$runtime_file" ``` -The file is under `/run/user/$UID`, which is tmpfs. Obtain only short-lived scoped tokens from the broker. +For the normal read-only profile, set only: + +```dotenv +JOOMLA_MCP_SITE_TOKEN=the-dedicated-joomla-api-token +``` + +The file is under `/run/user/$UID`, which is tmpfs. The script rejects symlinks, foreign ownership, hard links, +unexpected names and duplicate variables. Tokens are copied through systemd credentials and never placed in an +Incus command argument. -## 6. Enable required MCP servers +A guarded write profile also requires a random approval secret of at least 32 characters: ```bash -./scripts/mcp-toggle.sh opencode-llewellyn github true -./scripts/mcp-toggle.sh opencode-llewellyn gitea true -./scripts/mcp-toggle.sh opencode-llewellyn playwright true +openssl rand -hex 32 ``` -Joomla and JCB are intentionally absent and cannot be enabled until their VDM-owned releases are approved. +Place the result in `JOOMLA_MCP_APPROVAL_SECRET`. The `full` profile additionally requires +`JOOMLA_MCP_UPDATE_TOKEN`; do not reuse the normal Joomla API token. -## 7. Start OpenCode +## 7. Run the safe in-image Joomla test ```bash -./scripts/start-session.sh opencode-llewellyn "$runtime_file" +./scripts/occtl.sh joomla-test \ + opencode-llewellyn \ + "$runtime_file" \ + company ``` +This executes `joomla-mcp-live-test` inside the VM with `--profile read`, Joomla API transport and stdio MCP +transport. It has no mutation confirmation or disposable-site flag and writes redacted evidence under +`/workspace/.artifacts/joomla-mcp/`. + +The upstream `crud` and `full` live matrices are intentionally not wrapped by this convenience command. Run those +only against an explicitly disposable Joomla site after reading the upstream live-testing documentation. + +## 8. Enable other required MCP servers + +Joomla was enabled by `joomla-configure`. Other integrations remain separate: + +```bash +./scripts/occtl.sh mcp opencode-llewellyn github true +./scripts/occtl.sh mcp opencode-llewellyn gitea true +./scripts/occtl.sh mcp opencode-llewellyn playwright true +``` + +Enabling a local MCP now fails if its executable is absent. Enabling Joomla also fails if the target configuration +is absent or invalid. + +## 9. Start OpenCode + +```bash +./scripts/occtl.sh session opencode-llewellyn "$runtime_file" +``` + +The session validates every credential name referenced by the Joomla site file before starting OpenCode. Inside +OpenCode, call `joomla_sites_list`, then `joomla_capabilities`, `joomla_actions_search` and +`joomla_action_describe` before executing an action. + The guest's OpenCode data directory lives under `/run` for this session. -## 8. Configure model roles +## 10. Configure model roles After provider access is working: @@ -84,7 +150,7 @@ After provider access is working: Use exact IDs shown by `opencode models`. -## 9. Normal work +## 11. Normal work Clone only repositories assigned to the session account, create an agent branch, run tests and open a pull request. Do not merge or release directly from an autonomous session. diff --git a/docs/roadmap.md b/docs/roadmap.md index af8f42d..795a389 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,6 +7,9 @@ - Six composable release images remain: base, PHP, Python, C/C++, TypeScript and full. Android, GPU, GUI, containers, audio and security tooling are catalogued as planned and cannot enter a release matrix accidentally. +- JoomEngine MCP for Joomla is an exact, composable PHP/full capability with + fail-closed configuration, session-only credentials and a non-mutating live + read evidence path. - Incus restricted projects, fail-closed policy profiles, public-only connected egress and session TTL enforcement are defined. - Architecture-specific package identities, SBOM/Grype hooks, provenance, @@ -20,16 +23,19 @@ The platform must not be promoted from RC until all gates pass: 1. Bootstrap a clean Ubuntu host including QEMU/KVM and Incus. 2. Build all six amd64 images from the exact protected commit/tag. -3. Run language, browser, sanitisation and negative network tests. -4. Generate SBOMs, pass the vulnerability policy and sign every package. -5. Export every image and verify its signed package. -6. Import, boot and smoke-test every package on a second clean amd64 host. -7. Verify connected, offline and gateway policies against host, RFC1918, +3. Run language, browser, Joomla package/configuration, sanitisation and + negative network tests. +4. Run the Joomla MCP read profile against an approved Joomla 6.x target and + retain redacted evidence; run mutation matrices only on a disposable clone. +5. Generate SBOMs, pass the vulnerability policy and sign every package. +6. Export every image and verify its signed package. +7. Import, boot and smoke-test every package on a second clean amd64 host. +8. Verify connected, offline and gateway policies against host, RFC1918, metadata and public targets. -8. Verify cgroup TTL termination after caller loss and host reaper cleanup. -9. Exercise provider, GitHub App, Gitea MCP and short-lived SSH certificate - paths through the broker. -10. Publish the signed completion index only after all evidence is present. +9. Verify cgroup TTL termination after caller loss and host reaper cleanup. +10. Exercise provider, GitHub App, Gitea MCP and short-lived SSH certificate + paths through the broker. +11. Publish the signed completion index only after all evidence is present. ARM64 stays declared but non-promotable until equivalent native ARM64 evidence exists. @@ -40,7 +46,9 @@ exists. carefully scoped policies. - Deploy LiteLLM and MCP authorization gateways. - Replace transitional session environment credentials with the root-owned - tokenless guest relay. + tokenless guest relay where practical. +- Add a scoped Joomla credential exchange path without broadening Joomla ACL or + merging normal API and update authorities. - Configure self-hosted Squid allowlists for restricted and release policies. - Prove revocation, model budgets, repository/tool restrictions and bastion-only VPS access. @@ -54,8 +62,10 @@ exists. events, health reconciliation, warm pools and automatic retirement. - Promote images through signed candidate/stable channels. -## VDM Joomla and JCB +## Joomla and JCB -- Release and review `vast-development-method/joomla-mcp`. +- Promote the installed JoomEngine MCP for Joomla capability only after real + PHP/full image builds, second-host import, live read evidence and review of + every enabled upstream action-family gate. - Complete the JCB MCP protocol and test suite. -- Add each integration only after its own activation and credential-scope gate. +- Add JCB only after its own release, activation and credential-scope gate. diff --git a/docs/versioning-and-promotion.md b/docs/versioning-and-promotion.md index ddf6e91..1e4dce8 100644 --- a/docs/versioning-and-promotion.md +++ b/docs/versioning-and-promotion.md @@ -3,9 +3,9 @@ The repository version, image alias, Gitea package version and release tag must match. ```text -Repository tag: v0.3.0-rc.1 -Incus alias: vdm-opencode-php/0.3.0-rc.1-amd64 -Gitea package: vdm-opencode-php-amd64/0.3.0-rc.1 +Repository tag: v0.3.0-rc.2 +Incus alias: vdm-opencode-php/0.3.0-rc.2-amd64 +Gitea package: vdm-opencode-php-amd64/0.3.0-rc.2 Build manifest: /etc/vdm-opencode-platform/build.json ``` @@ -16,10 +16,11 @@ Before a release: 1. Review and deliberately update `manifest/toolchain.env` and `manifest/sources.lock.yaml`. 2. Confirm that no floating dependency or mutable GitHub Action reference was introduced. 3. Build from a clean, trusted Incus runner. -4. Run repository, guest, browser and security tests. +4. Run repository, guest, browser, Joomla MCP and security tests. 5. Export the image and verify the generated manifest and `SHA256SUMS`. -6. Pilot the image. -7. Tag the repository with the exact value from `VERSION` prefixed by `v`. -8. Retain the GitHub workflow artifact as a short-lived download and publish the durable package to Gitea. +6. Import on a second clean host and retain the Joomla read evidence for PHP/full promotion. +7. Pilot the image. +8. Tag the repository with the exact value from `VERSION` prefixed by `v`. +9. Retain the GitHub workflow artifact as a short-lived download and publish the durable package to Gitea. Do not mutate an existing released image alias. Publish a new semantic version. diff --git a/image/files/etc/joomla-mcp/sites.readonly.example.json b/image/files/etc/joomla-mcp/sites.readonly.example.json new file mode 100644 index 0000000..eb1b5d6 --- /dev/null +++ b/image/files/etc/joomla-mcp/sites.readonly.example.json @@ -0,0 +1,24 @@ +{ + "defaultSite": "company", + "sites": { + "company": { + "toolsets": [ + "discovery", + "content.read", + "structure.read", + "media.read", + "users.read", + "extensions.read", + "configuration.read", + "maintenance.read" + ], + "api": { + "baseUrl": "https://joomla.example.com", + "tokenEnv": "JOOMLA_MCP_SITE_TOKEN", + "timeoutMs": 30000, + "maxResponseBytes": 5242880, + "maxPageSize": 100 + } + } + } +} diff --git a/image/files/usr/local/libexec/vdm-opencode-session b/image/files/usr/local/libexec/vdm-opencode-session index ca425a6..137cae8 100755 --- a/image/files/usr/local/libexec/vdm-opencode-session +++ b/image/files/usr/local/libexec/vdm-opencode-session @@ -4,6 +4,7 @@ umask 077 : "${CREDENTIALS_DIRECTORY:?systemd credential directory is required}" : "${VDM_SESSION_ID:?session id is required}" +: "${VDM_SESSION_MODE:=opencode}" : "${VDM_AGENT_HOME:=/home/opencode}" : "${VDM_WORKSPACE_ROOT:=/workspace}" @@ -14,6 +15,8 @@ allowed_key() { GITEA_MCP_URL|GITEA_MCP_TOKEN|\ NEXTCLOUD_MCP_URL|NEXTCLOUD_MCP_TOKEN|\ JOOMLA_MCP_URL|JOOMLA_MCP_TOKEN|\ + JOOMLA_MCP_SITE_TOKEN|JOOMLA_MCP_UPDATE_TOKEN|\ + JOOMLA_MCP_APPROVAL_SECRET|\ JCB_MCP_URL|JCB_MCP_TOKEN|\ STT_MCP_URL|STT_MCP_TOKEN|\ VDM_LOCAL_LLM_BASE_URL|VDM_LOCAL_LLM_MODEL) return 0 ;; @@ -22,7 +25,12 @@ allowed_key() { } credential_file="$CREDENTIALS_DIRECTORY/session.env" +declare -A seen=() while IFS= read -r line || [ -n "$line" ]; do + [[ "$line" != *$'\r'* ]] || { + printf 'Runtime credential file contains a carriage return.\n' >&2 + exit 2 + } case "$line" in ""|\#*) continue ;; *=*) @@ -31,6 +39,11 @@ while IFS= read -r line || [ -n "$line" ]; do printf 'Rejected runtime credential key: %s\n' "$key" >&2 exit 2 } + [[ -z "${seen[$key]+x}" ]] || { + printf 'Rejected duplicate runtime credential key: %s\n' "$key" >&2 + exit 2 + } + seen["$key"]=1 value="${line#*=}" declare -gx "$key=$value" ;; @@ -48,4 +61,34 @@ export PATH="$VDM_AGENT_HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" export XDG_DATA_HOME="/run/vdm-opencode-$VDM_SESSION_ID/data" install -d -m 0700 "$XDG_DATA_HOME" cd "$VDM_WORKSPACE_ROOT" -exec "$VDM_AGENT_HOME/.local/bin/opencode" + +case "$VDM_SESSION_MODE" in + opencode) + exec "$VDM_AGENT_HOME/.local/bin/opencode" + ;; + joomla-read-test) + : "${VDM_JOOMLA_SITE_ALIAS:?Joomla site alias is required}" + : "${VDM_JOOMLA_TEST_OUTPUT:?Joomla test output path is required}" + [[ "$VDM_JOOMLA_SITE_ALIAS" =~ ^[A-Za-z][A-Za-z0-9_-]{0,63}$ ]] || { + printf 'Unsafe Joomla site alias.\n' >&2 + exit 2 + } + [[ "$VDM_JOOMLA_TEST_OUTPUT" == "$VDM_WORKSPACE_ROOT/"* ]] || { + printf 'Joomla test output must remain inside the workspace.\n' >&2 + exit 2 + } + install -d -m 0750 "$VDM_JOOMLA_TEST_OUTPUT" + exec /usr/local/bin/vdm-jomla-mcp-live-test \ + --config /etc/joomla-mcp/sites.json \ + --site "$VDM_JOOMLA_SITE_ALIAS" \ + --profile read \ + --joomla-path api \ + --mcp-transport stdio \ + --non-interactive \ + --output "$VDM_JOOMLA_TEST_OUTPUT" + ;; + *) + printf 'Unsupported VDM session mode: %s\n' "$VDM_SESSION_MODE" >&2 + exit 2 + ;; +esac diff --git a/image/provision/finalize.sh b/image/provision/finalize.sh index a4eedda..aafc96e 100755 --- a/image/provision/finalize.sh +++ b/image/provision/finalize.sh @@ -20,6 +20,7 @@ jq -n \ --arg os "$os_label" \ --arg opencode "$(sudo -u "$AGENT_USER" -H "$AGENT_HOME/.local/bin/opencode" --version 2>/dev/null || true)" \ --arg git_mcp "$(sudo -u "$AGENT_USER" -H "$AGENT_HOME/.local/share/pipx/venvs/mcp-server-git/bin/python" -c 'import importlib.metadata; print(importlib.metadata.version("mcp-server-git"))' 2>/dev/null || true)" \ + --arg joomla_mcp "$(npm list --global --json --depth=0 2>/dev/null | jq -r '.dependencies["@joomengine/joomla-mcp"].version // empty' 2>/dev/null || true)" \ --arg node "$(node --version 2>/dev/null || true)" \ --arg php "$(php -r 'echo PHP_VERSION;' 2>/dev/null || true)" \ --arg python "$(python3 --version 2>/dev/null || true)" \ @@ -32,6 +33,7 @@ jq -n \ toolchain: { opencode: $opencode, git_mcp: $git_mcp, + joomla_mcp: $joomla_mcp, node: $node, php: $php, python: $python diff --git a/image/provision/joomla-mcp.sh b/image/provision/joomla-mcp.sh new file mode 100755 index 0000000..bf23b2b --- /dev/null +++ b/image/provision/joomla-mcp.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +AGENT_USER="${AGENT_USER:-opencode}" +AGENT_HOME="/home/${AGENT_USER}" + +node -e ' +const [major, minor] = process.versions.node.split(".").map(Number); +if (major < 22 || (major === 22 && minor < 12)) { + process.stderr.write(`Joomla MCP requires Node.js 22.12 or newer; found ${process.versions.node}.\n`); + process.exit(1); +} +' + +npm install --global --ignore-scripts --no-audit --no-fund \ + "${JOOMLA_MCP_PACKAGE:?JOOMLA_MCP_PACKAGE is required}" + +installed_mcp="$( + npm list --global --json --depth=0 | + jq -r '.dependencies["@joomengine/joomla-mcp"].version' +)" +[ "$installed_mcp" = "${JOOMLA_MCP_EXPECTED_VERSION:?JOOMLA_MCP_EXPECTED_VERSION is required}" ] + +package_root="$(npm root --global)/@joomengine/joomla-mcp" +test -f "$package_root/dist/index.js" + +install_wrapper() { + local source_command="$1" + local wrapper_name="$2" + + cat > "/usr/local/bin/$wrapper_name" < /usr/local/bin/vdm-joomla-mcp-config-check <&2 + exit 1 +} +jq -e . "\$config" >/dev/null + +mapfile -t credential_keys < <( + jq -r ' + [ + .approval?.secretEnv, + .sites[].api?.tokenEnv, + .sites[].api?.updateTokenEnv + ] + | map(select(type == "string")) + | unique[] + ' "\$config" +) +for key in "\${credential_keys[@]}"; do + [[ "\$key" =~ ^[A-Z_][A-Z0-9_]*$ ]] || { + printf 'Unsafe credential environment name in Joomla MCP configuration: %s\n' "\$key" >&2 + exit 1 + } + case "\$key" in + JOOMLA_MCP_SITE_TOKEN|JOOMLA_MCP_APPROVAL_SECRET|JOOMLA_MCP_UPDATE_TOKEN) ;; + *) + printf 'Unsupported Joomla MCP credential reference: %s\n' "\$key" >&2 + exit 1 + ;; + esac + declare -gx "\$key=vdm-configuration-validation-placeholder-0123456789abcdef" +done + +node --input-type=module - "\$config" <<'NODE' +import { pathToFileURL } from 'node:url'; + +const packageEntry = '$package_root/dist/index.js'; +const { loadConfiguration } = await import(pathToFileURL(packageEntry).href); +const configPath = process.argv.at(-1); +await loadConfiguration(configPath); +process.stdout.write('Joomla MCP configuration is valid.\n'); +NODE +EOF +chmod 0755 /usr/local/bin/vdm-joomla-mcp-config-check + +install -d -o root -g "$AGENT_USER" -m 0750 /etc/joomla-mcp +test -f /etc/joomla-mcp/sites.readonly.example.json +rm -f /etc/joomla-mcp/sites.json + +config="$AGENT_HOME/.config/opencode/opencode.json" +tmp="$(mktemp)" +jq ' + .mcp.joomla = { + "type": "local", + "command": ["/usr/local/bin/vdm-joomla-mcp"], + "environment": { + "JOOMLA_MCP_CONFIG": "/etc/joomla-mcp/sites.json" + }, + "enabled": false, + "timeout": 30000 + } +' "$config" > "$tmp" +install -o "$AGENT_USER" -g "$AGENT_USER" -m 0640 "$tmp" "$config" +rm -f "$tmp" + +test -x /usr/local/bin/vdm-joomla-mcp +test -x /usr/local/bin/vdm-joomla-mcp-http +test -x /usr/local/bin/vdm-joomla-mcp-live-test +test -x /usr/local/bin/vdm-joomla-mcp-config-check +jq -e '.mcp.joomla.enabled == false' "$config" >/dev/null diff --git a/incus/profiles/generated/images/full.yaml b/incus/profiles/generated/images/full.yaml index 271a07d..6e5bfab 100644 --- a/incus/profiles/generated/images/full.yaml +++ b/incus/profiles/generated/images/full.yaml @@ -1,5 +1,5 @@ # Generated from manifest/images.yaml. Do not edit. -description: Mixed-language platform engineering. +description: Mixed-language platform engineering with Joomla MCP support. config: security.secureboot: 'true' user.vdm.variant: full diff --git a/incus/profiles/generated/images/php.yaml b/incus/profiles/generated/images/php.yaml index d194f95..39afe75 100644 --- a/incus/profiles/generated/images/php.yaml +++ b/incus/profiles/generated/images/php.yaml @@ -1,5 +1,5 @@ # Generated from manifest/images.yaml. Do not edit. -description: Joomla, JCB, PHP, TypeScript, database, and browser work. +description: Joomla, JCB, PHP, TypeScript, database, browser, and Joomla MCP work. config: security.secureboot: 'true' user.vdm.variant: php diff --git a/manifest/generated/platform.env b/manifest/generated/platform.env index 7585216..d0d9eee 100644 --- a/manifest/generated/platform.env +++ b/manifest/generated/platform.env @@ -1,6 +1,6 @@ # Generated from manifest/images.yaml. Do not edit. PLATFORM_NAME=vdm-opencode-platform -PLATFORM_VERSION=0.3.0-rc.1 +PLATFORM_VERSION=0.3.0-rc.2 INCUS_BASE_IMAGE=images:ubuntu/24.04/cloud INCUS_IMAGE_PREFIX=vdm-opencode PLATFORM_IMAGES=(base php python cpp typescript full) @@ -16,11 +16,11 @@ declare -Ag PLATFORM_ARCH_ALIASES=( declare -Ag PLATFORM_IMAGE_COMPONENTS=( [base]=shell - [php]='shell php node typescript browser database' + [php]='shell php node typescript browser database joomla-mcp' [python]='shell python' [cpp]='shell native' [typescript]='shell node typescript browser' - [full]='shell php python node typescript native browser database' + [full]='shell php python node typescript native browser database joomla-mcp' ) declare -Ag PLATFORM_IMAGE_DEFAULT_POLICY=( @@ -50,6 +50,7 @@ declare -Ag PLATFORM_COMPONENT_PROVISION=( [native]=cpp.sh [browser]=browser.sh [database]=database.sh + [joomla-mcp]=joomla-mcp.sh ) declare -Ag PLATFORM_POLICY_STATUS=( diff --git a/manifest/images.yaml b/manifest/images.yaml index 9861a2f..8161f70 100644 --- a/manifest/images.yaml +++ b/manifest/images.yaml @@ -2,7 +2,7 @@ schema: 2 platform: name: vdm-opencode-platform - version: 0.3.0-rc.1 + version: 0.3.0-rc.2 image_prefix: vdm-opencode base_image: images:ubuntu/24.04/cloud architectures: @@ -26,6 +26,7 @@ components: native: {status: ready, provision: cpp.sh} browser: {status: ready, provision: browser.sh} database: {status: ready, provision: database.sh} + joomla-mcp: {status: ready, provision: joomla-mcp.sh} android: {status: planned, provision: android.sh} gpu: {status: planned, provision: null} containers: {status: planned, provision: containers.sh} @@ -87,6 +88,11 @@ dimensions: phase: build status: ready components: [database] + joomla-mcp: + label: JoomEngine MCP for Joomla + phase: build + status: ready + components: [joomla-mcp] android: label: Android SDK and device tooling phase: build @@ -181,11 +187,11 @@ images: default_policy: connected default_size: small php: - purpose: Joomla, JCB, PHP, TypeScript, database, and browser work. + purpose: Joomla, JCB, PHP, TypeScript, database, browser, and Joomla MCP work. status: ready release: true languages: [shell, php, node, typescript] - capabilities: [cpu, browser, database] + capabilities: [cpu, browser, database, joomla-mcp] default_policy: connected default_size: standard python: @@ -213,10 +219,10 @@ images: default_policy: connected default_size: standard full: - purpose: Mixed-language platform engineering. + purpose: Mixed-language platform engineering with Joomla MCP support. status: ready release: true languages: [shell, php, python, node, typescript, c, cpp] - capabilities: [cpu, browser, database] + capabilities: [cpu, browser, database, joomla-mcp] default_policy: connected default_size: xlarge diff --git a/manifest/mcp-catalog.yaml b/manifest/mcp-catalog.yaml index 46dbd4d..04e311f 100644 --- a/manifest/mcp-catalog.yaml +++ b/manifest/mcp-catalog.yaml @@ -1,5 +1,5 @@ schema: 1 -reviewed_on: 2026-07-22 +reviewed_on: 2026-07-27 servers: git: status: enabled @@ -8,6 +8,14 @@ servers: source: https://github.com/modelcontextprotocol/servers/tree/main/src/git package: mcp-server-git note: Early development; restrict repository path to /workspace. + joomla: + status: installed-disabled-on-php-images + trust: first-party + transport: stdio + source: https://github.com/joomengine/joomla-mcp + package: "@joomengine/joomla-mcp" + version: 0.7.0 + note: Configure an HTTPS Joomla origin, inject only session credentials, and enable per instance. github: status: ready-disabled trust: first-party @@ -44,10 +52,6 @@ servers: note: Host microphone capture should remain outside the VM; inject only transcribed text. deferred_integrations: - joomla: - status: not-installed - source: https://github.com/vast-development-method/joomla-mcp - activation_gate: Add only after the first VDM-reviewed release is available. jcb: status: not-installed activation_gate: Add only after the VDM repository and first reviewed release are available. diff --git a/manifest/sources.lock.yaml b/manifest/sources.lock.yaml index 932bb7c..48aad27 100644 --- a/manifest/sources.lock.yaml +++ b/manifest/sources.lock.yaml @@ -1,5 +1,5 @@ schema: 1 -generated_on: 2026-07-22 +generated_on: 2026-07-27 policy: require_immutable_refs_for_release: true allow_floating_refs_for_development: true @@ -17,6 +17,11 @@ sources: repository: https://github.com/modelcontextprotocol/servers path: src/git build_ref: v2026.7.10 + joomla_mcp: + repository: https://github.com/joomengine/joomla-mcp + inspected_ref: 7160096413dc71ed699e4f8e4988e4c7edc8c9e4 + package: "@joomengine/joomla-mcp" + build_ref: 0.7.0 github_mcp: repository: https://github.com/github/github-mcp-server build_ref: REMOTE_NOT_BUNDLED @@ -31,9 +36,6 @@ sources: repository: https://github.com/cbcoutinho/nextcloud-mcp-server build_ref: DISABLED_UNTIL_AUDITED deferred_integrations: - joomla_mcp: - repository: https://github.com/vast-development-method/joomla-mcp - build_ref: DISABLED_UNTIL_FIRST_RELEASE jcb_mcp: build_ref: DISABLED_UNTIL_REPOSITORY_AND_RELEASE_EXIST diff --git a/manifest/toolchain.env b/manifest/toolchain.env index 9448102..a149e4c 100644 --- a/manifest/toolchain.env +++ b/manifest/toolchain.env @@ -1,4 +1,4 @@ -# Approved package versions for platform release 0.3.0-rc.1. +# Approved package versions for platform release 0.3.0-rc.2. # Update these values deliberately and validate every image before promotion. OPENCODE_PACKAGE=opencode-ai@1.18.4 @@ -12,6 +12,9 @@ PYTHON_MAJOR=3.12 GIT_MCP_PACKAGE=mcp-server-git==2026.7.10 GIT_MCP_EXPECTED_VERSION=2026.7.10 +JOOMLA_MCP_PACKAGE=@joomengine/joomla-mcp@0.7.0 +JOOMLA_MCP_EXPECTED_VERSION=0.7.0 + PLAYWRIGHT_MCP_PACKAGE=@playwright/mcp@0.0.78 PLAYWRIGHT_MCP_EXPECTED_VERSION=0.0.78 PLAYWRIGHT_PACKAGE=playwright@1.61.1 diff --git a/scripts/build-image.sh b/scripts/build-image.sh index 847fabb..8b7fb03 100755 --- a/scripts/build-image.sh +++ b/scripts/build-image.sh @@ -47,6 +47,8 @@ COMMON_ENV=( "OPENCODE_EXPECTED_VERSION=$OPENCODE_EXPECTED_VERSION" "GIT_MCP_PACKAGE=$GIT_MCP_PACKAGE" "GIT_MCP_EXPECTED_VERSION=$GIT_MCP_EXPECTED_VERSION" + "JOOMLA_MCP_PACKAGE=$JOOMLA_MCP_PACKAGE" + "JOOMLA_MCP_EXPECTED_VERSION=$JOOMLA_MCP_EXPECTED_VERSION" "PLAYWRIGHT_MCP_PACKAGE=$PLAYWRIGHT_MCP_PACKAGE" "PLAYWRIGHT_MCP_EXPECTED_VERSION=$PLAYWRIGHT_MCP_EXPECTED_VERSION" "PLAYWRIGHT_PACKAGE=$PLAYWRIGHT_PACKAGE" diff --git a/scripts/configure-joomla-mcp.sh b/scripts/configure-joomla-mcp.sh new file mode 100755 index 0000000..d47a676 --- /dev/null +++ b/scripts/configure-joomla-mcp.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/common.sh" + +NAME="${1:-}" +BASE_URL="${2:-}" +SITE_ALIAS="${3:-company}" +PROFILE="${4:-readonly}" + +if (($# > 4)); then + die "Usage: $0 INSTANCE HTTPS_BASE_URL [SITE_ALIAS] [readonly|content|admin|full]" +fi +if [ -z "$NAME" ] || [ -z "$BASE_URL" ]; then + die "Usage: $0 INSTANCE HTTPS_BASE_URL [SITE_ALIAS] [readonly|content|admin|full]" +fi +[[ "$SITE_ALIAS" =~ ^[A-Za-z][A-Za-z0-9_-]{0,63}$ ]] || + die "Unsafe Joomla site alias: $SITE_ALIAS" + +BASE_URL="${BASE_URL%/}" +[[ "$BASE_URL" =~ ^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?$ ]] || + die "Joomla base URL must be one HTTPS origin without credentials, path, query, or fragment" +[[ "$BASE_URL" != *".."* ]] || die "Joomla base URL contains an invalid hostname" +if [[ "$BASE_URL" =~ :([0-9]+)$ ]]; then + port="${BASH_REMATCH[1]}" + ((port >= 1 && port <= 65535)) || die "Joomla HTTPS port is outside 1-65535" +fi + +needs_approval=false +needs_update=false +case "$PROFILE" in + readonly) + toolsets='[ + "discovery", + "content.read", + "structure.read", + "media.read", + "users.read", + "extensions.read", + "configuration.read", + "maintenance.read" + ]' + ;; + content) + needs_approval=true + toolsets='[ + "discovery", + "content.read", + "content.write", + "structure.read", + "structure.write", + "media.read", + "media.write", + "users.read", + "extensions.read", + "configuration.read", + "maintenance.read" + ]' + ;; + admin) + needs_approval=true + toolsets='[ + "discovery", + "content.read", + "content.write", + "structure.read", + "structure.write", + "media.read", + "media.write", + "users.read", + "users.admin", + "extensions.read", + "extensions.admin", + "configuration.read", + "configuration.write", + "maintenance.read", + "maintenance.admin" + ]' + ;; + full) + needs_approval=true + needs_update=true + toolsets='[ + "discovery", + "content.read", + "content.write", + "structure.read", + "structure.write", + "media.read", + "media.write", + "users.read", + "users.admin", + "extensions.read", + "extensions.admin", + "configuration.read", + "configuration.write", + "maintenance.read", + "maintenance.admin", + "core-update" + ]' + ;; + *) die "Profile must be one of: readonly, content, admin, full" ;; +esac + +project_cmd info "$NAME" >/dev/null 2>&1 || die "Unknown instance: $NAME" +project_cmd start "$NAME" >/dev/null 2>&1 || true +wait_for_vm "$NAME" || die "VM agent did not become ready: $NAME" +project_cmd exec "$NAME" -- test -x /usr/local/bin/vdm-joomla-mcp || + die "Instance $NAME does not contain the Joomla MCP capability; use a current php or full image" + +config_file="$(mktemp)" +guest_config="/run/vdm-joomla-mcp-config-$$.json" +guest_target=/etc/joomla-mcp/sites.json +guest_target_temp=/etc/joomla-mcp/.sites.json.new +cleanup() { + rm -f "$config_file" + project_cmd exec "$NAME" -- rm -f "$guest_config" "$guest_target_temp" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +jq -n \ + --arg alias "$SITE_ALIAS" \ + --arg base_url "$BASE_URL" \ + --argjson toolsets "$toolsets" \ + --argjson needs_approval "$needs_approval" \ + --argjson needs_update "$needs_update" \ + '{ + defaultSite: $alias, + sites: { + ($alias): { + toolsets: $toolsets, + api: ( + { + baseUrl: $base_url, + tokenEnv: "JOOMLA_MCP_SITE_TOKEN", + timeoutMs: 30000, + maxResponseBytes: 5242880, + maxPageSize: 100 + } + + ( + if $needs_update + then {updateTokenEnv: "JOOMLA_MCP_UPDATE_TOKEN"} + else {} + end + ) + ) + } + } + } + + ( + if $needs_approval + then { + approval: { + secretEnv: "JOOMLA_MCP_APPROVAL_SECRET", + ttlMs: 300000, + requestTtlMs: 300000, + allowIndefinite: false + } + } + else {} + end + )' > "$config_file" +chmod 0600 "$config_file" + +project_cmd exec "$NAME" -- install -d -o root -g "$AGENT_USER" -m 0750 /etc/joomla-mcp +project_cmd file push --mode 0600 --uid 0 --gid 0 "$config_file" "$NAME$guest_config" +project_cmd exec "$NAME" -- /usr/local/bin/vdm-joomla-mcp-config-check "$guest_config" +project_cmd exec "$NAME" -- install -o root -g "$AGENT_USER" -m 0640 \ + "$guest_config" "$guest_target_temp" +project_cmd exec "$NAME" -- mv -f "$guest_target_temp" "$guest_target" + +"$SCRIPT_DIR/mcp-toggle.sh" "$NAME" joomla true +runtime_file="$("$SCRIPT_DIR/create-runtime-env.sh" "$NAME")" + +log "Configured local Joomla MCP for $SITE_ALIAS at $BASE_URL" +printf 'Profile: %s\n' "$PROFILE" +printf 'Credential file: %s\n' "$runtime_file" +printf 'Set JOOMLA_MCP_SITE_TOKEN before starting the session.\n' +if [ "$needs_approval" = true ]; then + printf 'Set JOOMLA_MCP_APPROVAL_SECRET to at least 32 random characters for guarded writes.\n' +fi +if [ "$needs_update" = true ]; then + printf 'Set the separately scoped JOOMLA_MCP_UPDATE_TOKEN before using core-update.\n' +fi diff --git a/scripts/create-runtime-env.sh b/scripts/create-runtime-env.sh index 9ce8f49..37670b8 100755 --- a/scripts/create-runtime-env.sh +++ b/scripts/create-runtime-env.sh @@ -1,18 +1,25 @@ #!/usr/bin/env bash set -Eeuo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/common.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/runtime-credentials.sh" NAME="${1:-}" -[ -n "$NAME" ] || { - printf 'Usage: %s INSTANCE_NAME\n' "$0" >&2 - exit 1 -} +[[ "$NAME" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$ ]] || + die "Usage: $0 INSTANCE_NAME" DIR="/run/user/${UID}/vdm-opencode" FILE="${DIR}/${NAME}.env" install -d -m 0700 "$DIR" +[ "$(stat -c '%u' "$DIR")" = "$UID" ] || + die "Runtime credential directory must be owned by UID $UID: $DIR" umask 077 -if [ ! -f "$FILE" ]; then +if [ -e "$FILE" ] || [ -L "$FILE" ]; then + validate_runtime_file "$FILE" +else cat > "$FILE" <<'EOF' # Runtime-only values. This directory is tmpfs and is removed at logout/reboot. GITHUB_MCP_URL= @@ -25,8 +32,25 @@ STT_MCP_URL= STT_MCP_TOKEN= VDM_LOCAL_LLM_BASE_URL=http://10.248.18.1:11434/v1 VDM_LOCAL_LLM_MODEL= + +# Local JoomEngine MCP for Joomla. Values are inherited only by the bounded session. +JOOMLA_MCP_SITE_TOKEN= +JOOMLA_MCP_APPROVAL_SECRET= +JOOMLA_MCP_UPDATE_TOKEN= EOF chmod 0600 "$FILE" fi +ensure_key() { + local key="$1" + grep -q "^${key}=" "$FILE" || printf '%s=\n' "$key" >> "$FILE" +} + +# Upgrade existing per-instance files without changing any existing value. +ensure_key JOOMLA_MCP_SITE_TOKEN +ensure_key JOOMLA_MCP_APPROVAL_SECRET +ensure_key JOOMLA_MCP_UPDATE_TOKEN +chmod 0600 "$FILE" +validate_runtime_file "$FILE" + printf '%s\n' "$FILE" diff --git a/scripts/lib/joomla-mcp.sh b/scripts/lib/joomla-mcp.sh new file mode 100755 index 0000000..b88d9c1 --- /dev/null +++ b/scripts/lib/joomla-mcp.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +joomla_mcp_enabled() { + local instance="$1" + + project_cmd exec "$instance" --mode=non-interactive -- \ + jq -e '.mcp.joomla.enabled == true' \ + "$AGENT_HOME/.config/opencode/opencode.json" >/dev/null 2>&1 +} + +validate_joomla_mcp_runtime_credentials() { + local instance="$1" + local runtime_file="$2" + local config=/etc/joomla-mcp/sites.json + local key + local -a required_keys=() + + joomla_mcp_enabled "$instance" || return 0 + + project_cmd exec "$instance" --mode=non-interactive -- \ + test -x /usr/local/bin/vdm-joomla-mcp || + die "Joomla MCP is enabled but is not installed in $instance" + project_cmd exec "$instance" --mode=non-interactive -- \ + test -r "$config" || + die "Joomla MCP is enabled but has no configuration in $instance" + project_cmd exec "$instance" --mode=non-interactive -- \ + /usr/local/bin/vdm-joomla-mcp-config-check "$config" >/dev/null || + die "Joomla MCP configuration validation failed in $instance" + + mapfile -t required_keys < <( + project_cmd exec "$instance" --mode=non-interactive -- \ + jq -r ' + [ + .approval?.secretEnv, + .sites[].api?.tokenEnv, + .sites[].api?.updateTokenEnv + ] + | map(select(type == "string")) + | unique[] + ' "$config" | tr -d '\r' + ) + (("${#required_keys[@]}" > 0)) || + die "Joomla MCP configuration has no runtime credential references" + + for key in "${required_keys[@]}"; do + allowed_runtime_key "$key" || + die "Jomla MCP configuration requests an unsupported credential: $key" + runtime_value_present "$runtime_file" "$key" || + die "Set $key in the protected runtime credential file before starting Joomla MCP" + if [ "$key" = JOOMLA_MCP_APPROVAL_SECRET ]; then + runtime_value_length_at_least "$runtime_file" "$key" 32 || + die "JOOMLA_MCP_APPROVAL_SECRET must contain at least 32 characters" + fi + done +} diff --git a/scripts/lib/runtime-credentials.sh b/scripts/lib/runtime-credentials.sh new file mode 100755 index 0000000..e5fea47 --- /dev/null +++ b/scripts/lib/runtime-credentials.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash + +allowed_runtime_key() { + case "$1" in + VDM_LLM_GATEWAY_URL|VDM_LLM_GATEWAY_TOKEN|\ + GITHUB_MCP_URL|GITHUB_MCP_TOKEN|\ + GITEA_MCP_URL|GITEA_MCP_TOKEN|\ + NEXTCLOUD_MCP_URL|NEXTCLOUD_MCP_TOKEN|\ + JOOMLA_MCP_URL|JOOMLA_MCP_TOKEN|\ + JOOMLA_MCP_SITE_TOKEN|JOOMLA_MCP_UPDATE_TOKEN|\ + JOOMLA_MCP_APPROVAL_SECRET|\ + JCB_MCP_URL|JCB_MCP_TOKEN|\ + STT_MCP_URL|STT_MCP_TOKEN|\ + VDM_LOCAL_LLM_BASE_URL|VDM_LOCAL_LLM_MODEL) return 0 ;; + *) return 1 ;; + esac +} + +validate_runtime_file() { + local file="$1" + local line + local key + declare -A seen=() + + if [ ! -f "$file" ] || [ -L "$file" ]; then + die "Runtime env must be a regular non-symlink file: $file" + fi + [ "$(stat -c '%u' "$file")" = "$UID" ] || + die "Runtime env file must be owned by UID $UID: $file" + [ "$(stat -c '%a' "$file")" = 600 ] || + die "Runtime env file must have mode 0600: $file" + [ "$(stat -c '%h' "$file")" = 1 ] || + die "Runtime env file must have exactly one hard link: $file" + + while IFS= read -r line || [ -n "$line" ]; do + [[ "$line" != *$'\r'* ]] || die "Runtime environment contains a carriage return: $file" + case "$line" in + ""|\#*) continue ;; + *=*) + key="${line%%=*}" + [[ "$key" =~ ^[A-Z][A-Z0-9_]*$ ]] || + die "Invalid runtime variable name: $key" + allowed_runtime_key "$key" || + die "Runtime variable is not allowed: $key" + [[ -z "${seen[$key]+x}" ]] || + die "Runtime variable is duplicated: $key" + seen["$key"]=1 + ;; + *) die "Invalid runtime environment line in $file" ;; + esac + done < "$file" +} + +runtime_value_present() { + local file="$1" + local wanted="$2" + local line + + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + "$wanted="*) + [ -n "${line#*=}" ] + return + ;; + esac + done < "$file" + return 1 +} + +runtime_value_length_at_least() { + local file="$1" + local wanted="$2" + local minimum="$3" + local line + local value + + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + "$wanted="*) + value="${line#*=}" + (("${#value}" >= minimum)) + return + ;; + esac + done < "$file" + return 1 +} diff --git a/scripts/mcp-toggle.sh b/scripts/mcp-toggle.sh index 8ad3e7f..6f20b92 100755 --- a/scripts/mcp-toggle.sh +++ b/scripts/mcp-toggle.sh @@ -17,9 +17,32 @@ case "$STATE" in true|false) ;; *) die "State must be true or false" ;; esac project_cmd exec "$NAME" --env "MCP_SERVER=$SERVER" --env "MCP_STATE=$STATE" -- bash -c ' set -Eeuo pipefail config="/home/opencode/.config/opencode/opencode.json" +jq -e --arg server "$MCP_SERVER" ".mcp[\$server] != null" "$config" >/dev/null || { + printf "Unknown MCP server: %s\n" "$MCP_SERVER" >&2 + exit 1 +} + +if [ "$MCP_STATE" = true ]; then + server_type="$(jq -r --arg server "$MCP_SERVER" ".mcp[\$server].type" "$config")" + if [ "$server_type" = local ]; then + command_path="$(jq -r --arg server "$MCP_SERVER" ".mcp[\$server].command[0] // empty" "$config")" + [ -n "$command_path" ] && [ -x "$command_path" ] || { + printf "Local MCP executable is unavailable for %s: %s\n" "$MCP_SERVER" "$command_path" >&2 + exit 1 + } + fi + if [ "$MCP_SERVER" = joomla ]; then + test -r /etc/joomla-mcp/sites.json || { + printf "Configure the Joomla target before enabling Joomla MCP.\n" >&2 + exit 1 + } + /usr/local/bin/vdm-joomla-mcp-config-check /etc/joomla-mcp/sites.json + fi +fi + tmp="$(mktemp)" jq --arg server "$MCP_SERVER" --argjson state "$MCP_STATE" \ - "if .mcp[\$server] == null then error(\"Unknown MCP server: \" + \$server) else .mcp[\$server].enabled = \$state end" \ + ".mcp[\$server].enabled = \$state" \ "$config" > "$tmp" install -o opencode -g opencode -m 0640 "$tmp" "$config" rm -f "$tmp" diff --git a/scripts/occtl.sh b/scripts/occtl.sh index 356ebbf..fe50a88 100755 --- a/scripts/occtl.sh +++ b/scripts/occtl.sh @@ -15,8 +15,10 @@ case "$command" in list) exec "$SCRIPT_DIR/list-vms.sh" "$@" ;; session) exec "$SCRIPT_DIR/start-session.sh" "$@" ;; stop-session) exec "$SCRIPT_DIR/stop-session.sh" "$@" ;; - runtime-env) exec "$SCRIPT_DIR/create-runtime-env.sh" "$@" ;; + runtime-env|credentials) exec "$SCRIPT_DIR/create-runtime-env.sh" "$@" ;; mcp) exec "$SCRIPT_DIR/mcp-toggle.sh" "$@" ;; + joomla-configure) exec "$SCRIPT_DIR/configure-joomla-mcp.sh" "$@" ;; + joomla-test) exec "$SCRIPT_DIR/test-joomla-mcp.sh" "$@" ;; models) exec "$SCRIPT_DIR/configure-models.sh" "$@" ;; package) exec "$SCRIPT_DIR/package-image.sh" "$@" ;; import-package) exec "$SCRIPT_DIR/import-image-package.sh" "$@" ;; @@ -34,10 +36,13 @@ occtl commands: launch VARIANT INSTANCE launch-wizard list + credentials INSTANCE runtime-env INSTANCE - session INSTANCE [RUNTIME_ENV_FILE] + session INSTANCE [RUNTIME_ENV_FILE] [TTL] stop-session INSTANCE mcp INSTANCE SERVER true|false + joomla-configure INSTANCE HTTPS_BASE_URL [SITE_ALIAS] [readonly|content|admin|full] + joomla-test INSTANCE [RUNTIME_ENV_FILE] [SITE_ALIAS] [TTL] models INSTANCE package VARIANT import-package DIRECTORY [IMAGE_ALIAS] diff --git a/scripts/start-session.sh b/scripts/start-session.sh index d388114..2b50bbe 100755 --- a/scripts/start-session.sh +++ b/scripts/start-session.sh @@ -3,6 +3,10 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/common.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/runtime-credentials.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/joomla-mcp.sh" NAME="${1:-}" [ -n "$NAME" ] || die "Usage: $0 INSTANCE_NAME [HOST_RUNTIME_ENV_FILE] [TTL]" @@ -16,43 +20,6 @@ project_cmd info "$NAME" >/dev/null 2>&1 || die "Unknown instance: $NAME" project_cmd start "$NAME" >/dev/null 2>&1 || true wait_for_vm "$NAME" || die "VM agent did not become ready: $NAME" -allowed_key() { - case "$1" in - VDM_LLM_GATEWAY_URL|VDM_LLM_GATEWAY_TOKEN|\ - GITHUB_MCP_URL|GITHUB_MCP_TOKEN|\ - GITEA_MCP_URL|GITEA_MCP_TOKEN|\ - NEXTCLOUD_MCP_URL|NEXTCLOUD_MCP_TOKEN|\ - JOOMLA_MCP_URL|JOOMLA_MCP_TOKEN|\ - JCB_MCP_URL|JCB_MCP_TOKEN|\ - STT_MCP_URL|STT_MCP_TOKEN|\ - VDM_LOCAL_LLM_BASE_URL|VDM_LOCAL_LLM_MODEL) return 0 ;; - *) return 1 ;; - esac -} - -validate_runtime_file() { - local file="$1" - local line - local key - if [ ! -f "$file" ] || [ -L "$file" ]; then - die "Runtime env must be a regular non-symlink file: $file" - fi - [ "$(stat -c '%u' "$file")" = "$UID" ] || die "Runtime env file must be owned by UID $UID: $file" - [ "$(stat -c '%a' "$file")" = 600 ] || die "Runtime env file must have mode 0600: $file" - [ "$(stat -c '%h' "$file")" = 1 ] || die "Runtime env file must have exactly one hard link: $file" - while IFS= read -r line || [ -n "$line" ]; do - case "$line" in - ""|\#*) continue ;; - *=*) - key="${line%%=*}" - [[ "$key" =~ ^[A-Z][A-Z0-9_]*$ ]] || die "Invalid runtime variable name: $key" - allowed_key "$key" || die "Runtime variable is not allowed: $key" - ;; - *) die "Invalid runtime environment line in $file" ;; - esac - done < "$file" -} - empty_env="$(mktemp)" trap 'rm -f "$empty_env"' EXIT chmod 0600 "$empty_env" @@ -62,6 +29,8 @@ else HOST_ENV_FILE="$empty_env" fi +validate_joomla_mcp_runtime_credentials "$NAME" "$HOST_ENV_FILE" + SESSION_ID="$(date +%s)-$$" UNIT="vdm-opencode-${SESSION_ID}" GUEST_INPUT_DIR="/run/vdm-opencode-input" @@ -111,6 +80,7 @@ project_cmd exec "$NAME" -- systemd-run \ --property="RestrictSUIDSGID=yes" \ --property="LoadCredential=session.env:$GUEST_CREDENTIAL" \ --setenv="VDM_SESSION_ID=$SESSION_ID" \ + --setenv="VDM_SESSION_MODE=opencode" \ --setenv="VDM_AGENT_HOME=$AGENT_HOME" \ --setenv="VDM_WORKSPACE_ROOT=$WORKSPACE_ROOT" \ --setenv="VDM_GITEA_BASE_URL=$GITEA_BASE_URL" \ diff --git a/scripts/test-joomla-mcp.sh b/scripts/test-joomla-mcp.sh new file mode 100755 index 0000000..abbf8ad --- /dev/null +++ b/scripts/test-joomla-mcp.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/common.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/runtime-credentials.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/joomla-mcp.sh" + +NAME="${1:-}" +[ -n "$NAME" ] || die "Usage: $0 INSTANCE [HOST_RUNTIME_ENV_FILE] [SITE_ALIAS] [TTL]" +HOST_ENV_FILE="${2:-/run/user/${UID}/vdm-opencode/${NAME}.env}" +SITE_ALIAS="${3:-company}" +TTL="${4:-30m}" +(($# <= 4)) || die "Usage: $0 INSTANCE [HOST_RUNTIME_ENV_FILE] [SITE_ALIAS] [TTL]" +[[ "$SITE_ALIAS" =~ ^[A-Za-z][A-Za-z0-9_-]{0,63}$ ]] || + die "Unsafe Joomla site alias: $SITE_ALIAS" +TTL_SECONDS="$(duration_to_seconds "$TTL")" +((TTL_SECONDS >= 60 && TT_SECONDS <= 86400)) || + die "Test TTL must be between 60 seconds and 24 hours." + +project_cmd info "$NAME" >/dev/null 2>&1 || die "Unknown instance: $NAME" +project_cmd start "$NAME" >/dev/null 2>&1 || true +wait_for_vm "$NAME" || die "VM agent did not become ready: $NAME" +[ -f "$HOST_ENV_FILE" ] || die "Runtime credential file does not exist: $HOST_ENV_FILE" +validate_runtime_file "$HOST_ENV_FILE" +validate_joomla_mcp_runtime_credentials "$NAME" "$HOST_ENV_FILE" +joomla_mcp_enabled "$NAME" || die "Joomla MCP is not enabled in $NAME" +project_cmd exec "$NAME" --mode=non-interactive -- \ + jq -e --arg alias "$SITE_ALIAS" '.sites[$alias] != null' \ + /etc/joomla-mcp/sites.json >/dev/null || + die "Joomla site alias is not configured: $SITE_ALIAS" + +SESSION_ID="$(date +%s)-$$" +UNIT="vdm-joomla-read-test-${SESSION_ID}" +GUEST_INPUT_DIR="/run/vdm-opencode-input" +GUEST_CREDENTIAL="${GUEST_INPUT_DIR}/${SESSION_ID}.env" +OUTPUT_DIR="$WORKSPACE_ROOT/.artifacts/joomla-mcp/${SESSION_ID}" + +cleanup() { + project_cmd exec "$NAME" -- rm -f "$GUEST_CREDENTIAL" >/dev/null 2>&1 || true + project_cmd exec "$NAME" -- rm -rf "/run/vdm-opencode-${SESSION_ID}" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +project_cmd exec "$NAME" -- install -d -o root -g root -m 0700 "$GUEST_INPUT_DIR" +project_cmd exec "$NAME" -- install -d -o "$AGENT_USER" -g "$AGENT_USER" -m 0750 \ + "$WORKSPACE_ROOT/.artifacts" "$WORKSPACE_ROOT/.artifacts/joomla-mcp" +project_cmd file push --mode 0600 --uid 0 --gid 0 "$HOST_ENV_FILE" "$NAME$GUEST_CREDENTIAL" + +log "Running the non-mutating Joomla MCP read profile in $NAME" +project_cmd exec "$NAME" -- systemd-run \ + --unit="$UNIT" \ + --service-type=exec \ + --pty \ + --wait \ + --collect \ + --property="User=$AGENT_USER" \ + --property="Group=$AGENT_USER" \ + --property="WorkingDirectory=$WORKSPACE_ROOT" \ + --property="RuntimeDirectory=vdm-opencode-${SESSION_ID}" \ + --property="RuntimeDirectoryMode=0700" \ + --property="RuntimeMaxSec=${TTL_SECONDS}s" \ + --property="TimeoutStopSec=20s" \ + --property="KillMode=control-group" \ + --property="SendSIGKILL=yes" \ + --property="UMask=0077" \ + --property="NoNewPrivileges=yes" \ + --property="PrivateTmp=yes" \ + --property="ProtectControlGroups=yes" \ + --property="ProtectKernelTunables=yes" \ + --property="ProtectKernelModules=yes" \ + --property="ProtectKernelLogs=yes" \ + --property="RestrictSUIDSGID=yes" \ + --property="LoadCredential=session.env:$GUEST_CREDENTIAL" \ + --setenv="VDM_SESSION_ID=$SESSION_ID" \ + --setenv="VDM_SESSION_MODE=joomla-read-test" \ + --setenv="VDM_AGENT_HOME=$AGENT_HOME" \ + --setenv="VDM_WORKSPACE_ROOT=$WORKSPACE_ROOT" \ + --setenv="VDM_JOOMLA_SITE_ALIAS=$SITE_ALIAS" \ + --setenv="VDM_JOOMLA_TEST_OUTPUT=$OUTPUT_DIR" \ + /usr/local/libexec/vdm-opencode-session + +log "Joomla MCP read evidence: $NAME$OUTPUT_DIR" diff --git a/scripts/verify-image.sh b/scripts/verify-image.sh index 6972741..c86511e 100755 --- a/scripts/verify-image.sh +++ b/scripts/verify-image.sh @@ -55,6 +55,31 @@ if image_has_component "$VARIANT" browser; then project_cmd exec "$INSTANCE" -- test -d /opt/ms-playwright fi +if image_has_component "$VARIANT" joomla-mcp; then + assert_version \ + "Joomla MCP" \ + "$JOOMLA_MCP_EXPECTED_VERSION" \ + "$(guest_output npm list --global --json --depth=0 | jq -r '.dependencies["@joomengine/joomla-mcp"].version')" + project_cmd exec "$INSTANCE" -- test -x /usr/local/bin/vdm-joomla-mcp + project_cmd exec "$INSTANCE" -- test -x /usr/local/bin/vdm-joomla-mcp-http + project_cmd exec "$INSTANCE" -- test -x /usr/local/bin/vdm-joomla-mcp-live-test + project_cmd exec "$INSTANCE" -- test -x /usr/local/bin/vdm-joomla-mcp-config-check + project_cmd exec "$INSTANCE" -- test -r /etc/joomla-mcp/sites.readonly.example.json + project_cmd exec "$INSTANCE" -- test ! -e /etc/joomla-mcp/sites.json + # shellcheck disable=SC2016 + project_cmd exec "$INSTANCE" -- jq -e \ + '.mcp.joomla.type == "local" + and .mcp.joomla.enabled == false + and .mcp.joomla.command[0] == "/usr/local/bin/vdm-joomla-mcp" + and .mcp.joomla.environment.JOOMLA_MCP_CONFIG == "/etc/joomla-mcp/sites.json"' \ + "/home/$AGENT_USER/.config/opencode/opencode.json" >/dev/null +else + project_cmd exec "$INSTANCE" -- test ! -x /usr/local/bin/vdm-joomla-mcp + # shellcheck disable=SC2016 + project_cmd exec "$INSTANCE" -- jq -e '.mcp.joomla == null' \ + "/home/$AGENT_USER/.config/opencode/opencode.json" >/dev/null +fi + if image_has_component "$VARIANT" typescript; then assert_version TypeScript "$TYPESCRIPT_EXPECTED_VERSION" "$(guest_output tsc --version | awk '{print $2}')" assert_version TSX "$TSX_EXPECTED_VERSION" "$(guest_output tsx --version | awk 'NR == 1 {print $2}')" diff --git a/tests/fixtures/joomla/incus b/tests/fixtures/joomla/incus new file mode 100755 index 0000000..ee716b6 --- /dev/null +++ b/tests/fixtures/joomla/incus @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +if [ "${1:-}" = info ]; then + exit 0 +fi +if [ "${1:-}" = --project ]; then + shift 2 +fi + +case "${1:-}:${2:-}" in + info:*|start:*) + exit 0 + ;; + file:push) + args=("$@") + source_index="$((${#args[@]} - 2))" + source_file="${args[$source_index]}" + cp "$source_file" "${CAPTURE_CONFIG:?CAPTURE_CONFIG is required}" + ;; + exec:*) + if [[ "$*" == *vdm-joomla-mcp-config-check* ]]; then + jq -e . "${CAPTURE_CONFIG:?CAPTURE_CONFIG is required}" >/dev/null + fi + exit 0 + ;; + *) + printf 'Unexpected Joomla configure mock Incus invocation: %s\n' "$*" >&2 + exit 1 + ;; +esac diff --git a/tests/fixtures/session/incus b/tests/fixtures/session/incus index fdb571d..556b619 100755 --- a/tests/fixtures/session/incus +++ b/tests/fixtures/session/incus @@ -11,6 +11,11 @@ fi printf '%q ' "$@" >> "${MOCK_INCUS_LOG:?MOCK_INCUS_LOG is required}" printf '\n' >> "$MOCK_INCUS_LOG" +# The generic session fixture represents an image where Joomla MCP is disabled. +if [ "${1:-}" = exec ] && [[ "$*" == *'.mcp.joomla.enabled == true'* ]]; then + exit 1 +fi + case "${1:-}:${2:-}" in list:--format) cat "${MOCK_INCUS_LIST:?MOCK_INCUS_LIST is required for list}" diff --git a/tests/joomla-mcp-configure.sh b/tests/joomla-mcp-configure.sh new file mode 100755 index 0000000..34260fe --- /dev/null +++ b/tests/joomla-mcp-configure.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +tmp_dir="$(mktemp -d)" +instance="joomla-config-test-$$" +runtime_file="/run/user/${UID}/vdm-opencode/${instance}.env" +trap 'rm -rf "$tmp_dir"; rm -f "$runtime_file"' EXIT + +export PATH="$ROOT_DIR/tests/fixtures/joomla:$PATH" +export CAPTURE_CONFIG="$tmp_dir/config.json" + +"$ROOT_DIR/scripts/configure-joomla-mcp.sh" \ + "$instance" https://www.example.com company readonly >/dev/null +jq -e ' + .defaultSite == "company" + and (.sites.company.toolsets | index("content.write") | not) + and (has("approval") | not) +' "$CAPTURE_CONFIG" >/dev/null + +"$ROOT_DIR/scripts/configure-joomla-mcp.sh" \ + "$instance" https://www.example.com company content >/dev/null +jq -e ' + (.sites.company.toolsets | index("content.write") != null) + and .approval.allowIndefinite == false + and (.sites.company.api | has("updateTokenEnv") | not) +' "$CAPTURE_CONFIG" >/dev/null + +"$ROOT_DIR/scripts/configure-joomla-mcp.sh" \ + "$instance" https://www.example.com company full >/dev/null +jq -e ' + (.sites.company.toolsets | index("core-update") != null) + and .sites.company.api.updateTokenEnv == "JOOMLA_MCP_UPDATE_TOKEN" +' "$CAPTURE_CONFIG" >/dev/null + +for key in JOOMLA_MCP_SITE_TOKEN JOOMLA_MCP_APPROVAL_SECRET JOOMLA_MCP_UPDATE_TOKEN; do + [ "$(grep -c "^${key}=" "$runtime_file")" = 1 ] +done +[ "$(stat -c '%a' "$runtime_file")" = 600 ] + +if "$ROOT_DIR/scripts/configure-joomla-mcp.sh" \ + "$instance" http://www.example.com company readonly >/dev/null 2>&1; then + printf 'An insecure Joomla origin was accepted.\n' >&2 + exit 1 +fi + +printf 'Joomla MCP configuration tests passed.\n' diff --git a/tests/joomla-mcp-integration.sh b/tests/joomla-mcp-integration.sh new file mode 100755 index 0000000..9525eb1 --- /dev/null +++ b/tests/joomla-mcp-integration.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" + +# shellcheck disable=SC1091 +source "$ROOT_DIR/manifest/toolchain.env" +# shellcheck disable=SC1091 +source "$ROOT_DIR/manifest/generated/platform.env" + +[ "$JOOMLA_MCP_PACKAGE" = "@joomengine/joomla-mcp@0.7.0" ] +[ "$JOOMLA_MCP_EXPECTED_VERSION" = "0.7.0" ] +[[ " ${PLATFORM_IMAGE_COMPONENTS[php]} " == *" joomla-mcp "* ]] +[[ " ${PLATFORM_IMAGE_COMPONENTS[full]} " == *" joomla-mcp "* ]] +for image in base python cpp typescript; do + [[ " ${PLATFORM_IMAGE_COMPONENTS[$image]} " != *" joomla-mcp "* ]] +done +[ "${PLATFORM_COMPONENT_PROVISION[joomla-mcp]}" = "joomla-mcp.sh" ] + +example="$ROOT_DIR/image/files/etc/joomla-mcp/sites.readonly.example.json" +jq -e ' + (.sites[].api.baseUrl | startswith("https://")) + and (.sites[].api.tokenEnv == "JOOMLA_MCP_SITE_TOKEN") + and ([.sites[].toolsets[] + | select(test("(\\.write$|\\.admin$|^core-update$)"))] + | length == 0) + and (has("approval") | not) +' "$example" >/dev/null +test ! -e "$ROOT_DIR/image/files/etc/joomla-mcp/sites.json" + +provision="$ROOT_DIR/image/provision/joomla-mcp.sh" +grep -F 'npm install --global --ignore-scripts' "$provision" >/dev/null +grep -F '"enabled": false' "$provision" >/dev/null +grep -F '/usr/local/bin/vdm-joomla-mcp-config-check' "$provision" >/dev/null + +for script in \ + "$ROOT_DIR/scripts/lib/runtime-credentials.sh" \ + "$ROOT_DIR/image/files/usr/local/libexec/vdm-opencode-session"; do + grep -F 'JOOMLA_MCP_SITE_TOKEN' "$script" >/dev/null + grep -F 'JOOMLA_MCP_UPDATE_TOKEN' "$script" >/dev/null + grep -F 'JOOMLA_MCP_APPROVAL_SECRET' "$script" >/dev/null +done + +grep -F 'allowIndefinite: false' "$ROOT_DIR/scripts/configure-joomla-mcp.sh" >/dev/null +grep -F -- '--profile read' \ + "$ROOT_DIR/image/files/usr/local/libexec/vdm-opencode-session" >/dev/null +if grep -Eq -- '--confirm-mutations|--disposable|--profile (crud|full)' \ + "$ROOT_DIR/image/files/usr/local/libexec/vdm-opencode-session"; then + printf 'The built-in Joomla smoke test must remain non-mutating.\n' >&2 + exit 1 +fi + +python3 - "$ROOT_DIR/manifest/sources.lock.yaml" <<'PY' +import sys +import yaml + +with open(sys.argv[1], encoding="utf-8") as stream: + lock = yaml.safe_load(stream) +source = lock["sources"]["joomla_mcp"] +assert source["repository"] == "https://github.com/joomengine/joomla-mcp" +assert source["inspected_ref"] == "7160096413dc71ed699e4f8e4988e4c7edc8c9e4" +assert source["package"] == "@joomengine/joomla-mcp" +assert str(source["build_ref"]) == "0.7.0" +PY + +printf 'Joomla MCP integration tests passed.\n' diff --git a/tests/runtime-credentials.sh b/tests/runtime-credentials.sh new file mode 100755 index 0000000..c21da9a --- /dev/null +++ b/tests/runtime-credentials.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck disable=SC1091 +source "$ROOT_DIR/scripts/lib/common.sh" +# shellcheck disable=SC1091 +source "$ROOT_DIR/scripts/lib/runtime-credentials.sh" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +valid="$tmp_dir/valid.env" +cat > "$valid" <<'EOF' +GITHUB_MCP_TOKEN=github-value +JOOMLA_MCP_SITE_TOKEN=joomla-value +JOOMLA_MCP_APPROVAL_SECRET=approval-value-0123456789abcdef0123 +JOOMLA_MCP_UPDATE_TOKEN=update-value +EOF +chmod 0600 "$valid" +validate_runtime_file "$valid" +runtime_value_present "$valid" JOOMLA_MCP_SITE_TOKEN +runtime_value_length_at_least "$valid" JOOMLA_MCP_APPROVAL_SECRET 32 +if runtime_value_length_at_least "$valid" JOOMLA_MCP_SITE_TOKEN 128; then + printf 'A short runtime value passed an oversized minimum length.\n' >&2 + exit 1 +fi +if runtime_value_present "$valid" NEXTCLOUD_MCP_TOKEN; then + printf 'Unexpected runtime value reported as present.\n' >&2 + exit 1 +fi + +duplicate="$tmp_dir/duplicate.env" +printf 'JOOMLA_MCP_SITE_TOKEN=one\nJOOMLA_MCP_SITE_TOKEN=two\n' > "$duplicate" +chmod 0600 "$duplicate" +if (validate_runtime_file "$duplicate" >/dev/null 2>&1); then + printf 'Duplicate runtime keys were accepted.\n' >&2 + exit 1 +fi + +unknown="$tmp_dir/unknown.env" +printf 'UNAPPROVED_SECRET=value\n' > "$unknown" +chmod 0600 "$unknown" +if (validate_runtime_file "$unknown" >/dev/null 2>&1); then + printf 'An unknown runtime key was accepted.\n' >&2 + exit 1 +fi + +link="$tmp_dir/link.env" +ln -s "$valid" "$link" +if (validate_runtime_file "$link" >/dev/null 2>&1); then + printf 'A symlink runtime file was accepted.\n' >&2 + exit 1 +fi + +printf 'Runtime credential validation tests passed.\n' diff --git a/tests/security-static.py b/tests/security-static.py index 0356da6..178ab06 100644 --- a/tests/security-static.py +++ b/tests/security-static.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 from __future__ import annotations +import json import unittest from pathlib import Path @@ -53,6 +54,31 @@ def test_session_secrets_do_not_enter_incus_arguments(self) -> None: for forbidden in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "XAI_API_KEY", "LD_PRELOAD", "BASH_ENV"): self.assertNotIn(forbidden, session) + def test_joomla_defaults_are_local_read_only_and_credential_free(self) -> None: + example = json.loads( + ( + ROOT + / "image/files/etc/joomla-mcp/sites.readonly.example.json" + ).read_text(encoding="utf-8") + ) + self.assertNotIn("approval", example) + for site in example["sites"].values(): + self.assertTrue(site["api"]["baseUrl"].startswith("https://")) + self.assertEqual("JOOMLA_MCP_SITE_TOKEN", site["api"]["tokenEnv"]) + for toolset in site["toolsets"]: + self.assertFalse(toolset.endswith(".write")) + self.assertFalse(toolset.endswith(".admin")) + self.assertNotEqual("core-update", toolset) + + provision = (ROOT / "image/provision/joomla-mcp.sh").read_text() + self.assertIn('"enabled": false', provision) + self.assertIn("--ignore-scripts", provision) + self.assertNotIn("JOOMLA_MCP_SITE_TOKEN=", provision) + + configure = (ROOT / "scripts/configure-joomla-mcp.sh").read_text() + self.assertIn("allowIndefinite: false", configure) + self.assertNotIn("--env JOOMLA_MCP_SITE_TOKEN", configure) + def test_all_provider_routes_exist(self) -> None: config = yaml.safe_load((ROOT / "broker/config/litellm.yaml").read_text()) routes = {entry["model_name"] for entry in config["model_list"]} diff --git a/tests/session-secret-transport.sh b/tests/session-secret-transport.sh index 82b685e..46017cd 100755 --- a/tests/session-secret-transport.sh +++ b/tests/session-secret-transport.sh @@ -10,7 +10,8 @@ export MOCK_INCUS_LIST="$tmp_dir/list.json" printf '[]\n' > "$MOCK_INCUS_LIST" runtime_file="$tmp_dir/runtime.env" sentinel="never-appear-in-incus-argv-7f3d9a" -printf 'GITHUB_MCP_TOKEN=%s\n' "$sentinel" > "$runtime_file" +printf 'GITHUB_MCP_TOKEN=%s\nJOOMLA_MCP_SITE_TOKEN=%s\n' \ + "$sentinel" "$sentinel" > "$runtime_file" chmod 0600 "$runtime_file" "$ROOT_DIR/scripts/start-session.sh" test-vm "$runtime_file" 60s diff --git a/tests/test_platform_manifest.py b/tests/test_platform_manifest.py index 8a9596d..813fd68 100644 --- a/tests/test_platform_manifest.py +++ b/tests/test_platform_manifest.py @@ -33,12 +33,29 @@ def test_supported_languages_are_intentional(self) -> None: def test_capability_policy_and_size_catalogs(self) -> None: dimensions = self.data["dimensions"] - self.assertTrue({"cpu", "browser", "database", "android", "gpu"} <= set(dimensions["capabilities"])) + self.assertTrue( + {"cpu", "browser", "database", "joomla-mcp", "android", "gpu"} + <= set(dimensions["capabilities"]) + ) self.assertGreaterEqual(len(dimensions["policies"]), 5) self.assertGreaterEqual(len(dimensions["sizes"]), 5) self.assertEqual("planned", dimensions["capabilities"]["android"]["status"]) self.assertEqual("planned", dimensions["capabilities"]["gpu"]["status"]) + def test_joomla_mcp_is_composable_and_php_scoped(self) -> None: + self.assertEqual( + {"status": "ready", "provision": "joomla-mcp.sh"}, + self.data["components"]["joomla-mcp"], + ) + capability = self.data["dimensions"]["capabilities"]["joomla-mcp"] + self.assertEqual("ready", capability["status"]) + self.assertEqual(["joomla-mcp"], capabity["components"]) + + for image in ("php", "full"): + self.assertIn("joomla-mcp", self.data["images"][image]["capabilities"]) + for image in ("base", "python", "cpp", "typescript"): + self.assertNotIn("joomla-mcp", self.data["images"][image]["capabilities"]) + def test_provider_matrices_are_identical_and_complete(self) -> None: outputs = [] for provider in ("github", "gitea"): diff --git a/tests/validate-repository.sh b/tests/validate-repository.sh index add0876..5777e76 100755 --- a/tests/validate-repository.sh +++ b/tests/validate-repository.sh @@ -9,6 +9,7 @@ extra_shell=( "$ROOT_DIR/image/files/usr/local/libexec/vdm-opencode-session" "$ROOT_DIR/tests/fixtures/incus" "$ROOT_DIR/tests/fixtures/session/incus" + "$ROOT_DIR/tests/fixtures/joomla/incus" ) while IFS= read -r -d '' script; do @@ -30,6 +31,9 @@ python3 "$ROOT_DIR/scripts/generate-platform.py" --check || fail=1 python3 -m unittest "$ROOT_DIR/tests/test_platform_manifest.py" || fail=1 python3 -m unittest "$ROOT_DIR/tests/security-static.py" || fail=1 bash "$ROOT_DIR/tests/generated-platform.sh" || fail=1 +bash "$ROOT_DIR/tests/joomla-mcp-integration.sh" || fail=1 +bash "$ROOT_DIR/tests/joomla-mcp-configure.sh" || fail=1 +bash "$ROOT_DIR/tests/runtime-credentials.sh" || fail=1 bash "$ROOT_DIR/tests/session-secret-transport.sh" || fail=1 bash "$ROOT_DIR/tests/reaper.sh" || fail=1 @@ -53,7 +57,13 @@ fi if grep -RIE --exclude-dir=.git --exclude-dir=build --exclude=validate-repository.sh \ '(nikosdion/joomla-mcp-php|OnepointConsultingLtd/joomla-mcp-server|joomla_mcp4joomla|joomla_component_mcp)' \ "$ROOT_DIR"; then - printf 'A non-VDM Joomla MCP reference is still present.\n' >&2 + printf 'An unapproved third-party Joomla MCP reference is still present.\n' >&2 + fail=1 +fi + +if grep -RIF --exclude-dir=.git --exclude-dir=build --exclude=validate-repository.sh \ + 'https://github.com/vast-development-method/joomla-mcp' "$ROOT_DIR"; then + printf 'The retired Joomla MCP repository location is still present.\n' >&2 fail=1 fi From cf2267705fa22f942c815750c77403aa074d44c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:35:45 +0200 Subject: [PATCH 2/5] Retain failed validation logs --- .github/workflows/validate.yaml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index f8f635f..9062a22 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -28,7 +28,20 @@ jobs: sudo apt-get install -y jq python3-yaml shellcheck yamllint - name: Validate repository - run: ./tests/validate-repository.sh + id: validate + shell: bash + run: | + set -o pipefail + ./tests/validate-repository.sh 2>&1 | tee validation.log + + - name: Upload failed validation log + if: failure() && steps.validate.outcome == 'failure' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v4.6.2 + with: + name: validation-log + path: validation.log + if-no-files-found: error + retention-days: 7 - name: Test image package round trip run: ./tests/package-roundtrip.sh From 3dd02ce77c6c4d76ad27f79d5408feff76405266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:38:37 +0200 Subject: [PATCH 3/5] Fix Joomla validation regressions --- image/provision/joomla-mcp.sh | 2 ++ scripts/test-joomla-mcp.sh | 4 +++- tests/test_platform_manifest.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/image/provision/joomla-mcp.sh b/image/provision/joomla-mcp.sh index bf23b2b..6bbe158 100755 --- a/image/provision/joomla-mcp.sh +++ b/image/provision/joomla-mcp.sh @@ -4,6 +4,8 @@ set -Eeuo pipefail AGENT_USER="${AGENT_USER:-opencode}" AGENT_HOME="/home/${AGENT_USER}" +# The single-quoted program is JavaScript evaluated by Node, not shell text. +# shellcheck disable=SC2016 node -e ' const [major, minor] = process.versions.node.split(".").map(Number); if (major < 22 || (major === 22 && minor < 12)) { diff --git a/scripts/test-joomla-mcp.sh b/scripts/test-joomla-mcp.sh index abbf8ad..b45fc13 100755 --- a/scripts/test-joomla-mcp.sh +++ b/scripts/test-joomla-mcp.sh @@ -17,7 +17,7 @@ TTL="${4:-30m}" [[ "$SITE_ALIAS" =~ ^[A-Za-z][A-Za-z0-9_-]{0,63}$ ]] || die "Unsafe Joomla site alias: $SITE_ALIAS" TTL_SECONDS="$(duration_to_seconds "$TTL")" -((TTL_SECONDS >= 60 && TT_SECONDS <= 86400)) || +((TTL_SECONDS >= 60 && TTL_SECONDS <= 86400)) || die "Test TTL must be between 60 seconds and 24 hours." project_cmd info "$NAME" >/dev/null 2>&1 || die "Unknown instance: $NAME" @@ -27,6 +27,8 @@ wait_for_vm "$NAME" || die "VM agent did not become ready: $NAME" validate_runtime_file "$HOST_ENV_FILE" validate_joomla_mcp_runtime_credentials "$NAME" "$HOST_ENV_FILE" joomla_mcp_enabled "$NAME" || die "Joomla MCP is not enabled in $NAME" +# The jq program must reach the guest unchanged. +# shellcheck disable=SC2016 project_cmd exec "$NAME" --mode=non-interactive -- \ jq -e --arg alias "$SITE_ALIAS" '.sites[$alias] != null' \ /etc/joomla-mcp/sites.json >/dev/null || diff --git a/tests/test_platform_manifest.py b/tests/test_platform_manifest.py index 813fd68..d5a8c9b 100644 --- a/tests/test_platform_manifest.py +++ b/tests/test_platform_manifest.py @@ -49,7 +49,7 @@ def test_joomla_mcp_is_composable_and_php_scoped(self) -> None: ) capability = self.data["dimensions"]["capabilities"]["joomla-mcp"] self.assertEqual("ready", capability["status"]) - self.assertEqual(["joomla-mcp"], capabity["components"]) + self.assertEqual(["joomla-mcp"], capability["components"]) for image in ("php", "full"): self.assertIn("joomla-mcp", self.data["images"][image]["capabilities"]) From 1004eca32b9266731b60bdf7d4947c40e359a49d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:40:13 +0200 Subject: [PATCH 4/5] Retain package round-trip failures --- .github/workflows/validate.yaml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index 9062a22..fb7ae75 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -44,4 +44,17 @@ jobs: retention-days: 7 - name: Test image package round trip - run: ./tests/package-roundtrip.sh + id: package-roundtrip + shell: bash + run: | + set -o pipefail + ./tests/package-roundtrip.sh 2>&1 | tee package-roundtrip.log + + - name: Upload failed package round-trip log + if: failure() && steps.package-roundtrip.outcome == 'failure' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v4.6.2 + with: + name: package-roundtrip-log + path: package-roundtrip.log + if-no-files-found: error + retention-days: 7 From a27bada48d7fb2dbbff61aa14187cca62c38887b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:42:31 +0200 Subject: [PATCH 5/5] Derive package test version from authority --- tests/package-roundtrip.sh | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/package-roundtrip.sh b/tests/package-roundtrip.sh index 4822c6d..d6d617b 100755 --- a/tests/package-roundtrip.sh +++ b/tests/package-roundtrip.sh @@ -8,18 +8,23 @@ trap 'rm -rf "$tmp_dir"' EXIT export PATH="$ROOT_DIR/tests/fixtures:$PATH" export MOCK_INCUS_LOG="$tmp_dir/incus.log" output_dir="$tmp_dir/package" +platform_version="$(<"$ROOT_DIR/VERSION")" +artifact_id="vdm-opencode-base-${platform_version}-amd64" "$ROOT_DIR/scripts/package-image.sh" base "$output_dir" -jq -e ' - .schema == 2 and - .platform == "vdm-opencode-platform" and - .version == "0.3.0-rc.1" and - .variant == "base" and - .architecture == "amd64" and - .artifact_id == "vdm-opencode-base-0.3.0-rc.1-amd64" and - (.payload | length == 2) -' "$output_dir/manifest.json" >/dev/null +jq -e \ + --arg version "$platform_version" \ + --arg artifact_id "$artifact_id" \ + ' + .schema == 2 and + .platform == "vdm-opencode-platform" and + .version == $version and + .variant == "base" and + .architecture == "amd64" and + .artifact_id == $artifact_id and + (.payload | length == 2) + ' "$output_dir/manifest.json" >/dev/null ( cd "$output_dir"