Add incoming webhooks, fix stale service worker and push re-registration
- Webhooks for automations: per-job secret, Gitea/GitHub HMAC signatures, payload appended to the instructions; settings on the automations page - Service worker gets a new file name per build (proxies that cache *.js served a stale worker, which broke updates and Android installation); /sw.js is a kill switch that removes old workers - Push: devices re-register their subscription with the server on start and before a test; the test reports why a push service rejected a message Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6dffcb501a
commit
10d4b64289
16 files changed
+267
-18
No files matched your search
@@ -38,6 +38,8 @@ pub struct Job {
|
||||
pub last_session_id: Option<String>,
|
||||
pub last_status: Option<String>,
|
||||
pub created_at: i64,
|
||||
/// Webhook enabled (the secret – also the HMAC key – is only shown once when created).
|
||||
pub webhook: bool,
|
||||
}
|
||||
|
||||
/// Parsed job file.
|
||||
@@ -117,6 +119,11 @@ pub fn describe(job: &Job) -> Value {
|
||||
|
||||
/// Start a run now. Returns the new session id.
|
||||
pub async fn run(app: &Arc<App>, job: &Job, trigger: &str) -> Result<String> {
|
||||
run_with(app, job, trigger, None).await
|
||||
}
|
||||
|
||||
/// Start a run; `payload` (webhook body) is appended to the instructions.
|
||||
pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<String>) -> Result<String> {
|
||||
let path = PathBuf::from(&job.path);
|
||||
let spec = match parse(&path) {
|
||||
Ok(s) => s,
|
||||
@@ -161,6 +168,10 @@ pub async fn run(app: &Arc<App>, job: &Job, trigger: &str) -> Result<String> {
|
||||
"<automation name=\"{}\" trigger=\"{trigger}\" file=\"{}\">\nThis is an unattended, scheduled run – nobody is watching live. Work autonomously, avoid questions unless you are truly blocked, and end with a short report of what you did and found.\n</automation>\n\n{}",
|
||||
spec.name, job.path, spec.prompt
|
||||
);
|
||||
let text = match payload {
|
||||
Some(p) => format!("{text}\n\n<webhook-payload>\n{p}\n</webhook-payload>\nThe automation was triggered by this webhook call – use the payload as input."),
|
||||
None => text,
|
||||
};
|
||||
crate::runner::submit(app.clone(), session.id.clone(), vec![Part::Text { text }], "user")?;
|
||||
|
||||
// Watch for the end (or the timeout).
|
||||
@@ -375,3 +386,67 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// --- incoming webhooks -------------------------------------------------------------
|
||||
|
||||
fn constant_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
fn hmac_hex(secret: &str, body: &[u8]) -> String {
|
||||
use hmac::{KeyInit, Mac};
|
||||
let mut mac = <hmac::Hmac<sha2::Sha256> as KeyInit>::new_from_slice(secret.as_bytes()).expect("hmac key");
|
||||
mac.update(body);
|
||||
mac.finalize().into_bytes().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Check a webhook call: shared secret (header/query) or Gitea/GitHub HMAC signature.
|
||||
pub fn verify_webhook(secret: &str, headers: &axum::http::HeaderMap, query_secret: Option<&str>, body: &[u8]) -> bool {
|
||||
let h = |k: &str| headers.get(k).and_then(|v| v.to_str().ok()).map(|v| v.trim().to_string());
|
||||
if let Some(given) = h("x-codecraft-secret").or(query_secret.map(String::from)) {
|
||||
return constant_eq(given.as_bytes(), secret.as_bytes());
|
||||
}
|
||||
let expected = hmac_hex(secret, body);
|
||||
if let Some(sig) = h("x-gitea-signature").or(h("x-gogs-signature")) {
|
||||
return constant_eq(sig.to_lowercase().as_bytes(), expected.as_bytes());
|
||||
}
|
||||
if let Some(sig) = h("x-hub-signature-256") {
|
||||
return constant_eq(sig.trim_start_matches("sha256=").to_lowercase().as_bytes(), expected.as_bytes());
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The payload as text for the prompt (pretty JSON, event name, size-limited).
|
||||
pub fn payload_text(headers: &axum::http::HeaderMap, body: &[u8]) -> String {
|
||||
let event = ["x-gitea-event", "x-github-event", "x-gitlab-event", "x-event-key"]
|
||||
.iter()
|
||||
.find_map(|k| headers.get(*k).and_then(|v| v.to_str().ok()))
|
||||
.map(|e| format!("event: {e}\n"))
|
||||
.unwrap_or_default();
|
||||
let text = match serde_json::from_slice::<Value>(body) {
|
||||
Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_default(),
|
||||
Err(_) => String::from_utf8_lossy(body).into_owned(),
|
||||
};
|
||||
let mut text: String = text.chars().take(20_000).collect();
|
||||
if body.len() > 20_000 {
|
||||
text.push_str("\n… (payload truncated)");
|
||||
}
|
||||
format!("{event}{text}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod webhook_tests {
|
||||
#[test]
|
||||
fn signatures() {
|
||||
let mut h = axum::http::HeaderMap::new();
|
||||
let sig = super::hmac_hex("s3cret", b"{\"a\":1}");
|
||||
h.insert("x-hub-signature-256", format!("sha256={sig}").parse().unwrap());
|
||||
assert!(super::verify_webhook("s3cret", &h, None, b"{\"a\":1}"));
|
||||
assert!(!super::verify_webhook("other", &h, None, b"{\"a\":1}"));
|
||||
let mut h2 = axum::http::HeaderMap::new();
|
||||
h2.insert("x-gitea-signature", sig.parse().unwrap());
|
||||
assert!(super::verify_webhook("s3cret", &h2, None, b"{\"a\":1}"));
|
||||
assert!(super::verify_webhook("abc", &axum::http::HeaderMap::new(), Some("abc"), b""));
|
||||
assert!(!super::verify_webhook("abc", &axum::http::HeaderMap::new(), None, b""));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user