Una tarea nunca escribe sola en un sitio. Propone una acción, una persona aprueba esa acción exacta y solo entonces el runner la realiza, sin tu código ni el modelo.
La guardia de solo lectura
Mientras una ejecución prepara, el runner mantiene su propia conexión DevTools con el navegador, enganchada a cada página, marco y worker. Hace fallar toda petición cuyo método no sea GET, HEAD u OPTIONS y la registra como evento blocked (se cuenta en blocked_requests de la ejecución). Ni tu script ni el agente pueden quitarla: es una conexión aparte que nunca ven.
Un envío de formulario, un fetch con POST o un DELETE desde la página fallan en esta fase. Las páginas que necesitan un POST solo para leerse no funcionan en una tarea.
Pasos
Una acción es una lista de 1 a 50 pasos tipados. El primero debe ser goto. Los selectores son selectores de Playwright (css=, text=, role=…; CSS atraviesa las shadow roots abiertas).
op | Campos | Qué hace |
|---|---|---|
goto | url | Abre una URL https:// de uno de los dominios permitidos de la tarea |
click | selector | Hace clic en el elemento |
fill | selector, text | Fija el valor de un campo o área de texto |
type | selector, text | Escribe el texto tecla a tecla |
press | key, selector opcional | Pulsa una tecla, por ejemplo Enter |
select | selector, value | Elige una opción de un <select> |
check | selector | Marca una casilla o botón de opción |
wait_for | selector o text | Espera a que aparezca |
wait | ms | Espera de 1 a 10000 ms |
assert_url | url | Se para con error salvo que la URL de la página empiece así (https://, dominio permitido) |
assert_text | text | Se para con error salvo que la página lo muestre |
Los textos admiten hasta 10000 caracteres, los selectores y valores hasta 2000 y las URL hasta 4000. Una propuesta lleva además un summary de una línea (hasta 300 caracteres), el target (la URL https:// donde cae la escritura; su host es el dominio de la acción) y una captura de la página tal como la vio la tarea.
Usa assert_url y assert_text para que una acción falle en vez de hacer otra cosa si la página ha cambiado desde que se propuso.
El hash de la carga
OpenWAF valida los pasos contra los dominios permitidos de la tarea y guarda payload_sha256: el SHA-256 en hexadecimal del JSON canónico de {"steps": [...]}, con los campos en el orden de la tabla (op, url, selector, text, key, value, ms), sin los campos vacíos, sin escapar HTML y sin salto de línea final.
Para aprobar devuelves ese hash con POST /v1/orgs/{org}/task-approvals/{id}/approve. Si no coincide con el guardado la respuesta es 409 payload_changed: apruebas lo que has visto y nada más. El runner vuelve a calcular el hash antes de actuar.
La fase de actuar
Tras la aprobación el runner abre un navegador nuevo con la clave y el perfil de la tarea, comprueba el hash y repite él mismo los pasos. No se ejecuta ni tu script ni el modelo. Las peticiones con métodos de escritura solo se permiten hacia el dominio de la acción y sus subdominios. La aprobación recibe executed_at, una captura final y un result: ok, final_url y error.
Si un límite por dominio aún no permite la acción, la aprobación sigue approved con not_before y se ejecuta entonces.
Estados
| Aprobación | Significado |
|---|---|
pending | Espera a una persona |
approved | Aprobada; espera al runner o a not_before |
executed | Los pasos se ejecutaron hasta el final |
failed | Falló un paso; result.error dice cuál |
rejected | Una persona la rechazó |
expired | Nadie decidió a tiempo |
Una ejecución con propuestas sigue en awaiting_approval hasta que no queda ninguna pendiente. Entonces termina succeeded si se ejecutó alguna acción, failed si alguna falló, rejected si se rechazaron todas o expired si caducaron.
Caducidad
Una aprobación caduca tras approval.expire_minutes: 120 minutos (2 horas) por defecto, entre 10 y 1440.
Políticas
approval.policy | Significado |
|---|---|
required | Cada acción espera a una persona. Es la única política que puedes fijar. |
relaxed | Las propuestas hacia relaxed_domains se aprueban solas. Solo la fija un operador, con motivo, y solo para destinos internos no públicos. |
Webhook
Dale a la tarea un webhook.url (una URL https:// pública) para recibir los eventos approval.requested y run.finished. El secreto (owts_…) se muestra una vez al fijar el webhook; "rotate_webhook_secret": true en un PATCH genera otro.
Cada entrega es un POST con un cuerpo JSON y estas cabeceras:
X-OpenWAF-Signature: t=1761735600,v1=5d2f…
X-OpenWAF-Event: approval.requested
X-OpenWAF-Delivery: 0b9d5c3e-…{"id": "0b9d5c3e-…", "type": "approval.requested", "created_at": "2026-10-29T10:00:00Z", "org_id": "…",
"task": {"id": "…", "name": "guestbook"},
"run": {"id": "…", "number": 12, "status": "awaiting_approval", "error": "", "finished_at": null},
"approval": {"id": "…", "status": "pending", "summary": "Sign the guestbook…", "target": "https://example.com/guestbook",
"domain": "example.com", "payload_sha256": "9c1e…", "expires_at": "2026-10-29T12:00:00Z"}}approval es null en run.finished. Una respuesta 2xx cuenta como entregada; si no, la entrega se reintenta 3 veces, tras 1, 5 y 30 minutos. X-OpenWAF-Delivery no cambia entre reintentos, así que puedes descartar duplicados.
t es la hora Unix de la entrega y v1 el HMAC-SHA256 en hexadecimal, con el secreto como clave, de t, un punto y el cuerpo tal cual. Recalcúlalo, compara en tiempo constante y rechaza entregas de más de 5 minutos:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(header, rawBody, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const want = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const got = Buffer.from(parts.v1 ?? "", "hex");
return got.length === want.length && timingSafeEqual(got, want);
}