MrMurphy Restful Deploy
Deploy WordPress plugins and themes over the REST API — upload, install, activate and uninstall without SFTP or SSH. Admin-only, application-password auth, audit log. Built for scripts and AI agents.
by Murphy Randle · github.com/mrmurphy/mrmurphy-restful-deploy · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/mrmurphy/mrmurphy-restful-deploy/archive/refs/heads/main.zipDeploy WordPress plugins and themes over the REST API — upload, install, activate and uninstall ZIP packages without SFTP or SSH access. Admin-only, authenticated with an application password, with an audit log. Built to work well with scripts and AI agents.
MIT licensed. Repository: https://github.com/mrmurphy/mrmurphy-restful-deploy.
The plugin is on by default: activate it, create an Application Password, and deployments
work. Switch it off — and back on — from Settings → Restful Deploy, or define
MRMURPHY_RESTFUL_DEPLOY_ENABLED in wp-config.php as the hard kill switch.
Why this exists
WordPress core already exposes part of this (verified against WP 7.1 on this install):
| Task | Core endpoint | Available? |
|---|---|---|
| List plugins / activate / deactivate / delete | GET PUT DELETE /wp/v2/plugins |
yes |
| Install a plugin from the wordpress.org directory by slug | POST /wp/v2/plugins |
yes |
| Install a plugin from your own ZIP | — | no |
| List themes | GET /wp/v2/themes |
yes (read-only) |
| Install a theme ZIP / switch theme / delete theme | — | no |
So: uploading arbitrary packages, switching themes and deleting themes need custom code.
That code is remote code execution on purpose — the whole design is about making it only
do that for a correctly authenticated, correctly privileged admin, and about leaving
evidence.
Install
- Copy this directory to
wp-content/plugins/mrmurphy-restful-deploy/. - Activate it (Plugins screen, or
wp plugin activate mrmurphy-restful-deploy). - Create an Application Password: Users → Profile → Application Passwords.
That is the whole install — deployments are on out of the box. Nothing has to be enabled first, and no constant is required. To close the endpoints again, go to Settings → Restful Deploy and switch them off; to close them from code, see below.
Controlling the endpoints
On by default, with two ways to change that.
- Settings → Restful Deploy, a
manage_optionsscreen (network plugins capability on multisite). One button, both directions: Switch deployments off / Switch deployments on. It also shows what the switch is doing, the gates, the endpoint list, the last ten audit entries and this hour's refusal counts. Both directions are written to the audit log, so "who turned this back on?" has an answer. MRMURPHY_RESTFUL_DEPLOY_ENABLEDinwp-config.php, if it is defined at all. This is the hard override for someone with code access:falsekills the endpoints dead — it beats the settings screen, so nothing reachable from the admin (a stolen session, a rogue plugin) can bring them back.truepins them on so no UI can switch them off. Most sites never need either;falseis there for the day you want the feature gone now.- Nothing else. No route switches the endpoints on or off, and nothing in the plugin changes the setting on its own — there is no timer, no window, no schedule.
Why it works this way: an Application Password is the credential, and the plugin trusts it — it has to, that is the feature. What the plugin adds around it is blast-radius and evidence, not a second gate: HTTPS only, an Application Password rather than a replayable nonce, the capabilities the WordPress user already has, a per-user hourly throttle so a runaway script cannot churn the site, and an audit log of every attempt. The kill switch is the escape hatch for when trust is exactly what you have lost.
Authentication
Authorization: Basic base64(user:application-password) — WordPress turns this into the
normal user context, so capabilities still apply:
/plugins*needsinstall_pluginsandactivate_plugins(delete_pluginsforDELETE)./themes*needsinstall_themesandswitch_themes(delete_themesforDELETE).- On multisite, network-admin capabilities as well, for read routes too.
A cookie + X-WP-Nonce session is refused by default: a nonce can be replayed by any
script that can read it, while an application password can be revoked on its own. The check
requires the current request to actually carry Basic credentials — core records the last
application password in a process-wide global, which alone would let a later cookie-only
request ride on an earlier request's login. Override with
define( 'MRMURPHY_RESTFUL_DEPLOY_REQUIRE_APP_PASSWORD', false ); only if you must.
Endpoints
Namespace mrmurphy-restful-deploy/v1.
| Method | Route | Purpose |
|---|---|---|
GET |
/inventory |
Installed plugins/themes, update status, active gates. |
GET |
/log?limit=50 |
Audit log (newest first) plus refusal counters. |
POST |
/plugins |
Install a plugin ZIP, optionally activate. |
POST |
/plugins/activate |
Activate an installed plugin. |
POST |
/plugins/deactivate |
Deactivate an installed plugin. |
DELETE |
/plugins |
Uninstall a plugin (optionally deactivating it first). |
POST |
/themes |
Install a theme ZIP, optionally activate. |
POST |
/themes/activate |
Switch the active theme. |
DELETE |
/themes |
Uninstall a theme. |
Deletion needs delete_plugins / delete_themes (plus deactivation / theme-switching) and
is additionally refused for: an active plugin unless deactivate: true is passed, this API
plugin itself, the active theme, and the parent of the active theme. DELETE /plugins also
exists in core as DELETE /wp/v2/plugins/<plugin> (with the same active-plugin rule);
DELETE /themes has no core equivalent.
POST /plugins and POST /themes
| Param | Type | Default | Meaning |
|---|---|---|---|
zip_base64 |
string | — | The ZIP, base64 encoded (a data: URL is accepted). |
file |
multipart | — | Alternative to zip_base64: -F file=@my-plugin.zip. |
filename |
string | — | Cosmetic; used in messages and the temp file name. |
activate |
bool | false |
Activate after installing. Also the confirmation for replacing running code. |
overwrite |
bool | false |
Replace — i.e. upgrade — an existing directory of the same name. Required whenever the package is already installed; a site can forbid overwriting entirely. |
dry_run |
bool | false |
Validate and describe the archive; install nothing. |
network_wide |
bool | false |
Multisite: activate for the whole network. |
DELETE /plugins and DELETE /themes
| Param | Type | Default | Meaning |
|---|---|---|---|
plugin |
string | required | Plugin file, e.g. my-plugin/my-plugin.php. |
deactivate |
bool | false |
Deactivate the plugin first. Without it an active plugin is refused with 409. |
stylesheet |
string | required | Theme directory name, for DELETE /themes. |
Parameters may arrive in a JSON body or in the query string. As in wp-admin, deleting a
plugin runs its own uninstall routine (uninstall.php / a registered uninstall hook), so
the plugin's data goes with it — that is the point of an uninstall, but it is not
recoverable from the ZIP alone.
ZIP requirements
- Exactly one top-level folder (
zip -r my-plugin.zip my-plugin) — a ZIP of loose files, or of a single file, is refused withmrmurphy_restful_deploy_flat_archive. - A plugin must have a
Plugin Name:header; a theme needsstyle.csswithTheme Name:. A theme ZIP sent to/plugins(or vice versa) is refused. __MACOSX/entries are ignored; entries with../,./, a leading/or absolute paths are refused.- Size caps:
MRMURPHY_RESTFUL_DEPLOY_MAX_BYTES(32 MB default) for the upload,MRMURPHY_RESTFUL_DEPLOY_MAX_UNCOMPRESSED_BYTES(512 MB) for expansion, plus a compression-ratio sanity check. PHP's own limits apply before this plugin sees anything:post_max_sizecaps the whole POST body (multipart or JSON — base64 inflates the package by ~33% and counts against it), andupload_max_filesizecaps a single uploaded file. When either is exceeded PHP truncates the request, so the endpoint reports a missing package; raise the PHP limits or shrink the package.
Examples
SITE=https://mrmurphy.dev/wp-json/mrmurphy-restful-deploy/v1
# Base64 JSON body
curl -sS -u "murphy:xxxx xxxx xxxx xxxx xxxx xxxx" \
-H 'Content-Type: application/json' \
-d "$(python3 -c 'import base64,json,sys;print(json.dumps({"zip_base64":base64.b64encode(open("mrmurphy-theme.zip","rb").read()).decode(),"activate":True}))')" \
"$SITE/themes"
# multipart upload, plugin, dry run first
curl -sS -u "murphy:xxxx xxxx xxxx xxxx xxxx xxxx" -F file=@my-plugin.zip -F dry_run=1 "$SITE/plugins"
curl -sS -u "murphy:xxxx xxxx xxxx xxxx xxxx xxxx" -F file=@my-plugin.zip -F activate=1 "$SITE/plugins"
# Uninstall (deactivating first)
curl -sS -u "murphy:xxxx xxxx xxxx xxxx xxxx xxxx" -X DELETE \
"$SITE/plugins?plugin=my-plugin%2Fmy-plugin.php&deactivate=1"
# Inventory and audit log
curl -sS -u "murphy:xxxx xxxx xxxx xxxx xxxx xxxx" "$SITE/inventory"
curl -sS -u "murphy:xxxx xxxx xxxx xxxx xxxx xxxx" "$SITE/log?limit=20"
Successful install:
{
"ok": true,
"dry_run": false,
"package": {
"detected_type": "plugin",
"name": "My Plugin",
"version": "1.2.3",
"top_level_dir": "my-plugin",
"main_file": "my-plugin/my-plugin.php",
"entry_count": 42,
"uncompressed_bytes": 310456
},
"installed": true,
"plugin": "my-plugin/my-plugin.php",
"activated": true,
"duration_ms": 412,
"audit": { "time": "2026-09-11T21:41:07+00:00", "action": "install_plugin", "status": "ok", "...": "..." },
"activation_audit": { "action": "activate_plugin", "status": "ok", "...": "..." }
}
Installing from the wordpress.org directory is already core — no need to build the ZIP:
curl -sS -u "murphy:xxxx xxxx xxxx xxxx xxxx xxxx" \
-H 'Content-Type: application/json' \
-d '{"slug":"akismet","status":"active"}' \
https://mrmurphy.dev/wp-json/wp/v2/plugins
Rate limits
30 mutating operations per user per hour (UTC), counted per user.
| Request | Cost |
|---|---|
GET /inventory, GET /log |
0 — reads are never throttled |
POST /plugins or /themes with "dry_run": true |
0 — validation writes nothing |
POST /plugins or /themes — an install, with or without activate |
1 |
POST /plugins/activate, /plugins/deactivate, /themes/activate |
1 each |
DELETE /plugins, DELETE /themes |
1 each |
A deploy done the way these docs recommend — dry-run, then install+activate in one call, then
read /inventory to confirm — costs one operation, so the default is six times what an
agent needing five deploys an hour would spend. Raise it with
define( 'MRMURPHY_RESTFUL_DEPLOY_MAX_OPERATIONS_PER_HOUR', 120 ); or the
mrmurphy_restful_deploy_max_operations_per_hour filter; GET /inventory reports the live
value under gates.max_operations_per_hour, and gates.rate_limit_mode says whether the
atomic counter or the best-effort fallback is in force.
The counter is bumped by one atomic statement before the write, so parallel requests cannot
slip past the cap, and the request that takes it over is refused — its slot is spent, which is
the safe direction. Refused and failed attempts cost the same as successful ones, because this
is a brake on a looping agent, not a meter on success. Over the cap you get
429 mrmurphy_restful_deploy_rate_limited.
Reads and dry runs are deliberately free: charging for validation would tax exactly the
careful behaviour the docs ask agents for. The trade is that validation work is bounded by the
size caps rather than by the throttle — an authenticated admin can decompress up to 32 MB per
request (MRMURPHY_RESTFUL_DEPLOY_MAX_BYTES) as often as they like. That caller can already
write code to disk, so this is not the boundary that matters; if it is the boundary you care
about, lower MRMURPHY_RESTFUL_DEPLOY_MAX_BYTES and keep the throttle for writes.
Security model and its limits
This API is remote code execution for a correctly authenticated, correctly privileged admin. Everything below is about making "correctly" mean something, and about the ways it can still go wrong.
Guards on the destructive paths
- Destinations are resolved before anything is written or deleted. A package directory that
is a symlink, or that does not resolve to a direct child of
wp-content/pluginsor the theme root, is refused with409 mrmurphy_restful_deploy_destination_is_symlink/_destination_outside_root. This matters becauseWP_Filesystem's recursive delete follows symlinks and core's owndelete_theme()has no such check — on a site whose theme directory is a symlink into a repo, an unguarded overwrite or delete destroys the repo. - Names must be single, safe path components: no
./../.hidden, no leading dash, no trailing dot or space (Win32 strips those), no path separators, and no Windows reserved device names. The same validation is applied to archive top-level folders, plugin directories and theme stylesheets. - Overwriting an existing package is the upgrade path and is on by default, but each
request still has to send
overwrite: true— a caller cannot clobber a package it did not know was already there. Replacing a plugin or theme that is currently running additionally requiresactivate: true, so replacing live code is never a silent side effect. A site that wants overwriting forbidden definesMRMURPHY_RESTFUL_DEPLOY_ALLOW_OVERWRITEasfalse. - The API refuses to overwrite or delete itself, comparing resolved paths so a symlink alias cannot slip past.
- A failed install sweeps the extracted working directory out of
wp-content/upgrade/, which is under the web root and would otherwise leave PHP reachable by URL with no install and no activation. activate/deactivateonly accept paths thatget_plugins()actually lists, andDELETE /themesrefuses the active theme even when the name differs only in case (macOS and Windows resolve that), because core'sdelete_theme()would.
Evidence
- The install is written to the audit log before any package code runs, and a
deactivation-before-delete is logged before the destructive call. A package whose own
activation or uninstall hook deletes the log or calls
exitcan therefore still be traced to its install. - Refusals are counted, not logged (bounded hourly
counters, returned by
GET /logunderrefusals). A caller who is already refused must not be able to grow — and therefore evict from — the capped audit log. - Entries record the application-password UUID (
auth_uuid) and user agent, so a leaked credential can be identified and revoked rather than guessed at.
Known limits — read these
- Anything installed through this API runs with the web server's privileges and can do whatever WordPress can, including tampering with the log from inside the request. The log records the install before that code runs; it cannot prevent what runs after.
- A stale directory can outlive its own package:
DELETErefuses to remove a plugin whose main file is already missing (it is not a key inget_plugins()), so clean such leftovers up over SFTP. - The hourly throttle is atomic (
gate_status()["rate_limit_mode"]reportssql) where the database supportsINSERT ... ON DUPLICATE KEY UPDATE; elsewhere it falls back to a best-effort counter and reportstransient. - Anonymous request failures are neither logged nor counted. Only failures from authenticated callers are counted.
- Package code runs in the request that installed it. A hook that calls
wp_die()or echoes HTML can end a response early; core's own/wp/v2/pluginshas the same exposure. - Staged uploads live in the system temp directory, mode 0600, removed by a shutdown handler as well as by normal cleanup.
Error codes
| Code | HTTP | Meaning |
|---|---|---|
mrmurphy_restful_deploy_disabled |
403 | Deployments switched off (or killed by the constant): switch them on under Settings → Restful Deploy. Anonymous callers get 404. |
mrmurphy_restful_deploy_not_authenticated |
401 | No user context. |
mrmurphy_restful_deploy_requires_ssl |
403 | Not HTTPS. |
mrmurphy_restful_deploy_requires_application_password |
403 | Cookie/nonce session, or no Basic credentials on this request. |
mrmurphy_restful_deploy_requires_network_admin |
403 | Multisite and not a network admin. |
mrmurphy_restful_deploy_rate_limited |
429 | Hourly per-user operation budget spent. |
mrmurphy_restful_deploy_missing_package |
400 | No zip_base64 and no file. |
mrmurphy_restful_deploy_invalid_base64 |
400 | Bad base64 payload. |
mrmurphy_restful_deploy_incompatible_archive |
400 | Not a readable ZIP. |
mrmurphy_restful_deploy_unsafe_archive_path |
400 | Entry escapes the extraction root. |
mrmurphy_restful_deploy_flat_archive |
400 | No single top-level folder, or a lone file. |
mrmurphy_restful_deploy_bad_top_level |
400 | Unusable top-level folder name. |
mrmurphy_restful_deploy_unknown_package |
400 | Neither plugin nor theme headers. |
mrmurphy_restful_deploy_wrong_package_type |
400 | Theme sent to /plugins or vice versa. |
mrmurphy_restful_deploy_package_too_large |
413 | Above the size cap. |
mrmurphy_restful_deploy_compression_ratio |
413 | Looks like a decompression bomb. |
folder_exists |
409 | Already installed; pass overwrite: true to upgrade it in place. |
mrmurphy_restful_deploy_overwrite_disabled |
403 | This site has overwriting switched off (the constant is defined as false). Uninstall and reinstall, or ask the human. |
mrmurphy_restful_deploy_overwrite_active_plugin / _active_theme |
409 | Target is running; pass activate: true to confirm. |
mrmurphy_restful_deploy_destination_is_symlink |
409 | Target is a symlink; refused so nothing outside wp-content is touched. |
mrmurphy_restful_deploy_destination_outside_root |
409 | Target does not resolve to a direct child of the package root. |
mrmurphy_restful_deploy_bad_destination |
400 | Unusable destination name. |
mrmurphy_restful_deploy_cannot_overwrite_self |
403 | Archive targets this API plugin. |
rest_cannot_delete_active_plugin |
409 | Active plugin; pass deactivate: true. |
mrmurphy_restful_deploy_cannot_delete_self |
403 | Tried to delete this API plugin. |
mrmurphy_restful_deploy_theme_is_active |
409 | Tried to delete the active theme. |
mrmurphy_restful_deploy_theme_has_active_child |
409 | Theme is the parent of the active theme. |
mrmurphy_restful_deploy_invalid_plugin / _invalid_stylesheet |
400 | Bad plugin/stylesheet name. |
mrmurphy_restful_deploy_delete_failed / _delete_incomplete |
400 / 500 | WordPress refused, or files survived. |
mrmurphy_restful_deploy_plugin_not_found / _theme_not_found |
404 | No such package. |
mrmurphy_restful_deploy_filesystem_unavailable |
500 | PHP cannot write to the WP tree. |
Configuration constants
All optional. The security gates (app password, SSL) default to enforced, and the plugin deploys — including upgrades — out of the box.
define( 'MRMURPHY_RESTFUL_DEPLOY_ENABLED', false ); // master switch, default ON
define( 'MRMURPHY_RESTFUL_DEPLOY_REQUIRE_APP_PASSWORD', true ); // default true
define( 'MRMURPHY_RESTFUL_DEPLOY_REQUIRE_SSL', true ); // default true
define( 'MRMURPHY_RESTFUL_DEPLOY_ALLOW_OVERWRITE', false ); // default ON: this forbids overwriting
define( 'MRMURPHY_RESTFUL_DEPLOY_MAX_BYTES', 32 * 1024 * 1024 ); // default 32 MB
define( 'MRMURPHY_RESTFUL_DEPLOY_MAX_UNCOMPRESSED_BYTES', 512 * 1024 * 1024 );
define( 'MRMURPHY_RESTFUL_DEPLOY_MAX_OPERATIONS_PER_HOUR', 30 ); // default 30
MRMURPHY_RESTFUL_DEPLOY_ENABLED is the exception to the "defined and not false" rule that
governs the others: it is the master switch, so what it is defined as matters. false is the
hard kill switch — it overrides the settings screen and the screen says so; true pins
deployments on; undefined leaves the settings screen in charge (and the screen defaults to on).
Every other constant is read as "defined and not the boolean false", so 1 behaves like
true and an undefined constant always takes the restrictive branch.
Filters: mrmurphy_restful_deploy_enabled, ..._app_password_required, ..._ssl_required,
..._overwrite_allowed, ..._max_bytes, ..._max_uncompressed_bytes,
..._max_operations_per_hour.
Audit log
Every mutating attempt — success or failure — appends an entry (timestamp, action, target
user, auth method, application-password UUID, IP, user agent, message, context) to the
option mrmurphy_restful_deploy_log, capped at 200 entries, newest first, read back via
GET /log. The install entry is written before any package code runs. With WP_DEBUG on it
is mirrored to error_log. That log is the only way to answer "who put that file there"
after the fact.
Refusals are not written as entries: that would let a caller who is already refused flood
the capped log and evict real evidence. They are counted per hour instead and returned by
GET /log under refusals.
Using it from an agent
Settings → Restful Deploy carries the whole brief, in two read-only fields with Copy buttons — no trip to the repository:
- Start here: paste this into your agent — the short block, with this site's base URL and the reading user's username already filled in. Your Application Password is the one thing it deliberately leaves blank.
- The full brief — the same text as
AGENT-INSTRUCTIONS.md: every route, copy-paste curl recipes, ZIP rules, a table of error codes with what to do about each, the house rules (dry-run first, never overwrite unasked, one mutation at a time, stop and ask when deployments are off) and what an agent must never do.
That page reads the file shipped inside the plugin, so the page and the file cannot drift apart. The file is still in the repository and in the zip for anyone who would rather read it there.
The short version of what an agent needs: the base URL, an Application Password, and
GET /inventory first — the response says whether deployments are on (gates.enabled,
gates.controlled_by) and what is already installed.
Tests
tests/ holds a WP-CLI harness that drives the real REST dispatch path against a local
copy:
python3 tests/make_fixtures.py # rebuild fixture ZIPs
PKG_PHASE=default wp eval-file tests/run-tests.php # 6 — out of the box it is ON
PKG_PHASE=off wp eval-file tests/run-tests.php # 10 — the off switch, both ways
PKG_PHASE=limits wp eval-file tests/run-tests.php # 10 — the throttle, measured
PKG_PHASE=admin wp eval-file tests/run-tests.php # 26 — the screen and the brief
PKG_PHASE=forced_off wp eval-file tests/run-tests.php # 7 — the kill switch wins
PKG_PHASE=forced_on wp eval-file tests/run-tests.php # 5 — the constant can pin it on
PKG_PHASE=enabled wp eval-file tests/run-tests.php # 134 — the whole API
forced_off and forced_on need the constant fixture copied into the target site's
mu-plugins (see tests/README.md); default, off and enabled run against a stock site
with nothing installed but the plugin.
The harness mutates whatever site it runs against (it installs a fixture plugin and theme
called mrmurphy-test-package / mrmurphy-test-theme, then restores the previous theme and
removes both fixtures and its own options at the end), but run it against a local copy, never
against a live site. See tests/README.md.