Merge pull request 'feat: prefill agent-edit modal, Ctrl+Enter for all modals, improved README' (#1) from feature/prefill-ctrlenter-readme into main
Deploy / Build (push) Successful in 27s
Deploy / Test & Lint (push) Successful in 33s
Deploy / Build and Push Docker Image (push) Successful in 1m43s

This commit was merged in pull request #1.
This commit is contained in:
2026-07-19 22:43:54 +02:00
6 changed files with 204 additions and 62 deletions
+185 -56
View File
@@ -4,7 +4,7 @@
> Review changes. Approve intent. Let agents proceed.
Agents submit structured change requests containing code diffs, configuration updates, or custom values. Humans review and approve them in the browser, then agents verify the platform-signed decision through the API before applying anything.
Agents submit structured change requests containing code diffs, configuration updates, or custom values. Humans review and approve them in a browser UI; agents then verify the platform-signed decision through the API before applying anything. Packaged as a single `docker compose` stack — no infrastructure beyond Postgres required.
---
@@ -12,110 +12,204 @@ Agents submit structured change requests containing code diffs, configuration up
```
Agent PatchPass Human
│ create_request ──────────► │ │
│ (diffs/config/custom) │ normalize · hash · store │
│ ◄── request_id + URL ────────│ ──── notify (WS) ──────────► │ reviews in browser
│ │ APPROVE / REJECT / REQUEST_CHANGES
│ get_request ─────────────► │ ◄──── signed decision ──────│
│ ◄── state + receipt ─────────│ │
│ consume_approval ─────────► │ mark CONSUMED (single-use) │
│ ◄── receipt (HMAC) ──────────│ │
│ apply changes │ │
│ create_request ──────────► │ │
│ (diffs/config/custom) │ normalize · hash · store │
│ ◄── request_id + URL ────────│ ──── notify (WebSocket) ───► │ reviews in browser
│ │ │ APPROVE / REJECT / REQUEST_CHANGES
│ get_request ─────────────► │ ◄──── signed decision ──────│
│ ◄── state + receipt ─────────│ │
│ consume_approval ─────────► │ mark CONSUMED (single-use) │
│ ◄── receipt (HMAC-SHA256) ───│ │
│ apply changes │ │
```
- **Decisions are platform-signed.** Approvals and rejections carry an HMAC-SHA256 receipt bound to the exact reviewed content (`content_hash`). If the agent changes anything after approval, the receipt no longer matches.
- **Approvals are single-use.** An agent must `consume` an approval before proceeding; it cannot be replayed.
- **Two ways in for agents:** a REST API (`/v1/...`) and an MCP server (`/mcp`), both authenticated with a per-agent API key and backed by the same service layer.
**Key guarantees:**
## Request states
- **Platform-signed decisions.** Every approval and rejection carries an HMAC-SHA256 receipt bound to `content_hash` — the hash of the exact content the human reviewed. If the agent changes anything after approval, the receipt no longer matches.
- **Single-use approvals.** An agent must `consume` an approval before proceeding; the same receipt cannot be replayed.
- **Two integration paths.** Agents connect via a streaming MCP server (`/mcp`) or a plain REST API (`/v1/...`), both authenticated with a per-agent API key and backed by the same service layer.
- **Changes-requested loop.** A human can return a request to the agent with reviewer notes; the agent updates and resubmits, and the updated diff is highlighted in the UI.
`PENDING` · `CHANGES_REQUESTED` · `APPROVED` · `REJECTED` · `EXPIRED` · `CONSUMED` · `CANCELLED`
---
No update is possible after approval, rejection, expiry, consumption, or cancellation. A `REQUEST_CHANGES` decision requires a comment and returns the request to the agent, which can `update_request` to resubmit (highlighted in the UI as **UPDATED**).
## Request lifecycle
| State | Meaning |
|---|---|
| `PENDING` | Submitted, awaiting human review |
| `CHANGES_REQUESTED` | Returned to agent with reviewer notes |
| `APPROVED` | Approved; agent must consume before acting |
| `REJECTED` | Hard block; agent must submit a new request |
| `EXPIRED` | Not reviewed within the expiry window |
| `CONSUMED` | Approval used; agent has proceeded |
| `CANCELLED` | Cancelled by agent or human |
No mutation is possible after `APPROVED`, `REJECTED`, `EXPIRED`, `CONSUMED`, or `CANCELLED`.
---
## Capabilities
- **Three change types:** `diff` (unified diffs), `config` (before/after key-value), `custom` (arbitrary structured values)
- **Real-time notifications** via WebSocket — the browser updates instantly when a new request arrives
- **TOTP 2FA** for human accounts
- **Admin panel** — global on/off switches for registration and request submission, user management, audit log for admin views of request payloads
- **Data export and account deletion** — humans can download all their data as JSON and delete their account from Settings
- **Auto-delete policy** — humans configure how long completed requests are retained
---
## Limits
| Limit | Value |
|---|---|
| Requests per agent per hour | 15 |
| Agents per human | 5 |
| Agents per human account | 5 |
| Simultaneous pending requests per agent | 110 (human-configured, default 5) |
| Request expiry | default 30 min, max 12 h |
| Request expiry window | 60 s 12 h (default 30 min) |
| Auto-delete retention | ≥ 7 days |
---
## Tech stack
| Layer | Technology |
|---|---|
| Backend | Node.js + TypeScript, [rjweb-server](https://server.rjweb.dev) |
| Backend | Node.js 24 + TypeScript (strict), [rjweb-server](https://server.rjweb.dev) |
| ORM | Prisma 7 (`@prisma/adapter-pg`) |
| Database | PostgreSQL |
| Frontend | React + TypeScript + Tailwind CSS (Vite), served by the backend |
| Database | PostgreSQL 16 |
| Frontend | React 18 + TypeScript + Tailwind CSS, built by Vite and served by the backend |
| Realtime | rjweb WebSocket channels |
| Tests | Vitest |
| Package manager | pnpm (Backend and UI are separate workspaces) |
| Tests | Vitest (integration, hits a real test database) |
| CI | Gitea Actions — lint · test · build Docker image |
---
## Repository layout
```
Backend/ Node + TypeScript API (rjweb-server) + Prisma
Backend/
src/
index.ts server bootstrap + static UI serving
lib/ service layer, auth, signing, limits, crons, MCP tools
routes/ REST (/api, /v1), MCP (/mcp), WebSocket (/api/ws)
tests/ Vitest integration tests
prisma/schema.prisma
UI/ React + Tailwind frontend (builds to UI/build)
Dockerfile multi-stage build (UI + backend)
docker-compose.yml backend + postgres
.gitea/workflows/ CI: build · lint · test · push image
index.ts server bootstrap, static UI serving, SPA fallback
lib/
ChangeRequestService.ts request lifecycle — all business logic lives here
AgentService.ts agent CRUD
Authentication.ts session (human) and API-key (agent) resolution
Signing.ts HMAC-SHA256 approval receipts
ChangeNormalization.ts diff normalization and content hashing
Limits.ts rate, pending, and agent-count enforcement
Notifications.ts notification fan-out
WsHub.ts WebSocket connection registry
SystemCrons.ts expiry and auto-delete background jobs
DataManager.ts global (admin) settings
mcp/ MCP JSON-RPC server and tool definitions
middlewares/
RouteAuth.ts
routes/
api/ human-facing REST — auth, account, agents, requests, notifications, admin
v1/ agent-facing REST — /v1/change-requests
mcp.ts MCP endpoint — /mcp
ws.ts WebSocket — /api/ws/notifications
tests/ Vitest integration tests
prisma/
schema.prisma
migrations/
UI/ React + Tailwind frontend (Vite), builds to UI/build/
Dockerfile multi-stage build (UI then backend)
docker-compose.yml backend + Postgres
example.env
.gitea/workflows/deploy.yml
```
## Running locally
---
Prerequisites: Node 24+, pnpm, and Postgres (or Docker).
## Quick start (Docker Compose)
The simplest way to run PatchPass is with the pre-built image:
```bash
# 1. Start Postgres (Docker)
# 1. Copy and edit the environment file
cp example.env .env
```
Open `.env` and set at minimum:
```bash
# Generate a strong secret: openssl rand -hex 32
INSTANCE_SECRET=<random 32-byte hex>
# Must point to the 'database' service in docker-compose.yml
DATABASE_URL=postgresql://patchpass:patchpass@db:5432/patchpass
# Public URL of your instance (used in notification links)
UI_URL=https://your-host
DOMAIN=your-host
```
```bash
# 2. Start
docker compose up -d
# The backend applies migrations on boot and serves the UI.
# Open http://localhost:5000 — the first account to register becomes admin.
```
---
## Development setup
Prerequisites: **Node.js 24+**, **pnpm**, and PostgreSQL (or Docker for the database).
```bash
# Start a local Postgres with Docker
docker run -d --name patchpass-db \
-e POSTGRES_USER=patchpass -e POSTGRES_PASSWORD=patchpass -e POSTGRES_DB=patchpass \
-p 5433:5432 postgres:16-alpine
# 2. Backend
# Backend
cd Backend
cp ../example.env .env # then edit DATABASE_URL / INSTANCE_SECRET
cp ../example.env .env # edit DATABASE_URL to point at localhost:5433
pnpm install
pnpm generate
pnpm migrate # apply migrations
pnpm generate # generate Prisma client types
pnpm migrate # apply database migrations
pnpm dev # http://localhost:5000
# 3. UI (separate terminal, for hot-reload dev)
# UI — hot-reload dev server (separate terminal)
cd UI
pnpm install
pnpm dev # http://localhost:3000 (proxies /api to :5000)
pnpm dev # http://localhost:3000, proxies /api and /v1 to :5000
```
For a production-style run, `cd UI && pnpm build` — the backend then serves `UI/build` at `/`.
For a production-style build without Docker, `cd UI && pnpm build` — the backend then serves `UI/build/` at `/`.
### Tests
### Running tests
```bash
cd Backend
pnpm test # spins up against the test database (see vitest.config.ts)
pnpm test # Vitest integration suite — spins up against the test database
```
## Deploying
The test database URL is configured in `Backend/vitest.config.ts`. Tests hit a real database; there are no mocks at the DB layer.
### Before committing
```bash
cp example.env .env # set DATABASE_URL=...@db:5432/..., a strong INSTANCE_SECRET
docker compose up -d --build
cd Backend && pnpm lint # ESLint (TypeScript strict)
cd Backend && pnpm test # integration tests
cd UI && pnpm lint # ESLint
cd UI && pnpm build # Vite production build check
```
The backend applies migrations on boot and serves the UI. The first account to register becomes the **admin**.
---
## Connecting an agent
Create an agent in the UI, then use its API key.
Create an agent in the UI (**Agents → New agent**), copy the API key shown once at creation, then configure your agent client.
**OpenClaw** — add to `~/.openclaw/openclaw.json`:
### OpenClaw
Add to `~/.openclaw/openclaw.json`:
```json
{
@@ -131,20 +225,55 @@ Create an agent in the UI, then use its API key.
}
```
**REST**:
### Generic MCP client (Claude Desktop, Cursor, etc.)
```bash
curl -X POST https://your-host/v1/change-requests \
-H "x-api-key: pp_agent_..." -H "Content-Type: application/json" \
-d '{"title":"Bump timeout","changes":[{"type":"config","path":"timeout","before":30,"after":60,"content_type":"integer"}]}'
```json
{
"mcpServers": {
"patchpass": {
"type": "streamable-http",
"url": "https://your-host/mcp",
"headers": { "x-api-key": "pp_agent_..." }
}
}
}
```
Tell your agent: *before taking any consequential action, call `create_request`, wait for approval, then `consume_approval` before proceeding.*
### REST API
```bash
# Submit a change request
curl -X POST https://your-host/v1/change-requests \
-H "x-api-key: pp_agent_..." \
-H "Content-Type: application/json" \
-d '{
"title": "Bump deployment timeout",
"changes": [
{ "type": "config", "path": "timeout", "before": 30, "after": 60, "content_type": "integer" }
]
}'
# Poll for the decision
curl https://your-host/v1/change-requests/{request_id} -H "x-api-key: pp_agent_..."
# Consume the approval before applying the change
curl -X POST https://your-host/v1/change-requests/{request_id}/consume -H "x-api-key: pp_agent_..."
```
### Agent instruction
Tell your agent:
> Before taking any consequential action (deploys, config changes, code edits), call `create_request`, wait for approval, then call `consume_approval` before proceeding.
---
## Privacy & data
Humans can export all their data as JSON and delete their account (cascading to agents, requests, and notifications) from **Settings**. Agent icon URLs are fetched once for validation and never stored. See the in-app Privacy Policy and Terms of Service.
---
## License
MIT-0
+1 -4
View File
@@ -9,14 +9,11 @@ Thoughts:
- Able to click on a agent either on home, agents list or in a request to show the agents past requests and overall actions from the list but moved over into a new page with a back button to return to the previous page.
- Show QR for 2fa
- invalid dates on admin action notifications
- pre fill data on agent edit modal
- overall ux improvements
- Fix popup for passwordmanagers filling in 2fa code on settings page
- ctrl enter submits modals (all)
- better repo rules for agents
- Nicer statistics on the dashboard
- Instance Notice popup that popups up on every session (like a cookie notice), content by admins, dismissable
- Instance Disabled message & setting for admins to set a message for when the instance is disabled, and a setting to allow users to see the message when the instance is disabled. (users can only delte or download their data when the instance is disabled)
- Data Hoster notice for footer when people host an instance for other people to use, content by admins, not dismissable footer only notice, all pages
- mobile friendly improvements
- human readme
- mobile friendly improvements
+11 -1
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { agents as agentsApi } from "../api/client";
import type { Agent } from "../api/types";
@@ -27,6 +27,15 @@ export function AgentFormModal({
const [maxPending, setMaxPending] = useState(agent?.max_pending_requests ?? 5);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) return;
setName(agent?.name ?? "");
setDescription(agent?.description ?? "");
setWebsite(agent?.website ?? "");
setIconUrl(agent?.icon_url ?? "");
setMaxPending(agent?.max_pending_requests ?? 5);
}, [open, agent]);
const submit = async () => {
if (!name.trim()) return toast.error("Name is required");
setLoading(true);
@@ -55,6 +64,7 @@ export function AgentFormModal({
<Modal
open={open}
onClose={onClose}
onSubmit={submit}
title={editing ? "Edit agent" : "New agent"}
footer={
<>
+1
View File
@@ -75,6 +75,7 @@ export function DecisionModal({
<Modal
open={!!decision}
onClose={onClose}
onSubmit={submit}
title={m.title}
footer={
<>
+4 -1
View File
@@ -3,6 +3,7 @@ import { ReactNode, useEffect } from "react";
export function Modal({
open,
onClose,
onSubmit,
title,
children,
footer,
@@ -10,6 +11,7 @@ export function Modal({
}: {
open: boolean;
onClose: () => void;
onSubmit?: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
@@ -19,6 +21,7 @@ export function Modal({
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && onSubmit) onSubmit();
};
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
@@ -26,7 +29,7 @@ export function Modal({
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, onClose]);
}, [open, onClose, onSubmit]);
if (!open) return null;
+2
View File
@@ -223,6 +223,7 @@ export function SettingsPage() {
<Modal
open={disable2faOpen}
onClose={() => setDisable2faOpen(false)}
onSubmit={disable2fa}
title="Disable 2FA"
footer={
<>
@@ -246,6 +247,7 @@ export function SettingsPage() {
<Modal
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onSubmit={deleteAccount}
title="Delete account"
footer={
<>