This commit is contained in:
jaberjaber23
2026-02-27 20:47:14 +03:00
parent 51c1e154d9
commit 0bb08f4ae1
17 changed files with 501 additions and 82 deletions
Generated
+36 -15
View File
@@ -1071,6 +1071,17 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "cron"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740"
dependencies = [
"chrono",
"once_cell",
"winnow 0.6.26",
]
[[package]]
name = "crossbeam"
version = "0.8.4"
@@ -3650,7 +3661,7 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.1.0"
version = "0.1.6"
dependencies = [
"async-trait",
"axum",
@@ -3686,7 +3697,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.1.0"
version = "0.1.6"
dependencies = [
"async-trait",
"axum",
@@ -3713,7 +3724,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.1.0"
version = "0.1.6"
dependencies = [
"clap",
"clap_complete",
@@ -3740,7 +3751,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.1.0"
version = "0.1.6"
dependencies = [
"axum",
"open",
@@ -3766,7 +3777,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.1.0"
version = "0.1.6"
dependencies = [
"aes-gcm",
"argon2",
@@ -3794,7 +3805,7 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.1.0"
version = "0.1.6"
dependencies = [
"chrono",
"dashmap",
@@ -3811,10 +3822,11 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.1.0"
version = "0.1.6"
dependencies = [
"async-trait",
"chrono",
"cron",
"crossbeam",
"dashmap",
"dirs 6.0.0",
@@ -3846,7 +3858,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.1.0"
version = "0.1.6"
dependencies = [
"async-trait",
"chrono",
@@ -3865,7 +3877,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.1.0"
version = "0.1.6"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -3884,7 +3896,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.1.0"
version = "0.1.6"
dependencies = [
"anyhow",
"async-trait",
@@ -3915,7 +3927,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.1.0"
version = "0.1.6"
dependencies = [
"chrono",
"hex",
@@ -3937,7 +3949,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.1.0"
version = "0.1.6"
dependencies = [
"async-trait",
"chrono",
@@ -3956,7 +3968,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.1.0"
version = "0.1.6"
dependencies = [
"async-trait",
"chrono",
@@ -4638,7 +4650,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -8250,6 +8262,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "0.6.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "0.7.14"
@@ -8491,7 +8512,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.1.0"
version = "0.1.6"
[[package]]
name = "yoke"
+37
View File
@@ -6930,6 +6930,36 @@ pub async fn patch_agent_config(
}
};
// Input length limits
const MAX_NAME_LEN: usize = 256;
const MAX_DESC_LEN: usize = 4096;
const MAX_PROMPT_LEN: usize = 65_536;
if let Some(ref name) = req.name {
if name.len() > MAX_NAME_LEN {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({"error": format!("Name exceeds max length ({MAX_NAME_LEN} chars)")})),
);
}
}
if let Some(ref desc) = req.description {
if desc.len() > MAX_DESC_LEN {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({"error": format!("Description exceeds max length ({MAX_DESC_LEN} chars)")})),
);
}
}
if let Some(ref prompt) = req.system_prompt {
if prompt.len() > MAX_PROMPT_LEN {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({"error": format!("System prompt exceeds max length ({MAX_PROMPT_LEN} chars)")})),
);
}
}
// Validate color format if provided
if let Some(ref color) = req.color {
if !color.is_empty() && !color.starts_with('#') {
@@ -7069,6 +7099,13 @@ pub async fn clone_agent(
}
};
if req.new_name.len() > 256 {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({"error": "Name exceeds max length (256 chars)"})),
);
}
if req.new_name.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
+2 -2
View File
@@ -1523,7 +1523,7 @@
<th>Agent</th>
<th>Status</th>
<th>Last Run</th>
<th>Runs</th>
<th>Next Run</th>
<th>Actions</th>
</tr>
</thead>
@@ -1543,7 +1543,7 @@
<span class="badge" :class="job.enabled ? 'badge-success' : 'badge-dim'" x-text="job.enabled ? 'Active' : 'Paused'"></span>
</td>
<td class="text-xs" :title="formatTime(job.last_run)" x-text="relativeTime(job.last_run)"></td>
<td class="text-xs" x-text="job.run_count || 0"></td>
<td class="text-xs" :title="formatTime(job.next_run)" x-text="relativeTime(job.next_run)"></td>
<td>
<div class="flex gap-1">
<button class="btn btn-primary btn-sm" @click="runNow(job)" :disabled="runningJobId === job.id">
@@ -62,8 +62,29 @@ function schedulerPage() {
},
async loadJobs() {
var data = await OpenFangAPI.get('/api/schedules');
this.jobs = data.schedules || [];
var data = await OpenFangAPI.get('/api/cron/jobs');
var raw = data.jobs || [];
// Normalize cron API response to flat fields the UI expects
this.jobs = raw.map(function(j) {
var cron = '';
if (j.schedule) {
if (j.schedule.kind === 'cron') cron = j.schedule.expr || '';
else if (j.schedule.kind === 'every') cron = 'every ' + j.schedule.every_secs + 's';
else if (j.schedule.kind === 'at') cron = 'at ' + (j.schedule.at || '');
}
return {
id: j.id,
name: j.name,
cron: cron,
agent_id: j.agent_id,
message: j.action ? j.action.message || '' : '',
enabled: j.enabled,
last_run: j.last_run,
next_run: j.next_run,
delivery: j.delivery ? j.delivery.kind || '' : '',
created_at: j.created_at
};
});
},
async loadTriggers() {
@@ -82,25 +103,20 @@ function schedulerPage() {
async loadHistory() {
this.historyLoading = true;
try {
// Build history from jobs with run data + recent audit entries
var historyItems = [];
// Add job run info from schedule data
var jobs = this.jobs || [];
for (var i = 0; i < jobs.length; i++) {
var job = jobs[i];
if (job.last_run) {
historyItems.push({
timestamp: job.last_run,
name: job.name || job.description || '(unnamed)',
name: job.name || '(unnamed)',
type: 'schedule',
status: 'completed',
run_count: job.run_count || 0
run_count: 0
});
}
}
// Also load trigger fire counts
var triggers = this.triggers || [];
for (var j = 0; j < triggers.length; j++) {
var t = triggers[j];
@@ -114,12 +130,9 @@ function schedulerPage() {
});
}
}
// Sort by timestamp descending
historyItems.sort(function(a, b) {
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
});
this.history = historyItems;
} catch(e) {
this.history = [];
@@ -141,13 +154,15 @@ function schedulerPage() {
this.creating = true;
try {
var jobName = this.newJob.name;
await OpenFangAPI.post('/api/schedules', {
name: this.newJob.name,
cron: this.newJob.cron,
var body = {
agent_id: this.newJob.agent_id,
message: this.newJob.message,
name: this.newJob.name,
schedule: { kind: 'cron', expr: this.newJob.cron },
action: { kind: 'agent_turn', message: this.newJob.message || 'Scheduled task: ' + this.newJob.name },
delivery: { kind: 'last_channel' },
enabled: this.newJob.enabled
});
};
await OpenFangAPI.post('/api/cron/jobs', body);
this.showCreateForm = false;
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true };
OpenFangToast.success('Schedule "' + jobName + '" created');
@@ -161,7 +176,7 @@ function schedulerPage() {
async toggleJob(job) {
try {
var newState = !job.enabled;
await OpenFangAPI.put('/api/schedules/' + job.id, { enabled: newState });
await OpenFangAPI.put('/api/cron/jobs/' + job.id + '/enable', { enabled: newState });
job.enabled = newState;
OpenFangToast.success('Schedule ' + (newState ? 'enabled' : 'paused'));
} catch(e) {
@@ -174,7 +189,7 @@ function schedulerPage() {
var jobName = job.name || job.id;
OpenFangToast.confirm('Delete Schedule', 'Delete "' + jobName + '"? This cannot be undone.', async function() {
try {
await OpenFangAPI.del('/api/schedules/' + job.id);
await OpenFangAPI.del('/api/cron/jobs/' + job.id);
self.jobs = self.jobs.filter(function(j) { return j.id !== job.id; });
OpenFangToast.success('Schedule "' + jobName + '" deleted');
} catch(e) {
@@ -189,19 +204,17 @@ function schedulerPage() {
var result = await OpenFangAPI.post('/api/schedules/' + job.id + '/run', {});
if (result.status === 'completed') {
OpenFangToast.success('Schedule "' + (job.name || 'job') + '" executed successfully');
// Update the job's last_run locally
job.last_run = new Date().toISOString();
job.run_count = (job.run_count || 0) + 1;
} else {
OpenFangToast.error('Schedule run failed: ' + (result.error || 'Unknown error'));
}
} catch(e) {
OpenFangToast.error('Failed to run schedule: ' + (e.message || e));
OpenFangToast.error('Run Now is not yet available for cron jobs');
}
this.runningJobId = '';
},
// ── Trigger helpers (reused from workflows page) ──
// ── Trigger helpers ──
triggerType(pattern) {
if (!pattern) return 'unknown';
@@ -259,13 +272,16 @@ function schedulerPage() {
for (var i = 0; i < agents.length; i++) {
if (agents[i].id === agentId) return agents[i].name;
}
// Truncate UUID
if (agentId.length > 12) return agentId.substring(0, 8) + '...';
return agentId;
},
describeCron(expr) {
if (!expr) return '';
// Handle non-cron schedule descriptions
if (expr.indexOf('every ') === 0) return expr;
if (expr.indexOf('at ') === 0) return 'One-time: ' + expr.substring(3);
var map = {
'* * * * *': 'Every minute',
'*/2 * * * *': 'Every 2 minutes',
@@ -291,7 +307,6 @@ function schedulerPage() {
};
if (map[expr]) return map[expr];
// Try to parse common patterns
var parts = expr.split(' ');
if (parts.length !== 5) return expr;
@@ -301,22 +316,26 @@ function schedulerPage() {
var mon = parts[3];
var dow = parts[4];
// "*/N * * * *" patterns
if (min.indexOf('*/') === 0 && hour === '*' && dom === '*' && mon === '*' && dow === '*') {
return 'Every ' + min.substring(2) + ' minutes';
}
// "0 */N * * *" patterns
if (min === '0' && hour.indexOf('*/') === 0 && dom === '*' && mon === '*' && dow === '*') {
return 'Every ' + hour.substring(2) + ' hours';
}
// "M H * * *" — daily at specific time
if (dom === '*' && mon === '*' && dow === '*' && min.match(/^\d+$/) && hour.match(/^\d+$/)) {
var dowNames = { '0': 'Sun', '1': 'Mon', '2': 'Tue', '3': 'Wed', '4': 'Thu', '5': 'Fri', '6': 'Sat', '7': 'Sun',
'1-5': 'Weekdays', '0,6': 'Weekends', '6,0': 'Weekends' };
if (dom === '*' && mon === '*' && min.match(/^\d+$/) && hour.match(/^\d+$/)) {
var h = parseInt(hour, 10);
var m = parseInt(min, 10);
var ampm = h >= 12 ? 'PM' : 'AM';
var h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);
var mStr = m < 10 ? '0' + m : '' + m;
return 'Daily at ' + h12 + ':' + mStr + ' ' + ampm;
var timeStr = h12 + ':' + mStr + ' ' + ampm;
if (dow === '*') return 'Daily at ' + timeStr;
var dowLabel = dowNames[dow] || ('DoW ' + dow);
return dowLabel + ' at ' + timeStr;
}
return expr;
@@ -340,7 +359,14 @@ function schedulerPage() {
try {
var diff = Date.now() - new Date(ts).getTime();
if (isNaN(diff)) return 'never';
if (diff < 0) return 'just now';
if (diff < 0) {
// Future time
var absDiff = Math.abs(diff);
if (absDiff < 60000) return 'in <1m';
if (absDiff < 3600000) return 'in ' + Math.floor(absDiff / 60000) + 'm';
if (absDiff < 86400000) return 'in ' + Math.floor(absDiff / 3600000) + 'h';
return 'in ' + Math.floor(absDiff / 86400000) + 'd';
}
if (diff < 60000) return 'just now';
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
+50 -11
View File
@@ -268,38 +268,48 @@ fn parse_mastodon_notification(
fn strip_html_tags(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut in_tag = false;
let mut tag_buf = String::new();
for ch in html.chars() {
match ch {
'<' => {
in_tag = true;
// Check if this is a <br> or </p> — insert newline
if html[result.len()..].starts_with("<br")
|| html[result.len()..].starts_with("</p")
tag_buf.clear();
}
'>' if in_tag => {
in_tag = false;
// Insert newline for block-level closing tags
let tag_lower = tag_buf.to_lowercase();
if tag_lower.starts_with("br")
|| tag_lower.starts_with("/p")
|| tag_lower.starts_with("/div")
|| tag_lower.starts_with("/li")
{
result.push('\n');
}
tag_buf.clear();
}
'>' => {
in_tag = false;
_ if in_tag => {
tag_buf.push(ch);
}
_ if !in_tag => {
_ => {
result.push(ch);
}
_ => {}
}
}
// Decode common HTML entities
result
// Decode HTML entities
let decoded = result
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.trim()
.to_string()
.replace("&#x27;", "'")
.replace("&nbsp;", " ");
decoded.trim().to_string()
}
#[async_trait]
@@ -577,6 +587,35 @@ mod tests {
assert_eq!(strip_html_tags("plain text"), "plain text");
}
#[test]
fn test_strip_html_tags_emoji() {
assert_eq!(
strip_html_tags("<p>Hello 🦀🔥 world</p>"),
"Hello 🦀🔥 world"
);
}
#[test]
fn test_strip_html_tags_cjk() {
assert_eq!(
strip_html_tags("<p>你好 <strong>世界</strong></p>"),
"你好 世界"
);
}
#[test]
fn test_strip_html_tags_numeric_entities() {
assert_eq!(strip_html_tags("&#39;hello&#39;"), "'hello'");
}
#[test]
fn test_strip_html_tags_div_newline() {
assert_eq!(
strip_html_tags("<div>one</div><div>two</div>").trim(),
"one\ntwo"
);
}
#[test]
fn test_parse_mastodon_notification_mention() {
let notif = serde_json::json!({
+49
View File
@@ -26,6 +26,32 @@ pub fn default_client_ids() -> HashMap<&'static str, &'static str> {
m
}
/// Resolve OAuth client IDs with config overrides applied on top of defaults.
pub fn resolve_client_ids(
config: &openfang_types::config::OAuthConfig,
) -> HashMap<String, String> {
let defaults = default_client_ids();
let mut resolved: HashMap<String, String> = defaults
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
if let Some(ref id) = config.google_client_id {
resolved.insert("google".into(), id.clone());
}
if let Some(ref id) = config.github_client_id {
resolved.insert("github".into(), id.clone());
}
if let Some(ref id) = config.microsoft_client_id {
resolved.insert("microsoft".into(), id.clone());
}
if let Some(ref id) = config.slack_client_id {
resolved.insert("slack".into(), id.clone());
}
resolved
}
/// OAuth2 token response (raw from provider, for deserialization).
#[derive(Debug, Serialize, Deserialize)]
pub struct OAuthTokens {
@@ -333,4 +359,27 @@ mod tests {
assert!(ids.contains_key("microsoft"));
assert!(ids.contains_key("slack"));
}
#[test]
fn resolve_client_ids_uses_defaults() {
let config = openfang_types::config::OAuthConfig::default();
let ids = resolve_client_ids(&config);
assert_eq!(ids["google"], "openfang-google-client-id");
assert_eq!(ids["github"], "openfang-github-client-id");
}
#[test]
fn resolve_client_ids_applies_overrides() {
let config = openfang_types::config::OAuthConfig {
google_client_id: Some("my-real-google-id".into()),
github_client_id: None,
microsoft_client_id: Some("my-msft-id".into()),
slack_client_id: None,
};
let ids = resolve_client_ids(&config);
assert_eq!(ids["google"], "my-real-google-id");
assert_eq!(ids["github"], "openfang-github-client-id"); // default
assert_eq!(ids["microsoft"], "my-msft-id");
assert_eq!(ids["slack"], "openfang-slack-client-id"); // default
}
}
+69 -4
View File
@@ -31,7 +31,6 @@ const SALT_LEN: usize = 16;
/// Nonce length for AES-256-GCM.
const NONCE_LEN: usize = 12;
/// Magic bytes for vault file format versioning.
#[allow(dead_code)]
const VAULT_MAGIC: &[u8; 4] = b"OFV1";
/// On-disk vault format (encrypted).
@@ -312,14 +311,34 @@ impl CredentialVault {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&self.path, content)?;
// Prepend OFV1 magic bytes for format detection
let mut output = Vec::with_capacity(VAULT_MAGIC.len() + content.len());
output.extend_from_slice(VAULT_MAGIC);
output.extend_from_slice(content.as_bytes());
std::fs::write(&self.path, output)?;
Ok(())
}
/// Load and decrypt vault from disk.
fn load(&mut self, master_key: &[u8; 32]) -> ExtensionResult<()> {
let content = std::fs::read_to_string(&self.path)?;
let vault_file: VaultFile = serde_json::from_str(&content)
let raw = std::fs::read(&self.path)?;
// Strip OFV1 magic header if present; legacy JSON files start with '{'
let content = if raw.starts_with(VAULT_MAGIC) {
std::str::from_utf8(&raw[VAULT_MAGIC.len()..])
.map_err(|e| ExtensionError::Vault(format!("UTF-8 decode failed: {e}")))?
} else if raw.first() == Some(&b'{') {
// Legacy JSON vault (no magic header)
std::str::from_utf8(&raw)
.map_err(|e| ExtensionError::Vault(format!("UTF-8 decode failed: {e}")))?
} else {
return Err(ExtensionError::Vault(
"Unrecognized vault file format".to_string(),
));
};
let vault_file: VaultFile = serde_json::from_str(content)
.map_err(|e| ExtensionError::Vault(format!("Vault file parse failed: {e}")))?;
if vault_file.version != 1 {
@@ -590,4 +609,50 @@ mod tests {
let k2 = derive_key(&master, &salt).unwrap();
assert_eq!(k1.as_ref(), k2.as_ref());
}
#[test]
fn vault_file_has_magic_header() {
let (_dir, mut vault) = test_vault();
let key = random_key();
vault.init_with_key(key).unwrap();
let raw = std::fs::read(&vault.path).unwrap();
assert_eq!(&raw[..4], b"OFV1");
}
#[test]
fn vault_legacy_json_compat() {
let (dir, mut vault) = test_vault();
let key = random_key();
vault.init_with_key(key.clone()).unwrap();
vault
.set("KEY".to_string(), Zeroizing::new("val".to_string()))
.unwrap();
// Strip the OFV1 magic header to simulate a legacy vault file
let raw = std::fs::read(&vault.path).unwrap();
assert_eq!(&raw[..4], b"OFV1");
std::fs::write(&vault.path, &raw[4..]).unwrap();
// Should still load (legacy compat)
let mut vault2 = CredentialVault::new(dir.path().join("vault.enc"));
vault2.unlock_with_key(key).unwrap();
assert_eq!(vault2.get("KEY").unwrap().as_str(), "val");
}
#[test]
fn vault_rejects_bad_magic() {
let (dir, mut vault) = test_vault();
let key = random_key();
vault.init_with_key(key.clone()).unwrap();
// Overwrite with unrecognized binary data
std::fs::write(&vault.path, b"BAAD not json").unwrap();
let mut vault2 = CredentialVault::new(dir.path().join("vault.enc"));
let result = vault2.unlock_with_key(key);
assert!(result.is_err());
let msg = format!("{:?}", result.unwrap_err());
assert!(msg.contains("Unrecognized vault file format"));
}
}
+1
View File
@@ -32,6 +32,7 @@ subtle = { workspace = true }
rand = { workspace = true }
hex = { workspace = true }
reqwest = { workspace = true }
cron = "0.15"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+62 -12
View File
@@ -293,16 +293,37 @@ impl CronScheduler {
///
/// - `At { at }` — returns `at` directly.
/// - `Every { every_secs }` — returns `now + every_secs`.
/// - `Cron { .. }` — returns 60 seconds from now (placeholder until a cron
/// expression parser is added).
/// - `Cron { expr, tz }` — parses the cron expression and computes the next
/// matching time. Supports standard 5-field (`min hour dom month dow`) and
/// 6-field (`sec min hour dom month dow`) formats by converting to the
/// 7-field format required by the `cron` crate.
pub fn compute_next_run(schedule: &CronSchedule) -> chrono::DateTime<Utc> {
match schedule {
CronSchedule::At { at } => *at,
CronSchedule::Every { every_secs } => Utc::now() + Duration::seconds(*every_secs as i64),
CronSchedule::Cron { .. } => {
// Placeholder: real cron parsing will be added when the `cron`
// crate is brought in. For now, fire 60 seconds from now.
Utc::now() + Duration::seconds(60)
CronSchedule::Cron { expr, tz: _ } => {
// Convert standard 5/6-field cron to 7-field for the `cron` crate.
// Standard 5-field: min hour dom month dow
// 6-field: sec min hour dom month dow
// cron crate: sec min hour dom month dow year
let trimmed = expr.trim();
let fields: Vec<&str> = trimmed.split_whitespace().collect();
let seven_field = match fields.len() {
5 => format!("0 {trimmed} *"),
6 => format!("{trimmed} *"),
_ => expr.clone(),
};
match seven_field.parse::<cron::Schedule>() {
Ok(sched) => sched
.upcoming(Utc)
.next()
.unwrap_or_else(|| Utc::now() + Duration::hours(1)),
Err(e) => {
warn!("Failed to parse cron expression '{}': {}", expr, e);
Utc::now() + Duration::hours(1)
}
}
}
}
}
@@ -655,18 +676,47 @@ mod tests {
}
#[test]
fn test_compute_next_run_cron_placeholder() {
let before = Utc::now();
fn test_compute_next_run_cron_daily() {
let now = Utc::now();
let schedule = CronSchedule::Cron {
expr: "0 9 * * *".into(),
tz: None,
};
let next = compute_next_run(&schedule);
let after = Utc::now();
// Placeholder returns ~60s from now
assert!(next >= before + Duration::seconds(59));
assert!(next <= after + Duration::seconds(61));
// Should be within the next 24 hours (next 09:00 UTC)
assert!(next > now);
assert!(next <= now + Duration::hours(24));
assert_eq!(next.format("%M").to_string(), "00");
assert_eq!(next.format("%H").to_string(), "09");
}
#[test]
fn test_compute_next_run_cron_with_dow() {
let now = Utc::now();
let schedule = CronSchedule::Cron {
expr: "30 14 * * 1-5".into(),
tz: None,
};
let next = compute_next_run(&schedule);
// Should be within the next 7 days and at 14:30
assert!(next > now);
assert!(next <= now + Duration::days(7));
assert_eq!(next.format("%H:%M").to_string(), "14:30");
}
#[test]
fn test_compute_next_run_cron_invalid_expr() {
let now = Utc::now();
let schedule = CronSchedule::Cron {
expr: "not a cron".into(),
tz: None,
};
let next = compute_next_run(&schedule);
// Invalid expression falls back to 1 hour from now
assert!(next > now + Duration::minutes(59));
assert!(next <= now + Duration::minutes(61));
}
// -- error message truncation in record_failure -------------------------
+8 -1
View File
@@ -975,6 +975,9 @@ impl OpenFangKernel {
if !dm.model.is_empty() {
manifest.model.model = dm.model.clone();
}
if dm.base_url.is_some() {
manifest.model.base_url = dm.base_url.clone();
}
}
// Create workspace directory for the agent
@@ -3499,7 +3502,11 @@ impl OpenFangKernel {
let driver_config = DriverConfig {
provider: agent_provider.clone(),
api_key: std::env::var(&api_key_env).ok(),
base_url: manifest.model.base_url.clone(),
base_url: manifest
.model
.base_url
.clone()
.or_else(|| self.config.default_model.base_url.clone()),
};
drivers::create_driver(&driver_config).map_err(|e| {
+3
View File
@@ -382,6 +382,9 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
if model.contains("glm") {
return (1.50, 5.00);
}
if model.contains("codegeex") {
return (0.10, 0.10);
}
// ── Moonshot / Kimi ─────────────────────────────────────────
if model.contains("moonshot") || model.contains("kimi") {
+3 -1
View File
@@ -88,7 +88,9 @@ impl AgentScheduler {
// Reset the window if an hour has passed
tracker.reset_if_expired();
if tracker.total_tokens > quota.max_llm_tokens_per_hour {
if quota.max_llm_tokens_per_hour > 0
&& tracker.total_tokens > quota.max_llm_tokens_per_hour
{
return Err(OpenFangError::QuotaExceeded(format!(
"Token limit exceeded: {} / {}",
tracker.total_tokens, quota.max_llm_tokens_per_hour
+9 -2
View File
@@ -17,7 +17,7 @@ use openfang_types::model_catalog::{
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL,
OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, XAI_BASE_URL,
ZHIPU_BASE_URL,
ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
};
use std::sync::Arc;
@@ -152,6 +152,11 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
api_key_env: "ZHIPU_API_KEY",
key_required: true,
}),
"zhipu_coding" | "codegeex" => Some(ProviderDefaults {
base_url: ZHIPU_CODING_BASE_URL,
api_key_env: "ZHIPU_API_KEY",
key_required: true,
}),
"qianfan" | "baidu" => Some(ProviderDefaults {
base_url: QIANFAN_BASE_URL,
api_key_env: "QIANFAN_API_KEY",
@@ -317,6 +322,7 @@ pub fn known_providers() -> &'static [&'static str] {
"qwen",
"minimax",
"zhipu",
"zhipu_coding",
"qianfan",
]
}
@@ -409,8 +415,9 @@ mod tests {
assert!(providers.contains(&"qwen"));
assert!(providers.contains(&"minimax"));
assert!(providers.contains(&"zhipu"));
assert!(providers.contains(&"zhipu_coding"));
assert!(providers.contains(&"qianfan"));
assert_eq!(providers.len(), 26);
assert_eq!(providers.len(), 27);
}
#[test]
+31 -2
View File
@@ -10,7 +10,7 @@ use openfang_types::model_catalog::{
LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL,
OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, XAI_BASE_URL,
ZHIPU_BASE_URL,
ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
};
use std::collections::HashMap;
@@ -435,6 +435,15 @@ fn builtin_providers() -> Vec<ProviderInfo> {
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "zhipu_coding".into(),
display_name: "Zhipu Coding (CodeGeeX)".into(),
api_key_env: "ZHIPU_API_KEY".into(),
base_url: ZHIPU_CODING_BASE_URL.into(),
key_required: true,
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "moonshot".into(),
display_name: "Moonshot (Kimi)".into(),
@@ -518,6 +527,7 @@ fn builtin_aliases() -> HashMap<String, String> {
("ernie", "ernie-4.5-8k"),
("kimi", "moonshot-v1-128k"),
("minimax", "minimax-text-01"),
("codegeex", "codegeex-4"),
];
pairs
.into_iter()
@@ -2350,6 +2360,23 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// Zhipu Coding / CodeGeeX (1)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "codegeex-4".into(),
display_name: "CodeGeeX 4".into(),
provider: "zhipu_coding".into(),
tier: ModelTier::Smart,
context_window: 131_072,
max_output_tokens: 8_192,
input_cost_per_m: 0.10,
output_cost_per_m: 0.10,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec!["codegeex".into()],
},
// ══════════════════════════════════════════════════════════════
// Moonshot / Kimi (3)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
@@ -2570,7 +2597,7 @@ mod tests {
#[test]
fn test_catalog_has_providers() {
let catalog = ModelCatalog::new();
assert_eq!(catalog.list_providers().len(), 27);
assert_eq!(catalog.list_providers().len(), 28);
}
#[test]
@@ -2790,6 +2817,7 @@ mod tests {
assert!(catalog.get_provider("qwen").is_some());
assert!(catalog.get_provider("minimax").is_some());
assert!(catalog.get_provider("zhipu").is_some());
assert!(catalog.get_provider("zhipu_coding").is_some());
assert!(catalog.get_provider("moonshot").is_some());
assert!(catalog.get_provider("qianfan").is_some());
assert!(catalog.get_provider("bedrock").is_some());
@@ -2800,6 +2828,7 @@ mod tests {
let catalog = ModelCatalog::new();
assert!(catalog.find_model("kimi").is_some());
assert!(catalog.find_model("glm").is_some());
assert!(catalog.find_model("codegeex").is_some());
assert!(catalog.find_model("ernie").is_some());
assert!(catalog.find_model("minimax").is_some());
}
+58 -1
View File
@@ -140,15 +140,28 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
}
let host = extract_host(url);
let hostname = host.split(':').next().unwrap_or(&host);
// For IPv6 bracket notation like [::1]:80, extract [::1] as hostname
let hostname = if host.starts_with('[') {
host.find(']')
.map(|i| &host[..=i])
.unwrap_or(&host)
} else {
host.split(':').next().unwrap_or(&host)
};
// Hostname-based blocklist (catches metadata endpoints)
let blocked = [
"localhost",
"ip6-localhost",
"metadata.google.internal",
"metadata.aws.internal",
"instance-data",
"169.254.169.254",
"100.100.100.200", // Alibaba Cloud IMDS
"192.0.0.192", // Azure IMDS alternative
"0.0.0.0",
"::1",
"[::1]",
];
if blocked.contains(&hostname) {
return Err(format!("SSRF blocked: {hostname} is a restricted hostname"));
@@ -192,6 +205,19 @@ fn is_private_ip(ip: &IpAddr) -> bool {
fn extract_host(url: &str) -> String {
if let Some(after_scheme) = url.split("://").nth(1) {
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
// Handle IPv6 bracket notation: [::1]:8080
if host_port.starts_with('[') {
// Extract [addr]:port or [addr]
if let Some(bracket_end) = host_port.find(']') {
let ipv6_host = &host_port[..=bracket_end]; // includes brackets
let after_bracket = &host_port[bracket_end + 1..];
if let Some(port) = after_bracket.strip_prefix(':') {
return format!("{ipv6_host}:{port}");
}
let default_port = if url.starts_with("https") { 443 } else { 80 };
return format!("{ipv6_host}:{default_port}");
}
}
if host_port.contains(':') {
host_port.to_string()
} else if url.starts_with("https") {
@@ -245,4 +271,35 @@ mod tests {
assert!(check_ssrf("ftp://internal.corp/data").is_err());
assert!(check_ssrf("gopher://evil.com").is_err());
}
#[test]
fn test_ssrf_blocks_cloud_metadata() {
// Alibaba Cloud IMDS
assert!(check_ssrf("http://100.100.100.200/latest/meta-data/").is_err());
// Azure IMDS alternative
assert!(check_ssrf("http://192.0.0.192/metadata/instance").is_err());
}
#[test]
fn test_ssrf_blocks_zero_ip() {
assert!(check_ssrf("http://0.0.0.0/").is_err());
}
#[test]
fn test_ssrf_blocks_ipv6_localhost() {
assert!(check_ssrf("http://[::1]/admin").is_err());
assert!(check_ssrf("http://[::1]:8080/api").is_err());
}
#[test]
fn test_extract_host_ipv6() {
let h = extract_host("http://[::1]:8080/path");
assert_eq!(h, "[::1]:8080");
let h2 = extract_host("https://[::1]/path");
assert_eq!(h2, "[::1]:443");
let h3 = extract_host("http://[::1]/path");
assert_eq!(h3, "[::1]:80");
}
}
+25
View File
@@ -1043,6 +1043,30 @@ pub struct KernelConfig {
/// e.g. `ollama = "http://192.168.1.100:11434/v1"`
#[serde(default)]
pub provider_urls: HashMap<String, String>,
/// OAuth client ID overrides for PKCE flows.
#[serde(default)]
pub oauth: OAuthConfig,
}
/// OAuth client ID overrides for PKCE flows.
///
/// Configure in config.toml:
/// ```toml
/// [oauth]
/// google_client_id = "your-google-client-id"
/// github_client_id = "your-github-client-id"
/// ```
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct OAuthConfig {
/// Google OAuth2 client ID for PKCE flow.
pub google_client_id: Option<String>,
/// GitHub OAuth client ID for PKCE flow.
pub github_client_id: Option<String>,
/// Microsoft (Entra ID) OAuth client ID.
pub microsoft_client_id: Option<String>,
/// Slack OAuth client ID.
pub slack_client_id: Option<String>,
}
/// Global spending budget configuration.
@@ -1188,6 +1212,7 @@ impl Default for KernelConfig {
thinking: None,
budget: BudgetConfig::default(),
provider_urls: HashMap::new(),
oauth: OAuthConfig::default(),
}
}
}
@@ -36,6 +36,7 @@ pub const GITHUB_COPILOT_BASE_URL: &str = "https://api.githubcopilot.com";
pub const QWEN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
pub const MINIMAX_BASE_URL: &str = "https://api.minimax.chat/v1";
pub const ZHIPU_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
pub const ZHIPU_CODING_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.cn/v1";
pub const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";