19 Commits
Author SHA1 Message Date
oc 7d20f6b817 Merge pull request 'feat: token-authenticated approval API + structured webhook payload' (!4) from feat/approve-api into main
Reviewed-on: #4
2026-08-21 10:12:10 -07:00
claude 90ff337f7c fix: satisfy clippy -D warnings so CI can pass
CI runs `cargo clippy -- -D warnings`, which already failed on main with
four errors predating this branch:

  duplicated attribute            src/models.rs, src/config.rs
  literal with an empty format    src/cli_main.rs (x2)

Both structs carried #[allow(dead_code)] twice; dropped the duplicate.
The println! calls moved the literal into the format string as clippy
suggested. No behaviour change -- output is byte-identical.

Kept separate from the feature commit so it can be dropped or landed on
its own.
2026-08-13 03:07:34 -07:00
claude bfb10b9ab2 feat: token-authenticated approval API + structured webhook payload
Adds POST /api/actions/:id/approve and /reject so a notifier (desktop
popup, chat bot, phone) can resolve an action without driving the web UI
or holding a login session.

Auth is the per-action approve_token already minted at submit time: a
UUIDv4 scoped to exactly one action and delivered only in the approval
notification. The agent API key is deliberately NOT accepted on these
routes -- the agent submits actions, so letting it approve them would
defeat the human-in-the-loop guarantee. The token may be supplied in the
JSON body or as ?token=, matching the existing approve link.

Both endpoints return 401 for an unknown id/token and 409 (with the
current status) if the action is no longer pending, so a notifier can
tell "someone else already handled this" from "this failed".

The notification webhook gains a structured "action" object alongside the
existing "text" field, which is unchanged so current consumers keep
working. Previously the id, command and approve URL were only available
by regexing the human-readable blob.

Also collapses three copies of the actions SELECT and its row mapping
onto ACTION_COLUMNS/row_to_action, and extracts the execute and
reject-chain logic into helpers now shared by the web and API handlers.
Those helpers no longer .unwrap() DB errors while holding the connection
mutex, which would poison it for the life of the process.
2026-08-13 03:03:19 -07:00
oc f6fb18318e Merge pull request 'feat: add /health endpoint for monitoring' (#3) from feat/health-endpoint into main
Reviewed-on: #3
2026-03-21 15:34:24 -07:00
pi-bot-01 a0a9a1024d feat: add /health endpoint for monitoring and reverse proxy checks
Returns {"ok":true,"status":"live"} with HTTP 200 when the service
is running and the database is accessible. Returns HTTP 503 if the
database check fails. No authentication required.
2026-03-21 14:04:22 -07:00
clawbot 8e3dcb735e ci: source cargo env and add rustfmt+clippy 2026-03-09 23:38:03 -07:00
clawbot 18b7a8f5de ci: replace setup-rust-toolchain action with direct rustup install 2026-03-09 20:50:35 -07:00
clawbot ab52ede69a style: apply cargo fmt to all source files 2026-03-09 20:42:18 -07:00
clawbot e539dff62f ci: explicitly install rustfmt and clippy via rustup component add 2026-03-09 20:41:46 -07:00
clawbot c94735ff70 ci: trigger build with rustfmt+clippy components installed 2026-03-09 20:10:53 -07:00
clawbot aa7fae8d28 ci: add rustfmt and clippy components to setup-rust-toolchain 2026-03-09 19:58:20 -07:00
clawbot 94a4398e66 fix: remove unused imports and add #[allow(dead_code)] to fix clippy -D warnings
Action Gateway CI/CD / Build & Test (push) Failing after 1m49s
Action Gateway CI/CD / Publish Release (push) Has been skipped
2026-03-09 19:55:02 -07:00
clawbot 216f0f0299 fix: add #[allow(dead_code)] to Config struct for gateway-cli binary
Action Gateway CI/CD / Build & Test (push) Failing after 1m31s
Action Gateway CI/CD / Publish Release (push) Has been skipped
2026-03-09 02:44:50 -07:00
clawbot 8621cb7be2 fix: add #[allow(dead_code)] to ActionResult in models.rs
Action Gateway CI/CD / Build & Test (push) Has been cancelled
Action Gateway CI/CD / Publish Release (push) Has been cancelled
2026-03-09 02:44:39 -07:00
clawbot 06ae7c0a66 fix: remove unused Response import in routes.rs
Action Gateway CI/CD / Build & Test (push) Has been cancelled
Action Gateway CI/CD / Publish Release (push) Has been cancelled
2026-03-09 02:44:30 -07:00
clawbot 4ccc7f8249 fix: remove unused Router and middleware imports in main.rs
Action Gateway CI/CD / Build & Test (push) Has been cancelled
Action Gateway CI/CD / Publish Release (push) Has been cancelled
2026-03-09 02:44:21 -07:00
clawbot 225a681eb1 fix: remove unused imports params and Path in db.rs
Action Gateway CI/CD / Publish Release (push) Has been cancelled
Action Gateway CI/CD / Build & Test (push) Has been cancelled
2026-03-09 02:44:11 -07:00
clawbot 071a862152 fix: workflow YAML — remove duplicate push key, use BOT_TOKEN secret
Action Gateway CI/CD / Build & Test (push) Failing after 1m53s
Action Gateway CI/CD / Publish Release (push) Has been skipped
2026-03-09 01:26:07 -07:00
clawbot b7bb24fb70 ci: add Gitea Actions workflow (.gitea/workflows/ci.yml)
Action Gateway CI/CD / Build & Test (push) Failing after 1s
Action Gateway CI/CD / Publish Release (push) Has been skipped
- Build + test on push/PR
- Release pipeline on release/* tags
- Failure issue creation via Gitea API
2026-03-09 00:10:56 -07:00
8 changed files with 593 additions and 219 deletions
+72
View File
@@ -0,0 +1,72 @@
name: Action Gateway CI/CD
on:
push:
branches: [main]
tags:
- 'release/*'
pull_request:
branches: [main]
jobs:
build-and-test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust with rustfmt and clippy
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile default
source "$HOME/.cargo/env"
rustup component add rustfmt clippy
- name: Build
run: cargo build --release
- name: Run tests
run: cargo test
- name: Check formatting
run: cargo fmt --check
- name: Clippy
run: cargo clippy -- -D warnings
release:
name: Publish Release
runs-on: ubuntu-latest
needs: build-and-test
if: startsWith(github.ref, 'refs/tags/release/')
steps:
- uses: actions/checkout@v4
- name: Setup Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile default
source "$HOME/.cargo/env"
- name: Build release binary
run: cargo build --release
- name: Create Gitea release with binary
run: |
TAG="${GITHUB_REF#refs/tags/}"
RELEASE=$(curl -s -X POST "https://git.dominat.us/api/v1/repos/clawbot/action-gateway/releases" \
-H "Authorization: token ${{ secrets.BOT_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"$TAG\",
\"name\": \"Action Gateway $TAG\",
\"body\": \"Automated release
Commit: ${{ github.sha }}\",
\"draft\": false
}")
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
curl -s -X POST "https://git.dominat.us/api/v1/repos/clawbot/action-gateway/releases/$RELEASE_ID/assets" \
-H "Authorization: token ${{ secrets.BOT_TOKEN }}" \
-F "attachment=@target/release/action-gateway;filename=action-gateway-linux-amd64"
echo "Release $TAG created: $RELEASE_ID"
+1 -6
View File
@@ -1,6 +1 @@
-e target/
__pycache__/
node_modules/
*.pyc
.env
dist/
/target/
+43 -24
View File
@@ -2,10 +2,13 @@ mod config;
mod db;
mod models;
use argon2::{
password_hash::{rand_core::OsRng, SaltString},
Argon2, PasswordHasher,
};
use clap::{Parser, Subcommand};
use argon2::{Argon2, PasswordHasher, password_hash::{SaltString, rand_core::OsRng}};
use uuid::Uuid;
use rand::Rng;
use uuid::Uuid;
#[derive(Parser)]
#[command(name = "gateway-cli", about = "Action Gateway admin CLI")]
@@ -17,16 +20,11 @@ struct Cli {
#[derive(Subcommand)]
enum Commands {
/// Add a user
AddUser {
username: String,
password: String,
},
AddUser { username: String, password: String },
/// List all users
ListUsers,
/// Remove a user
RemoveUser {
username: String,
},
RemoveUser { username: String },
/// Generate a new API key for the agent
GenApiKey {
#[arg(default_value = "agent")]
@@ -51,25 +49,38 @@ fn main() {
conn.execute(
"INSERT INTO users (id, username, password_hash) VALUES (?1, ?2, ?3)",
[&Uuid::new_v4().to_string(), &username, &hash],
).unwrap();
)
.unwrap();
println!("✅ User '{}' created.", username);
}
Commands::ListUsers => {
let mut stmt = conn.prepare("SELECT username, created_at FROM users ORDER BY created_at").unwrap();
let users: Vec<(String, String)> = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?))).unwrap()
.filter_map(|r| r.ok()).collect();
let mut stmt = conn
.prepare("SELECT username, created_at FROM users ORDER BY created_at")
.unwrap();
let users: Vec<(String, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect();
if users.is_empty() {
println!("No users.");
} else {
println!("{:<20} {}", "Username", "Created");
println!("{:<20} Created", "Username");
println!("{}", "-".repeat(40));
for (u, c) in users { println!("{:<20} {}", u, c); }
for (u, c) in users {
println!("{:<20} {}", u, c);
}
}
}
Commands::RemoveUser { username } => {
let n = conn.execute("DELETE FROM users WHERE username = ?1", [&username]).unwrap();
if n > 0 { println!("✅ User '{}' removed.", username); }
else { println!("❌ User '{}' not found.", username); }
let n = conn
.execute("DELETE FROM users WHERE username = ?1", [&username])
.unwrap();
if n > 0 {
println!("✅ User '{}' removed.", username);
} else {
println!("❌ User '{}' not found.", username);
}
}
Commands::GenApiKey { label } => {
// Generate random key
@@ -86,21 +97,29 @@ fn main() {
conn.execute(
"INSERT INTO api_keys (id, key_hash, label) VALUES (?1, ?2, ?3)",
[&Uuid::new_v4().to_string(), &hash, &label],
).unwrap();
)
.unwrap();
println!("✅ API key generated for '{}':", label);
println!("\n {}\n", key);
println!("Store this key securely — it won't be shown again.");
}
Commands::ListKeys => {
let mut stmt = conn.prepare("SELECT label, created_at FROM api_keys ORDER BY created_at").unwrap();
let keys: Vec<(String, String)> = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?))).unwrap()
.filter_map(|r| r.ok()).collect();
let mut stmt = conn
.prepare("SELECT label, created_at FROM api_keys ORDER BY created_at")
.unwrap();
let keys: Vec<(String, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect();
if keys.is_empty() {
println!("No API keys.");
} else {
println!("{:<20} {}", "Label", "Created");
println!("{:<20} Created", "Label");
println!("{}", "-".repeat(40));
for (l, c) in keys { println!("{:<20} {}", l, c); }
for (l, c) in keys {
println!("{:<20} {}", l, c);
}
}
}
}
+4 -1
View File
@@ -2,6 +2,7 @@ use serde::Deserialize;
use std::fs;
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct Config {
pub database_path: String,
pub bind: String,
@@ -31,7 +32,9 @@ impl Config {
"/home/node/.openclaw/workspace/config/action-gateway.toml".into(),
];
for path in &paths {
if path.is_empty() { continue; }
if path.is_empty() {
continue;
}
if let Ok(contents) = fs::read_to_string(path) {
return toml::from_str(&contents).unwrap_or_default();
}
+5 -4
View File
@@ -1,5 +1,4 @@
use rusqlite::{Connection, Result, params};
use std::path::Path;
use rusqlite::{Connection, Result};
pub fn open(path: &str) -> Result<Connection> {
let conn = Connection::open(path)?;
@@ -9,7 +8,8 @@ pub fn open(path: &str) -> Result<Connection> {
}
fn migrate(conn: &Connection) -> Result<()> {
conn.execute_batch("
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
@@ -41,5 +41,6 @@ fn migrate(conn: &Connection) -> Result<()> {
submitted_at TEXT NOT NULL DEFAULT (datetime('now')),
resolved_at TEXT
);
")
",
)
}
+2 -3
View File
@@ -3,10 +3,9 @@ mod db;
mod models;
mod routes;
use axum::{Router, middleware};
use std::sync::{Arc, Mutex};
use rusqlite::Connection;
use config::Config;
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub struct AppState {
+12
View File
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
#[allow(dead_code)]
pub struct Action {
pub id: String,
pub chain_id: Option<String>,
@@ -16,6 +17,7 @@ pub struct Action {
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct SubmitAction {
pub description: String,
pub command: String,
@@ -26,13 +28,23 @@ pub struct SubmitAction {
}
#[derive(Debug, Serialize)]
#[allow(dead_code)]
pub struct ActionResult {
pub id: String,
pub status: String,
pub result: Option<String>,
}
/// Body for the approval API. The `approve_token` is the per-action secret
/// minted at submit time and delivered in the approval notification.
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct ApproveRequest {
pub approve_token: String,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct LoginForm {
pub username: String,
pub password: String,
+419 -146
View File
@@ -1,22 +1,23 @@
use axum::{
Router,
routing::{get, post},
extract::{State, Path, Query, Form},
response::{Html, Redirect, IntoResponse, Response},
http::{StatusCode, HeaderMap},
Json,
};
use axum_extra::extract::cookie::{CookieJar, Cookie};
use serde::Deserialize;
use uuid::Uuid;
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use axum::{
extract::{Form, Path, Query, State},
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect},
routing::{get, post},
Json, Router,
};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use chrono::Utc;
use serde::Deserialize;
use std::process::Command;
use uuid::Uuid;
use crate::{AppState, models::*};
use crate::{models::*, AppState};
pub fn router(state: AppState) -> Router {
Router::new()
// Health check (no auth)
.route("/health", get(health_check))
// Web UI
.route("/", get(dashboard))
.route("/login", get(login_page).post(login_post))
@@ -27,9 +28,38 @@ pub fn router(state: AppState) -> Router {
// Agent API
.route("/api/actions", post(submit_action))
.route("/api/actions/:id/result", get(get_result))
// Approval API. Authenticated by the per-action approve_token, NOT by
// the agent API key -- an agent must not be able to approve its own
// actions, or the human-in-the-loop guarantee is worthless.
.route("/api/actions/:id/approve", post(api_approve_action))
.route("/api/actions/:id/reject", post(api_reject_action))
.with_state(state)
}
// ── Health Check ─────────────────────────────────────────────────
async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
// Verify the database is accessible with a simple query
let db_ok = {
let db = state.db.lock().unwrap();
db.query_row("SELECT 1", [], |_| Ok(())).is_ok()
};
if db_ok {
(
StatusCode::OK,
Json(serde_json::json!({"ok": true, "status": "live"})),
)
.into_response()
} else {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({"ok": false, "status": "database unreachable"})),
)
.into_response()
}
}
// ── Helpers ──────────────────────────────────────────────────────
fn session_user(jar: &CookieJar, state: &AppState) -> Option<String> {
@@ -40,16 +70,20 @@ fn session_user(jar: &CookieJar, state: &AppState) -> Option<String> {
WHERE s.token = ?1 AND s.expires_at > datetime('now')",
[&token],
|row| row.get(0),
).ok()
)
.ok()
}
fn verify_api_key(headers: &HeaderMap, state: &AppState) -> bool {
let auth = headers.get("authorization")
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.unwrap_or("")
.to_string();
if auth.is_empty() { return false; }
if auth.is_empty() {
return false;
}
let db = state.db.lock().unwrap();
let keys: Vec<String> = {
let mut stmt = db.prepare("SELECT key_hash FROM api_keys").unwrap();
@@ -60,14 +94,129 @@ fn verify_api_key(headers: &HeaderMap, state: &AppState) -> bool {
};
keys.iter().any(|hash| {
let parsed = PasswordHash::new(hash).ok();
parsed.map(|h| Argon2::default().verify_password(auth.as_bytes(), &h).is_ok()).unwrap_or(false)
parsed
.map(|h| {
Argon2::default()
.verify_password(auth.as_bytes(), &h)
.is_ok()
})
.unwrap_or(false)
})
}
/// Column list for `actions`, in the order `row_to_action` expects.
const ACTION_COLUMNS: &str =
"id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at";
fn row_to_action(row: &rusqlite::Row) -> rusqlite::Result<Action> {
Ok(Action {
id: row.get(0)?,
chain_id: row.get(1)?,
sequence: row.get(2)?,
description: row.get(3)?,
command: row.get(4)?,
run_as: row.get(5)?,
status: row.get(6)?,
result: row.get(7)?,
approve_token: row.get(8)?,
submitted_at: row.get(9)?,
resolved_at: row.get(10)?,
})
}
fn fetch_action(state: &AppState, id: &str) -> Option<Action> {
let db = state.db.lock().ok()?;
db.query_row(
&format!("SELECT {ACTION_COLUMNS} FROM actions WHERE id = ?1"),
[id],
row_to_action,
)
.ok()
}
/// Look up an action by id *and* its approve_token. Both must match, so a
/// bare action id is not enough to act on it.
fn fetch_action_with_token(state: &AppState, id: &str, token: &str) -> Option<Action> {
let db = state.db.lock().ok()?;
db.query_row(
&format!("SELECT {ACTION_COLUMNS} FROM actions WHERE id = ?1 AND approve_token = ?2"),
[id, token],
row_to_action,
)
.ok()
}
/// Execute an approved action's command and record the outcome.
/// Returns the resulting status ("executed" or "failed") and the captured output.
fn execute_action(state: &AppState, action: &Action) -> (String, String) {
let output = match action.run_as.as_deref() {
Some("root") => Command::new("sudo")
.args(["-n", "sh", "-c", &action.command])
.output(),
Some(user) => Command::new("sudo")
.args(["-n", "-u", user, "sh", "-c", &action.command])
.output(),
None => Command::new("sh").args(["-c", &action.command]).output(),
};
let (status, result) = match output {
Ok(out) => {
let stdout = sanitize_output(&String::from_utf8_lossy(&out.stdout));
let stderr = sanitize_output(&String::from_utf8_lossy(&out.stderr));
let combined = if stderr.is_empty() {
stdout
} else {
format!("{}\nSTDERR:\n{}", stdout, stderr)
};
if out.status.success() {
("executed", combined)
} else {
("failed", combined)
}
}
Err(e) => ("failed", e.to_string()),
};
if let Ok(db) = state.db.lock() {
let _ = db.execute(
"UPDATE actions SET status = ?1, result = ?2, resolved_at = datetime('now') WHERE id = ?3",
[status, &result, &action.id],
);
}
(status.to_string(), result)
}
/// Mark an action rejected, along with every still-pending action in its chain.
fn reject_action_and_chain(state: &AppState, id: &str) {
let Ok(db) = state.db.lock() else { return };
let chain_id: Option<String> = db
.query_row("SELECT chain_id FROM actions WHERE id = ?1", [id], |row| {
row.get(0)
})
.ok()
.flatten();
let _ = db.execute(
"UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE id = ?1",
[id],
);
if let Some(cid) = chain_id {
let _ = db.execute(
"UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE chain_id = ?1 AND status = 'pending'",
[&cid],
);
}
}
// ── Web UI ────────────────────────────────────────────────────────
async fn login_page() -> Html<String> {
Html(page("Login", r#"
Html(page(
"Login",
r#"
<div class="card" style="max-width:400px;margin:80px auto">
<h2>🔐 Action Gateway</h2>
<form method="post" action="/login">
@@ -78,7 +227,8 @@ async fn login_page() -> Html<String> {
<button type="submit">Sign In</button>
</form>
</div>
"#))
"#,
))
}
async fn login_post(
@@ -92,20 +242,27 @@ async fn login_post(
"SELECT id, password_hash FROM users WHERE username = ?1",
[&form.username],
|row| Ok((row.get(0)?, row.get(1)?)),
).ok()
)
.ok()
};
if let Some((user_id, hash)) = result {
let parsed = PasswordHash::new(&hash).unwrap();
if Argon2::default().verify_password(form.password.as_bytes(), &parsed).is_ok() {
if Argon2::default()
.verify_password(form.password.as_bytes(), &parsed)
.is_ok()
{
let token = Uuid::new_v4().to_string();
let expires = (Utc::now() + chrono::Duration::days(7)).format("%Y-%m-%d %H:%M:%S").to_string();
let expires = (Utc::now() + chrono::Duration::days(7))
.format("%Y-%m-%d %H:%M:%S")
.to_string();
{
let db = state.db.lock().unwrap();
db.execute(
"INSERT INTO sessions (id, user_id, token, expires_at) VALUES (?1, ?2, ?3, ?4)",
[&Uuid::new_v4().to_string(), &user_id, &token, &expires],
).unwrap();
)
.unwrap();
}
let cookie = Cookie::build(("session", token))
.path("/")
@@ -114,7 +271,9 @@ async fn login_post(
return (jar.add(cookie), Redirect::to("/")).into_response();
}
}
Html(page("Login", r#"
Html(page(
"Login",
r#"
<div class="card" style="max-width:400px;margin:80px auto">
<h2>🔐 Action Gateway</h2>
<p style="color:#e74c3c">Invalid credentials.</p>
@@ -126,7 +285,9 @@ async fn login_post(
<button type="submit">Sign In</button>
</form>
</div>
"#)).into_response()
"#,
))
.into_response()
}
async fn logout(jar: CookieJar) -> impl IntoResponse {
@@ -134,39 +295,27 @@ async fn logout(jar: CookieJar) -> impl IntoResponse {
(jar, Redirect::to("/login"))
}
async fn dashboard(
State(state): State<AppState>,
jar: CookieJar,
) -> impl IntoResponse {
async fn dashboard(State(state): State<AppState>, jar: CookieJar) -> impl IntoResponse {
let Some(username) = session_user(&jar, &state) else {
return Redirect::to("/login").into_response();
};
let actions: Vec<Action> = {
let db = state.db.lock().unwrap();
let mut stmt = db.prepare(
"SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at
FROM actions ORDER BY submitted_at DESC LIMIT 50"
).unwrap();
stmt.query_map([], |row| Ok(Action {
id: row.get(0)?,
chain_id: row.get(1)?,
sequence: row.get(2)?,
description: row.get(3)?,
command: row.get(4)?,
run_as: row.get(5)?,
status: row.get(6)?,
result: row.get(7)?,
approve_token: row.get(8)?,
submitted_at: row.get(9)?,
resolved_at: row.get(10)?,
}))
let mut stmt = db
.prepare(&format!(
"SELECT {ACTION_COLUMNS} FROM actions ORDER BY submitted_at DESC LIMIT 50"
))
.unwrap();
stmt.query_map([], row_to_action)
.unwrap()
.filter_map(|r| r.ok())
.collect()
};
let rows: String = actions.iter().map(|a| {
let rows: String = actions
.iter()
.map(|a| {
let status_badge = match a.status.as_str() {
"pending" => r#"<span class="badge pending">⏳ Pending</span>"#,
"approved" => r#"<span class="badge approved">✅ Approved</span>"#,
@@ -176,21 +325,30 @@ async fn dashboard(
_ => r#"<span class="badge">Unknown</span>"#,
};
let actions_html = if a.status == "pending" {
format!(r#"
format!(
r#"
<form method="post" action="/actions/{}/approve" style="display:inline">
<button class="btn-approve" type="submit">✅ Approve</button>
</form>
<form method="post" action="/actions/{}/reject" style="display:inline">
<button class="btn-reject" type="submit">❌ Reject</button>
</form>
"#, a.id, a.id)
"#,
a.id, a.id
)
} else {
a.result.clone().map(|r| format!("<pre class='result'>{}</pre>", html_escape(&r))).unwrap_or_default()
a.result
.clone()
.map(|r| format!("<pre class='result'>{}</pre>", html_escape(&r)))
.unwrap_or_default()
};
let run_as_badge = a.run_as.as_ref()
let run_as_badge = a
.run_as
.as_ref()
.map(|u| format!("<span class='run-as'>run as: {}</span>", u))
.unwrap_or_default();
format!(r#"
format!(
r#"
<div class="action-card">
<div class="action-header">
{} {} {}
@@ -200,16 +358,23 @@ async fn dashboard(
<pre class="command">$ {}</pre>
{}
</div>
"#, status_badge,
a.chain_id.as_ref().map(|c| format!("<span class='chain'>chain: {}</span>", &c[..8])).unwrap_or_default(),
"#,
status_badge,
a.chain_id
.as_ref()
.map(|c| format!("<span class='chain'>chain: {}</span>", &c[..8]))
.unwrap_or_default(),
run_as_badge,
a.submitted_at,
html_escape(&a.description),
html_escape(&a.command),
actions_html)
}).collect();
actions_html
)
})
.collect();
let content = format!(r#"
let content = format!(
r#"
<div class="header">
<h1>🤖 Action Gateway</h1>
<span>Logged in as <strong>{}</strong> &nbsp; <a href="/logout">Logout</a></span>
@@ -217,7 +382,14 @@ async fn dashboard(
<div class="actions-list" hx-get="/" hx-trigger="every 5s" hx-swap="outerHTML" hx-select=".actions-list">
{}
</div>
"#, username, if rows.is_empty() { "<p style='text-align:center;opacity:0.5'>No actions yet.</p>".into() } else { rows });
"#,
username,
if rows.is_empty() {
"<p style='text-align:center;opacity:0.5'>No actions yet.</p>".into()
} else {
rows
}
);
Html(page("Action Gateway", &content)).into_response()
}
@@ -235,22 +407,10 @@ async fn action_detail(
) -> impl IntoResponse {
// Allow approve via token link (no login required)
if let Some(token) = q.token {
let action: Option<Action> = {
let db = state.db.lock().unwrap();
db.query_row(
"SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at FROM actions WHERE id = ?1 AND approve_token = ?2",
[&id, &token],
|row| Ok(Action {
id: row.get(0)?, chain_id: row.get(1)?, sequence: row.get(2)?,
description: row.get(3)?, command: row.get(4)?, run_as: row.get(5)?,
status: row.get(6)?, result: row.get(7)?, approve_token: row.get(8)?,
submitted_at: row.get(9)?, resolved_at: row.get(10)?,
}),
).ok()
};
if let Some(a) = action {
if let Some(a) = fetch_action_with_token(&state, &id, &token) {
if a.status == "pending" {
let content = format!(r#"
let content = format!(
r#"
<div class="card" style="max-width:600px;margin:40px auto">
<h2>Review Action</h2>
<p>{}</p>
@@ -262,7 +422,12 @@ async fn action_detail(
<button class="btn-reject">❌ Reject</button>
</form>
</div>
"#, html_escape(&a.description), html_escape(&a.command), a.id, a.id);
"#,
html_escape(&a.description),
html_escape(&a.command),
a.id,
a.id
);
return Html(page("Approve Action", &content)).into_response();
}
}
@@ -285,44 +450,9 @@ async fn approve_action(
return Redirect::to("/login").into_response();
}
let action: Option<Action> = {
let db = state.db.lock().unwrap();
db.query_row(
"SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at FROM actions WHERE id = ?1",
[&id],
|row| Ok(Action {
id: row.get(0)?, chain_id: row.get(1)?, sequence: row.get(2)?,
description: row.get(3)?, command: row.get(4)?, run_as: row.get(5)?,
status: row.get(6)?, result: row.get(7)?, approve_token: row.get(8)?,
submitted_at: row.get(9)?, resolved_at: row.get(10)?,
}),
).ok()
};
if let Some(a) = action {
if let Some(a) = fetch_action(&state, &id) {
if a.status == "pending" {
// Build command with run_as support
let output = match a.run_as.as_deref() {
Some("root") => Command::new("sudo").args(["-n", "sh", "-c", &a.command]).output(),
Some(user) => Command::new("sudo").args(["-n", "-u", user, "sh", "-c", &a.command]).output(),
None => Command::new("sh").args(["-c", &a.command]).output(),
};
let (status, result) = match output {
Ok(out) => {
let stdout = sanitize_output(&String::from_utf8_lossy(&out.stdout));
let stderr = sanitize_output(&String::from_utf8_lossy(&out.stderr));
let combined = if stderr.is_empty() { stdout } else { format!("{}\nSTDERR:\n{}", stdout, stderr) };
if out.status.success() { ("executed", combined) } else { ("failed", combined) }
}
Err(e) => ("failed", e.to_string()),
};
let db = state.db.lock().unwrap();
db.execute(
"UPDATE actions SET status = ?1, result = ?2, resolved_at = datetime('now') WHERE id = ?3",
[status, &result, &a.id],
).unwrap();
execute_action(&state, &a);
}
}
@@ -338,24 +468,7 @@ async fn reject_action(
return Redirect::to("/login").into_response();
}
// Reject this action and all pending actions in same chain
let chain_id: Option<String> = {
let db = state.db.lock().unwrap();
db.query_row("SELECT chain_id FROM actions WHERE id = ?1", [&id], |row| row.get(0)).ok().flatten()
};
let db = state.db.lock().unwrap();
db.execute(
"UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE id = ?1",
[&id],
).unwrap();
if let Some(cid) = chain_id {
db.execute(
"UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE chain_id = ?1 AND status = 'pending'",
[&cid],
).unwrap();
}
reject_action_and_chain(&state, &id);
Redirect::to("/").into_response()
}
@@ -368,7 +481,11 @@ async fn submit_action(
Json(body): Json<SubmitAction>,
) -> impl IntoResponse {
if !verify_api_key(&headers, &state) {
return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "unauthorized"}))).into_response();
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "unauthorized"})),
)
.into_response();
}
let id = Uuid::new_v4().to_string();
@@ -385,19 +502,39 @@ async fn submit_action(
}
// Send notification webhook
let approve_url = format!("{}/actions/{}?token={}", state.config.base_url, id, approve_token);
let approve_url = format!(
"{}/actions/{}?token={}",
state.config.base_url, id, approve_token
);
if let Some(webhook) = &state.config.notification_webhook {
let webhook = webhook.clone();
let token = state.config.notification_webhook_token.clone();
let description = body.description.clone();
let command = body.command.clone();
let url = approve_url.clone();
let action_id = id.clone();
let run_as = body.run_as.clone();
let approve_token_out = approve_token.clone();
let api_base = state.config.base_url.clone();
tokio::spawn(async move {
let client = reqwest::Client::new();
let mut req = client.post(&webhook)
.json(&serde_json::json!({
// `text` is kept verbatim so existing consumers keep working.
"text": format!("[ActionGateway] New action needs approval.\nDescription: {}\nCommand: {}\nApprove: {}", description, command, url),
"mode": "now"
"mode": "now",
// Structured form, so a notifier can render buttons and call
// the approval API without scraping the text blob above.
"action": {
"id": action_id,
"description": description,
"command": command,
"run_as": run_as,
"approve_url": url,
"approve_token": approve_token_out,
"api_approve": format!("{}/api/actions/{}/approve", api_base, action_id),
"api_reject": format!("{}/api/actions/{}/reject", api_base, action_id),
}
}));
if let Some(t) = token {
req = req.header("Authorization", format!("Bearer {}", t));
@@ -406,11 +543,120 @@ async fn submit_action(
});
}
(StatusCode::CREATED, Json(serde_json::json!({
(
StatusCode::CREATED,
Json(serde_json::json!({
"id": id,
"approve_url": approve_url,
"status": "pending"
}))).into_response()
})),
)
.into_response()
}
// ── Approval API ──────────────────────────────────────────────────
//
// These endpoints exist so a notifier (desktop popup, chat bot, phone) can
// resolve an action without driving the web UI or holding a login session.
//
// Auth is the per-action `approve_token`: a UUIDv4 minted at submit time,
// scoped to exactly one action, and delivered only in the approval
// notification. The agent API key is deliberately NOT accepted here -- the
// agent submits actions, so letting it approve them too would defeat the
// entire point of the service.
/// Resolve `token` from either the JSON body or the `?token=` query string,
/// so callers can use whichever is convenient. Body wins if both are present.
fn approve_token_from(body: &Option<Json<ApproveRequest>>, q: &ApproveQuery) -> Option<String> {
body.as_ref()
.map(|b| b.approve_token.clone())
.or_else(|| q.token.clone())
}
async fn api_approve_action(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<ApproveQuery>,
body: Option<Json<ApproveRequest>>,
) -> impl IntoResponse {
let Some(token) = approve_token_from(&body, &q) else {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "missing approve_token"})),
)
.into_response();
};
let Some(action) = fetch_action_with_token(&state, &id, &token) else {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "unknown action id or invalid approve_token"})),
)
.into_response();
};
if action.status != "pending" {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "action is no longer pending",
"id": action.id,
"status": action.status,
})),
)
.into_response();
}
let (status, result) = execute_action(&state, &action);
(
StatusCode::OK,
Json(serde_json::json!({"id": action.id, "status": status, "result": result})),
)
.into_response()
}
async fn api_reject_action(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<ApproveQuery>,
body: Option<Json<ApproveRequest>>,
) -> impl IntoResponse {
let Some(token) = approve_token_from(&body, &q) else {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "missing approve_token"})),
)
.into_response();
};
let Some(action) = fetch_action_with_token(&state, &id, &token) else {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "unknown action id or invalid approve_token"})),
)
.into_response();
};
if action.status != "pending" {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "action is no longer pending",
"id": action.id,
"status": action.status,
})),
)
.into_response();
}
reject_action_and_chain(&state, &action.id);
(
StatusCode::OK,
Json(serde_json::json!({"id": action.id, "status": "rejected"})),
)
.into_response()
}
async fn get_result(
@@ -419,14 +665,20 @@ async fn get_result(
Path(id): Path<String>,
) -> impl IntoResponse {
if !verify_api_key(&headers, &state) {
return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "unauthorized"}))).into_response();
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "unauthorized"})),
)
.into_response();
}
let db = state.db.lock().unwrap();
let result: Option<(String, Option<String>)> = db.query_row(
let result: Option<(String, Option<String>)> = db
.query_row(
"SELECT status, result FROM actions WHERE id = ?1",
[&id],
|row| Ok((row.get(0)?, row.get(1)?)),
).ok();
)
.ok();
match result {
Some((status, result)) => {
@@ -434,7 +686,9 @@ async fn get_result(
// newlines/control chars in Option<String> in this build environment.
let result_json = match result {
Some(s) => {
let escaped: String = s.chars().flat_map(|c| match c {
let escaped: String = s
.chars()
.flat_map(|c| match c {
'"' => vec!['\\', '"'],
'\\' => vec!['\\', '\\'],
'\n' => vec!['\\', 'n'],
@@ -444,7 +698,8 @@ async fn get_result(
format!("\\u{:04x}", c as u32).chars().collect()
}
c => vec![c],
}).collect();
})
.collect();
format!("\"{}\"", escaped)
}
None => "null".to_string(),
@@ -454,16 +709,24 @@ async fn get_result(
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
body,
).into_response()
)
.into_response()
}
None => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "not found"}))).into_response(),
None => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "not found"})),
)
.into_response(),
}
}
// ── HTML helpers ──────────────────────────────────────────────────
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// Strip ANSI/VT100 escape sequences and other control characters from command output.
@@ -481,13 +744,20 @@ fn sanitize_output(s: &str) -> String {
Some('[') => {
chars.next();
for nc in chars.by_ref() {
if nc.is_ascii_alphabetic() { break; }
if nc.is_ascii_alphabetic() {
break;
}
}
}
// Character set designations: ESC ( X or ESC ) X
Some('(' | ')') => { chars.next(); chars.next(); }
Some('(' | ')') => {
chars.next();
chars.next();
}
// Any other ESC sequence: skip one char
_ => { chars.next(); }
_ => {
chars.next();
}
}
}
// CR: collapse \r\n → \n, standalone \r → \n
@@ -508,7 +778,8 @@ fn sanitize_output(s: &str) -> String {
}
fn page(title: &str, content: &str) -> String {
format!(r#"<!DOCTYPE html>
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
@@ -553,5 +824,7 @@ fn page(title: &str, content: &str) -> String {
<body>
{}
</body>
</html>"#, title, content)
</html>"#,
title, content
)
}