This commit is contained in:
pavel 2026-02-10 20:33:04 +01:00
commit 643203b8a5
9 changed files with 822 additions and 44 deletions

302
Cargo.lock generated
View file

@ -208,6 +208,36 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "axum-extra"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
dependencies = [
"axum",
"axum-core",
"bytes",
"fastrand",
"futures-util",
"headers",
"http",
"http-body",
"http-body-util",
"mime",
"multer",
"pin-project-lite",
"serde",
"tower",
"tower-layer",
"tower-service",
]
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@ -292,8 +322,11 @@ name = "bot"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"axum-extra",
"base64",
"chrono", "chrono",
"dashmap", "dashmap",
"jsonwebtoken",
"migration", "migration",
"reqwest", "reqwest",
"sea-orm", "sea-orm",
@ -519,6 +552,18 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@ -529,6 +574,33 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.20.11" version = "0.20.11"
@ -681,6 +753,44 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@ -690,6 +800,27 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"ff",
"generic-array",
"group",
"hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core",
"sec1",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@ -743,6 +874,22 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@ -896,6 +1043,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [ dependencies = [
"typenum", "typenum",
"version_check", "version_check",
"zeroize",
] ]
[[package]] [[package]]
@ -927,6 +1075,17 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core",
"subtle",
]
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.13" version = "0.4.13"
@ -987,6 +1146,30 @@ dependencies = [
"hashbrown 0.15.5", "hashbrown 0.15.5",
] ]
[[package]]
name = "headers"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
dependencies = [
"base64",
"bytes",
"headers-core",
"http",
"httpdate",
"mime",
"sha1",
]
[[package]]
name = "headers-core"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
dependencies = [
"http",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.4.1" version = "0.4.1"
@ -1348,6 +1531,29 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "jsonwebtoken"
version = "10.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1"
dependencies = [
"base64",
"ed25519-dalek",
"getrandom 0.2.17",
"hmac",
"js-sys",
"p256",
"p384",
"pem",
"rand",
"rsa",
"serde",
"serde_json",
"sha2",
"signature",
"simple_asn1",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -1472,6 +1678,23 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.14" version = "0.2.14"
@ -1651,6 +1874,30 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]]
name = "p384"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@ -1680,6 +1927,16 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"serde_core",
]
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@ -1785,6 +2042,15 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.4.0" version = "3.4.0"
@ -2005,6 +2271,16 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"subtle",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@ -2332,6 +2608,20 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "2.11.1" version = "2.11.1"
@ -2490,6 +2780,18 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simple_asn1"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
dependencies = [
"num-bigint",
"num-traits",
"thiserror",
"time",
]
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.2" version = "1.0.2"

View file

@ -20,3 +20,6 @@ uuid = { version = "1.8", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
tokio-cron-scheduler = "0.15.1" tokio-cron-scheduler = "0.15.1"
dashmap = "6.1.0" dashmap = "6.1.0"
jsonwebtoken = { version = "10.3.0", features = ["rsa", "rust_crypto"] }
base64 = "0.22.1"
axum-extra = { version = "0.9", features = ["typed-header"] }

View file

@ -14,7 +14,23 @@
</head> </head>
<body class="dark-theme"> <body class="dark-theme">
<div id="app"> <div id="login-overlay" class="login-overlay glass hidden">
<div class="login-box">
<span class="logo-icon"></span>
<div class="login-title">Agency</div>
<p>Secure Agent Dashboard</p>
<button id="login-btn" class="btn btn-primary">Login with Authentik</button>
</div>
</div>
<div id="callback-overlay" class="login-overlay glass hidden">
<div class="login-box">
<div class="loader"></div>
<p>Authenticating with Authentik...</p>
</div>
</div>
<div id="app" class="hidden">
<aside class="sidebar glass"> <aside class="sidebar glass">
<header class="sidebar-header"> <header class="sidebar-header">
<a href="#" id="logo-link" class="logo"> <a href="#" id="logo-link" class="logo">
@ -31,6 +47,11 @@
<!-- Tasks will be injected here --> <!-- Tasks will be injected here -->
</ul> </ul>
</nav> </nav>
<footer class="sidebar-footer">
<button id="logout-btn" class="btn btn-ghost btn-sm logout-btn">
Logout
</button>
</footer>
</aside> </aside>
<main class="main-content"> <main class="main-content">
@ -127,7 +148,7 @@
<div class="form-group"> <div class="form-group">
<label>Execution Schedule</label> <label>Execution Schedule</label>
<div id="schedule-presets" class="preset-group"> <div id="schedule-presets" class="preset-group">
<button type="button" class="btn btn-preset active" data-cron="">One-time</button> <button type="button" class="btn btn-preset active" data-cron="">Manual</button>
<button type="button" class="btn btn-preset" data-cron="0 */5 * * * *">Every 5m</button> <button type="button" class="btn btn-preset" data-cron="0 */5 * * * *">Every 5m</button>
<button type="button" class="btn btn-preset" data-cron="0 0 * * * *">Hourly</button> <button type="button" class="btn btn-preset" data-cron="0 0 * * * *">Hourly</button>
<button type="button" class="btn btn-preset" data-cron="0 0 0 * * *">Daily</button> <button type="button" class="btn btn-preset" data-cron="0 0 0 * * *">Daily</button>
@ -147,8 +168,7 @@
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-ghost" id="cancel-task">Cancel</button> <button type="button" class="btn btn-ghost" id="cancel-task">Cancel</button>
<button type="submit" class="btn btn-primary" id="submit-task-btn">Execute <button type="submit" class="btn btn-primary" id="submit-task-btn">Save Task</button>
Directive</button>
</div> </div>
</form> </form>
</div> </div>

View file

@ -2,6 +2,13 @@ import { marked } from 'marked';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
const API_URL = 'http://localhost:3000'; const API_URL = 'http://localhost:3000';
// These should ideally be environment-specific
const AUTH_CONFIG = {
issuer: 'https://idm.flegr.me/application/o/bot/',
clientId: 'CicDk8mpSBY1SW4ofCamO3B583ttmvKTSPDQwvpb',
redirectUri: window.location.origin + '/callback',
authorizeEndpoint: 'https://idm.flegr.me/application/o/authorize/',
};
const state = { const state = {
tasks: [], tasks: [],
@ -9,9 +16,15 @@ const state = {
selectedRunId: null, selectedRunId: null,
currentView: 'dashboard', // 'dashboard' or 'task' currentView: 'dashboard', // 'dashboard' or 'task'
isEditing: false, isEditing: false,
token: localStorage.getItem('auth_token')
}; };
// DOM elements // DOM elements
const loginOverlay = document.getElementById('login-overlay');
const callbackOverlay = document.getElementById('callback-overlay');
const loginBtn = document.getElementById('login-btn');
const appEl = document.getElementById('app');
const logoutBtn = document.getElementById('logout-btn');
const taskListEl = document.getElementById('task-list'); const taskListEl = document.getElementById('task-list');
const newTaskBtn = document.getElementById('new-task-btn'); const newTaskBtn = document.getElementById('new-task-btn');
const modalContainer = document.getElementById('modal-container'); const modalContainer = document.getElementById('modal-container');
@ -42,9 +55,31 @@ const toggleCustomCronBtn = document.getElementById('toggle-custom-cron');
const customCronContainer = document.getElementById('custom-cron-container'); const customCronContainer = document.getElementById('custom-cron-container');
const presetBtns = document.querySelectorAll('.btn-preset'); const presetBtns = document.querySelectorAll('.btn-preset');
// Wrapper for fetch to include Authorization header
async function fetchWithAuth(url, options = {}) {
if (!state.token) {
showLogin();
throw new Error('Not authenticated');
}
const headers = {
...options.headers,
'Authorization': `Bearer ${state.token}`
};
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
logout();
throw new Error('Session expired');
}
return response;
}
async function fetchTasks() { async function fetchTasks() {
try { try {
const response = await fetch(`${API_URL}/tasks`); const response = await fetchWithAuth(`${API_URL}/tasks`);
const newTasks = await response.json(); const newTasks = await response.json();
// Check if we should follow the latest run (if we were already watching it) // Check if we should follow the latest run (if we were already watching it)
@ -88,7 +123,7 @@ async function fetchTasks() {
async function fetchRecentRuns() { async function fetchRecentRuns() {
try { try {
const response = await fetch(`${API_URL}/runs/recent`); const response = await fetchWithAuth(`${API_URL}/runs/recent`);
const recentRuns = await response.json(); const recentRuns = await response.json();
renderDashboard(recentRuns); renderDashboard(recentRuns);
} catch (error) { } catch (error) {
@ -230,8 +265,11 @@ function showTaskView(task) {
if (!run) { if (!run) {
viewStatusEl.textContent = 'No runs'; viewStatusEl.textContent = 'No runs';
logsOutputEl.innerHTML = ''; viewStatusEl.className = 'status-badge pending';
answerContainerEl.classList.add('hidden'); viewDateEl.textContent = '-';
logsOutputEl.innerHTML = '<div class="waiting-placeholder">No logs available. Click "Run Task" to start the agent.</div>';
answerContainerEl.classList.remove('hidden');
answerOutputEl.innerHTML = '<div class="waiting-placeholder">Waiting for the first execution...</div>';
return; return;
} }
@ -245,12 +283,12 @@ function showTaskView(task) {
minute: '2-digit' minute: '2-digit'
}); });
if (run.answer) {
answerContainerEl.classList.remove('hidden'); answerContainerEl.classList.remove('hidden');
if (run.answer) {
const rawHtml = marked.parse(run.answer); const rawHtml = marked.parse(run.answer);
answerOutputEl.innerHTML = DOMPurify.sanitize(rawHtml); answerOutputEl.innerHTML = DOMPurify.sanitize(rawHtml);
} else { } else {
answerContainerEl.classList.add('hidden'); answerOutputEl.innerHTML = '<div class="waiting-placeholder">Agent is working on the final answer...</div>';
} }
// Check if we should auto-scroll // Check if we should auto-scroll
@ -279,7 +317,7 @@ rerunBtn.addEventListener('click', async () => {
if (!state.selectedTaskId) return; if (!state.selectedTaskId) return;
try { try {
const response = await fetch(`${API_URL}/tasks/${state.selectedTaskId}/runs`, { const response = await fetchWithAuth(`${API_URL}/tasks/${state.selectedTaskId}/runs`, {
method: 'POST', method: 'POST',
}); });
const updatedTask = await response.json(); const updatedTask = await response.json();
@ -289,15 +327,15 @@ rerunBtn.addEventListener('click', async () => {
} }
selectTask(updatedTask.id); selectTask(updatedTask.id);
} catch (error) { } catch (error) {
console.error('Error re-running task:', error); console.error('Error running task:', error);
alert('Failed to re-run task.'); alert('Failed to run task.');
} }
}); });
newTaskBtn.addEventListener('click', () => { newTaskBtn.addEventListener('click', () => {
state.isEditing = false; state.isEditing = false;
modalTitle.textContent = 'New Agent Task'; modalTitle.textContent = 'New Agent Task';
submitTaskBtn.textContent = 'Execute Directive'; submitTaskBtn.textContent = 'Save Task';
goalTextarea.value = ''; goalTextarea.value = '';
cronInput.value = ''; cronInput.value = '';
updateScheduleUI(''); updateScheduleUI('');
@ -316,8 +354,8 @@ editTaskBtn.addEventListener('click', () => {
if (!task) return; if (!task) return;
state.isEditing = true; state.isEditing = true;
modalTitle.textContent = 'Edit Directive'; modalTitle.textContent = 'Edit Agent Task';
submitTaskBtn.textContent = 'Update Directive'; submitTaskBtn.textContent = 'Save Task';
goalTextarea.value = task.goal; goalTextarea.value = task.goal;
cronInput.value = task.cron || ''; cronInput.value = task.cron || '';
updateScheduleUI(task.cron || ''); updateScheduleUI(task.cron || '');
@ -383,13 +421,13 @@ newTaskForm.addEventListener('submit', async (e) => {
try { try {
let response; let response;
if (state.isEditing) { if (state.isEditing) {
response = await fetch(`${API_URL}/tasks/${state.selectedTaskId}`, { response = await fetchWithAuth(`${API_URL}/tasks/${state.selectedTaskId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ goal, cron }), body: JSON.stringify({ goal, cron }),
}); });
} else { } else {
response = await fetch(`${API_URL}/tasks`, { response = await fetchWithAuth(`${API_URL}/tasks`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ goal, cron }), body: JSON.stringify({ goal, cron }),
@ -415,11 +453,6 @@ newTaskForm.addEventListener('submit', async (e) => {
} }
}); });
// Initial load
// Initial fetch
fetchTasks();
// Auto-refresh every 3 seconds
let isPolling = false; let isPolling = false;
async function startAutoRefresh() { async function startAutoRefresh() {
setInterval(async () => { setInterval(async () => {
@ -433,4 +466,71 @@ async function startAutoRefresh() {
}, 3000); }, 3000);
} }
startAutoRefresh(); async function showLogin() {
loginOverlay.classList.remove('hidden');
appEl.classList.add('hidden');
}
async function logout() {
state.token = null;
localStorage.removeItem('auth_token');
showLogin();
}
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
if (!code) return;
window.history.replaceState({}, document.title, "/");
callbackOverlay.classList.remove('hidden');
loginOverlay.classList.add('hidden');
try {
const response = await fetch(`${API_URL}/auth/callback?code=${code}&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}`);
const data = await response.json();
if (data.access_token) {
state.token = data.access_token;
localStorage.setItem('auth_token', data.access_token);
callbackOverlay.classList.add('hidden');
appEl.classList.remove('hidden');
initializeApp();
} else {
throw new Error('No access token in response');
}
} catch (error) {
console.error('Auth callback failed:', error);
alert('Authentication failed.');
showLogin();
}
}
loginBtn.addEventListener('click', () => {
const authUrl = `${AUTH_CONFIG.authorizeEndpoint}?client_id=${AUTH_CONFIG.clientId}&response_type=code&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}&scope=openid profile email`;
window.location.href = authUrl;
});
logoutBtn.addEventListener('click', () => {
logout();
});
async function initializeApp() {
if (!state.token) {
showLogin();
return;
}
appEl.classList.remove('hidden');
loginOverlay.classList.add('hidden');
await fetchTasks();
startAutoRefresh();
}
// Check for callback on load
if (window.location.pathname === '/callback' || window.location.search.includes('code=')) {
handleCallback();
} else {
initializeApp();
}

View file

@ -434,6 +434,21 @@ body {
padding-bottom: 8px; padding-bottom: 8px;
} }
.answer-output {
min-height: 100px;
}
.waiting-placeholder {
color: var(--text-dim);
font-style: italic;
display: flex;
align-items: center;
justify-content: center;
height: 100px;
background: rgba(255, 255, 255, 0.02);
border-radius: 8px;
}
.answer-output h2 { .answer-output h2 {
font-size: 20px; font-size: 20px;
} }
@ -712,3 +727,90 @@ textarea:focus {
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--text-dim); background: var(--text-dim);
} }
.login-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
background: rgba(10, 10, 15, 0.8);
backdrop-filter: blur(20px);
}
.login-box {
text-align: center;
max-width: 400px;
width: 90%;
padding: 40px;
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
align-items: center;
}
.login-box .logo-icon {
font-size: 64px;
margin-bottom: 16px;
display: block;
}
.login-title {
font-size: 32px;
font-weight: 700;
margin-bottom: 8px;
letter-spacing: -0.02em;
}
.login-box p {
color: var(--text-dim);
margin-bottom: 32px;
}
.loader {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-left-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid var(--glass-border);
margin-top: auto;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.logout-btn {
/* Styles for logout button, based on common patterns */
background: transparent;
color: var(--text-dim);
border: none;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: color 0.2s ease;
}
.logout-btn:hover {
color: var(--primary);
}

View file

@ -22,7 +22,8 @@ impl Agent {
) -> Self { ) -> Self {
let intro = format!( let intro = format!(
"You are an autonomous agent. You have access to tools that can help "You are an autonomous agent. You have access to tools that can help
you achieve your goals. Use them wisely. Do not ask for clarification and use the you achieve your goals. Use them wisely. The user is unable to respond to you
so do not ask for clarification and use the
answer tool once you to give your final answer. current date is {}", answer tool once you to give your final answer. current date is {}",
Utc::now().to_rfc3339() Utc::now().to_rfc3339()
); );

149
src/auth.rs Normal file
View file

@ -0,0 +1,149 @@
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub exp: usize,
pub iat: usize,
pub iss: String,
pub aud: String,
}
#[derive(Debug, Deserialize)]
struct Jwk {
kty: String,
kid: String,
n: String,
e: String,
alg: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Jwks {
keys: Vec<Jwk>,
}
pub struct JwksVerifier {
issuer: String,
jwks_uri: String,
keys: Arc<RwLock<Vec<Jwk>>>,
client: Client,
}
impl JwksVerifier {
pub async fn new(issuer: String) -> Result<Self, Box<dyn std::error::Error>> {
let client = Client::new();
// Authentik OIDC discovery
let discovery_url = format!(
"{}/.well-known/openid-configuration",
issuer.trim_end_matches('/')
);
let config: serde_json::Value = client.get(&discovery_url).send().await?.json().await?;
let jwks_uri = config["jwks_uri"]
.as_str()
.ok_or("Missing jwks_uri in discovery")?
.to_string();
let verifier = Self {
issuer,
jwks_uri,
keys: Arc::new(RwLock::new(Vec::new())),
client,
};
verifier.refresh_keys().await?;
Ok(verifier)
}
pub async fn refresh_keys(&self) -> Result<(), Box<dyn std::error::Error>> {
let jwks: Jwks = self.client.get(&self.jwks_uri).send().await?.json().await?;
let mut keys = self.keys.write().await;
*keys = jwks.keys;
Ok(())
}
pub async fn verify(&self, token: &str) -> Result<Claims, Box<dyn std::error::Error>> {
let header = decode_header(token)?;
let kid = header.kid.ok_or("Missing kid in token header")?;
let keys = self.keys.read().await;
let jwk = keys
.iter()
.find(|k| k.kid == kid)
.ok_or("Key not found in JWKS")?;
let decoding_key = DecodingKey::from_rsa_components(&jwk.n, &jwk.e)?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[self.issuer.clone()]);
// Aud validation might need careful config, usually it's the client_id
validation.validate_aud = false;
let token_data = decode::<Claims>(token, &decoding_key, &validation)?;
Ok(token_data.claims)
}
}
pub struct Authenticator {
client_id: String,
client_secret: String,
token_url: String,
client: Client,
}
impl Authenticator {
pub async fn new(
issuer: String,
client_id: String,
client_secret: String,
) -> Result<Self, Box<dyn std::error::Error>> {
let client = Client::new();
let discovery_url = format!(
"{}/.well-known/openid-configuration",
issuer.trim_end_matches('/')
);
let config: serde_json::Value = client.get(&discovery_url).send().await?.json().await?;
let token_url = config["token_endpoint"]
.as_str()
.ok_or("Missing token_endpoint in discovery")?
.to_string();
Ok(Self {
client_id,
client_secret,
token_url,
client,
})
}
pub async fn exchange_code(
&self,
code: String,
redirect_uri: String,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let params = [
("grant_type", "authorization_code"),
("code", &code),
("redirect_uri", &redirect_uri),
("client_id", &self.client_id),
("client_secret", &self.client_secret),
];
let res = self
.client
.post(&self.token_url)
.form(&params)
.send()
.await?
.json()
.await?;
Ok(res)
}
}

View file

@ -1,5 +1,6 @@
mod agent; mod agent;
mod api; mod api;
mod auth;
mod entities; mod entities;
mod scheduler; mod scheduler;
mod server; mod server;

View file

@ -1,16 +1,20 @@
use axum::{ use axum::{
Json, Router, Json, RequestPartsExt, Router,
extract::{Path, State}, extract::{FromRef, FromRequestParts, Path, Query, State},
http::StatusCode, http::{StatusCode, request::Parts},
routing::{get, post}, routing::{get, post},
}; };
use axum_extra::{
TypedHeader,
headers::{Authorization, authorization::Bearer},
};
use chrono::Utc; use chrono::Utc;
use sea_orm::{ use sea_orm::{
ActiveModelTrait, Database, DatabaseConnection, EntityTrait, QueryOrder, QuerySelect, Set, ActiveModelTrait, Database, DatabaseConnection, EntityTrait, QueryOrder, QuerySelect, Set,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use tower_http::cors::CorsLayer; use tower_http::cors::{Any, CorsLayer};
use uuid::Uuid; use uuid::Uuid;
use crate::agent::Agent; use crate::agent::Agent;
@ -25,6 +29,8 @@ pub struct AppState {
pub scheduler: Arc<Scheduler>, pub scheduler: Arc<Scheduler>,
pub zen_api_key: Option<String>, pub zen_api_key: Option<String>,
pub tavily_api_key: Option<String>, pub tavily_api_key: Option<String>,
pub verifier: Arc<crate::auth::JwksVerifier>,
pub authenticator: Arc<crate::auth::Authenticator>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -84,20 +90,43 @@ pub async fn start(db_url: &str) -> Result<(), Box<dyn std::error::Error>> {
} }
} }
let authentik_issuer =
std::env::var("AUTHENTIK_ISSUER").map_err(|_| "AUTHENTIK_ISSUER not set")?;
let authentik_client_id =
std::env::var("AUTHENTIK_CLIENT_ID").map_err(|_| "AUTHENTIK_CLIENT_ID not set")?;
let authentik_client_secret =
std::env::var("AUTHENTIK_CLIENT_SECRET").map_err(|_| "AUTHENTIK_CLIENT_SECRET not set")?;
let verifier = Arc::new(crate::auth::JwksVerifier::new(authentik_issuer.clone()).await?);
let authenticator = Arc::new(
crate::auth::Authenticator::new(
authentik_issuer,
authentik_client_id,
authentik_client_secret,
)
.await?,
);
let state = Arc::new(AppState { let state = Arc::new(AppState {
db, db,
scheduler, scheduler,
zen_api_key, zen_api_key,
tavily_api_key, tavily_api_key,
verifier,
authenticator,
}); });
let cors = CorsLayer::permissive(); let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new() let app = Router::new()
.route("/tasks", post(create_task).get(list_tasks)) .route("/tasks", post(create_task).get(list_tasks))
.route("/tasks/:id", get(get_task).put(update_task)) .route("/tasks/:id", get(get_task).put(update_task))
.route("/tasks/:id/runs", post(rerun_task)) .route("/tasks/:id/runs", post(rerun_task))
.route("/runs/recent", get(get_recent_runs)) .route("/runs/recent", get(get_recent_runs))
.route("/auth/callback", get(auth_callback))
.layer(cors) .layer(cors)
.with_state(state); .with_state(state);
@ -109,6 +138,7 @@ pub async fn start(db_url: &str) -> Result<(), Box<dyn std::error::Error>> {
} }
async fn list_tasks( async fn list_tasks(
_user: AuthenticatedUser,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<TaskResponse>>, (StatusCode, String)> { ) -> Result<Json<Vec<TaskResponse>>, (StatusCode, String)> {
let tasks = Task::find() let tasks = Task::find()
@ -142,6 +172,7 @@ async fn list_tasks(
} }
async fn create_task( async fn create_task(
_user: AuthenticatedUser,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(payload): Json<CreateTaskRequest>, Json(payload): Json<CreateTaskRequest>,
) -> Result<Json<TaskResponse>, (StatusCode, String)> { ) -> Result<Json<TaskResponse>, (StatusCode, String)> {
@ -166,10 +197,11 @@ async fn create_task(
let _ = state.scheduler.remove_task_job(task_id).await; let _ = state.scheduler.remove_task_job(task_id).await;
} }
execute_agent_run(state, task_id, payload.goal).await get_task_inner(task_id, &state).await.map(Json)
} }
async fn rerun_task( async fn rerun_task(
_user: AuthenticatedUser,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<TaskResponse>, (StatusCode, String)> { ) -> Result<Json<TaskResponse>, (StatusCode, String)> {
@ -234,10 +266,11 @@ async fn execute_agent_run(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
get_task(Path(task_id), State(state)).await get_task_inner(task_id, &state).await.map(Json)
} }
async fn update_task( async fn update_task(
_user: AuthenticatedUser,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<UpdateTaskRequest>, Json(payload): Json<UpdateTaskRequest>,
@ -262,29 +295,95 @@ async fn update_task(
let _ = state.scheduler.remove_task_job(id).await; let _ = state.scheduler.remove_task_job(id).await;
} }
get_task(Path(id), State(state)).await get_task_inner(id, &state).await.map(Json)
}
pub struct AuthenticatedUser(pub crate::auth::Claims);
#[axum::async_trait]
impl<S> FromRequestParts<S> for AuthenticatedUser
where
Arc<AppState>: axum::extract::FromRef<S>,
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = Arc::<AppState>::from_ref(state);
let TypedHeader(Authorization(bearer)) = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
.await
.map_err(|_| {
(
StatusCode::UNAUTHORIZED,
"Missing or invalid Authorization header".to_string(),
)
})?;
let claims = app_state
.verifier
.verify(bearer.token())
.await
.map_err(|e| {
(
StatusCode::UNAUTHORIZED,
format!("Token verification failed: {}", e),
)
})?;
Ok(AuthenticatedUser(claims))
}
}
#[derive(Deserialize)]
pub struct AuthCallbackQuery {
pub code: String,
pub redirect_uri: String,
}
async fn auth_callback(
State(state): State<Arc<AppState>>,
Query(query): Query<AuthCallbackQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
state
.authenticator
.exchange_code(query.code, query.redirect_uri)
.await
.map(Json)
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Token exchange failed: {}", e),
)
})
} }
async fn get_task( async fn get_task(
_user: AuthenticatedUser,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> Result<Json<TaskResponse>, (StatusCode, String)> { ) -> Result<Json<TaskResponse>, (StatusCode, String)> {
get_task_inner(id, &state).await.map(Json)
}
async fn get_task_inner(id: Uuid, state: &AppState) -> Result<TaskResponse, (StatusCode, String)> {
let results = Task::find_by_id(id) let results = Task::find_by_id(id)
.find_with_related(TaskRun) .find_with_related(TaskRun)
.all(&state.db) .all(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let (task, runs) = results let (t, runs) = results
.into_iter() .into_iter()
.next() .next()
.ok_or((StatusCode::NOT_FOUND, "Task not found".to_string()))?; .ok_or((StatusCode::NOT_FOUND, "Task not found".to_string()))?;
Ok(Json(TaskResponse { Ok(TaskResponse {
id: task.id, id: t.id,
goal: task.goal, goal: t.goal,
cron: task.cron, cron: t.cron,
created_at: task.created_at, created_at: t.created_at,
runs: runs runs: runs
.into_iter() .into_iter()
.map(|r| TaskRunResponse { .map(|r| TaskRunResponse {
@ -295,10 +394,11 @@ async fn get_task(
created_at: r.created_at, created_at: r.created_at,
}) })
.collect(), .collect(),
})) })
} }
async fn get_recent_runs( async fn get_recent_runs(
_user: AuthenticatedUser,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<RecentRunResponse>>, (StatusCode, String)> { ) -> Result<Json<Vec<RecentRunResponse>>, (StatusCode, String)> {
let results = TaskRun::find() let results = TaskRun::find()