The request was a screenshot of Easypanel: an "Enable/Disable Auto Deploy" button and a webhook URL you can call from anywhere. sh0 already deployed on Git push. That is not the same thing, and the difference is who sh0 is for.
The user decides the shape
sh0's first audience is the hosting company's base: more than 1,200 developers coming from cPanel. They ship
their own code as a Dockerfile or an archive, and they barely use GitHub. A webhook that only accepts a
GitHub, GitLab or Bitbucket signature does nothing for them. Until this session, their only remote trigger was
POST /apps/:id/deployments with an API key, and an API key is an account credential: every app its owner can
reach.
What they need is something a curl -X POST pasted into a CI job, a cron or a build script can call, and that
can do nothing except redeploy one app.
The design, in four constraints
- The token is the authentication, so it lives in the path. One form only, never header and path. That is
- what a copy-pasted
curlcalls without any setup. The token is 256 bits from the OS RNG and is shown once. - It is stored as a SHA-256 hash (the same primitive as API keys; high entropy makes a slow hash pointless) and
- compared in constant time.
- A refusal says nothing. An unknown app, a disabled URL and a wrong token all return the same 404, byte for
- byte. When the app has no URL at all, the handler still hashes against a dummy digest, so that branch costs the
- same as a wrong token.
- Exactly the Deploy button. We did not write a second deploy path. The button's handler was split, and the
- URL calls the same
launch_deploymentfunction. The refactor also fixed an old leak: the Docker client for the - app's node is now resolved before the row is inserted, so an unreachable node no longer leaves an orphan
-
queueddeployment. - Ten calls don't stack ten deploys. Each app keeps one in-memory "pending" slot. A call that finds the slot
- taken joins that deployment. The slot is freed when the pipeline acquires the app's deploy lock. Keeping it in
- memory was deliberate: a database row would survive a crash and could wedge the URL forever.
The proof ran on the demo box: ten calls fired in parallel during a running deployment produced exactly one
extra deployment, and nine responses said "coalesced": true.
What the audit found
The read-only audit agent returned GO-WITH-FIXES. Two of its findings changed the code before the release candidate was cut.
The token was one log level away from journald. tower_http's TraceLayer default span records the full
URI. At info the span is disabled. Running sh0 with --log-level debug to chase an unrelated bug would have
written every live deploy token in clear. Nothing in the feature's own code logged the token. The framework did.
The fix is a custom span that redacts the token segment and no longer records query strings at all. The campaign
then checked it: journalctl -u sh0 | grep <token> returned zero lines.
A developer could mint a credential that outlives them. The spec said "roles that can deploy the app". The
problem is that a deploy URL is a bearer credential. Removing the developer from the project does not revoke the
URL they created. Issuing or re-arming a credential is an administrator act, so rotation and enable/disable now
require the project admin role, and developers keep read access. The session log records this as a decision
taken without the CEO, because it departs from the prompt and can be undone in one line.
The toggle, proven by a click
The "Auto deploy" switch in the action bar writes the existing auto_deploy flag. The proof was a click, not a
PATCH: headless Chromium, driven over CDP, clicked [data-testid=auto-deploy-toggle]. aria-pressed went from
true to false, and the API then read auto_deploy=false. A route test can only show that the route works.
The click shows that the button calls the route.
What stays open
- If the first deployment's insert fails, a caller that coalesced into it receives an id that points to nothing.
- This is rare, it is documented, and it can be fixed.
- A deploy that never releases the lock freezes the pending slot until restart.
- On an install reached by IP with no panel domain, the URL is
http://, so the token travels in clear. That is - the same limitation as the existing Git webhooks.
Each of these is written down in the session log under "Deferred". None of them is hidden behind a "fixed".
Since then, in session 261120 on 17 October, the Git side caught up: turning on auto deploy now makes sh0 create the GitHub webhook itself, and when it cannot, sh0 falls back to polling the branch. The end-to-end proof against a real GitHub repository is still owed, and the issue stays open until it is produced.