Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

101 changes: 101 additions & 0 deletions crates/openfang-api/src/channel_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,61 @@ pub struct KernelBridgeAdapter {
started_at: Instant,
}

fn download_token_secret(config: &openfang_types::config::KernelConfig) -> String {
let api_key = config.api_key.trim();
if !api_key.is_empty() {
api_key.to_string()
} else if config.auth.enabled {
config.auth.password_hash.clone()
} else {
String::new()
}
}

fn download_base_url(api_listen: &str) -> String {
if let Ok(url) = std::env::var("OPENFANG_URL") {
let url = url.trim_end_matches('/').to_string();
if !url.is_empty() {
return url;
}
}
let listen = api_listen
.replacen("0.0.0.0", "127.0.0.1", 1)
.replacen("[::]", "127.0.0.1", 1);
let url = format!("http://{listen}");
// The 127.0.0.1 fallback works for the CLI channel but is useless for
// remote-recipient channels (Telegram, web). Warn once when we fall back
// so the operator knows to set OPENFANG_URL before enabling those
// channels. Using `warn!` (not error!) because the CLI channel works
// fine with this default; the warning is for remote-channel readiness.
tracing::warn!(
url = %url,
"OPENFANG_URL not set; remote channels (Telegram, web) will receive \
download links pointing at the daemon's loopback address. Set \
OPENFANG_URL to your public URL before enabling them."
);
url
}

fn encode_path(path: &str) -> String {
path.split('/')
.map(percent_encode)
.collect::<Vec<_>>()
.join("/")
}

fn percent_encode(value: &str) -> String {
let mut out = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}

#[async_trait]
impl ChannelBridgeHandle for KernelBridgeAdapter {
async fn send_message(&self, agent_id: AgentId, message: &str) -> Result<String, String> {
Expand Down Expand Up @@ -297,6 +352,52 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
msg
}

async fn download_file_url(&self, agent_id: AgentId, rel_path: &str) -> Result<String, String> {
let entry = self
.kernel
.registry
.get(agent_id)
.ok_or_else(|| "Agent not found".to_string())?;
let workspace = entry
.manifest
.workspace
.as_ref()
.ok_or_else(|| "Agent has no workspace".to_string())?;
let download = openfang_runtime::workspace::read_file_for_download(
workspace,
entry.manifest.state_dir.as_deref(),
rel_path,
)
.map_err(|e| match e {
openfang_runtime::workspace::WorkspaceFileError::NotFound => {
"File not found in workspace.".to_string()
}
openfang_runtime::workspace::WorkspaceFileError::Forbidden => {
"Path is outside the workspace.".to_string()
}
other => other.to_string(),
})?;

let secret = download_token_secret(&self.kernel.config);
if secret.is_empty() {
return Err("Download links require an API key or dashboard auth secret.".to_string());
}
let token = crate::session_auth::create_download_token(
&agent_id.to_string(),
&download.rel_path,
&secret,
5 * 60,
);
let base_url = download_base_url(&self.kernel.config.api_listen);
Ok(format!(
"{}/api/agents/{}/files/{}?download_token={}",
base_url,
agent_id,
encode_path(&download.rel_path),
percent_encode(&token),
))
}

// ── Automation: workflows, triggers, schedules, approvals ──

async fn list_workflows_text(&self) -> String {
Expand Down
12 changes: 12 additions & 0 deletions crates/openfang-api/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,18 @@ pub async fn auth(
return next.run(request).await;
}

let signed_download = is_get
&& path.starts_with("/api/agents/")
&& path.contains("/files/")
&& request.uri().query().is_some_and(|query| {
query
.split('&')
.any(|pair| pair.starts_with("download_token="))
});
if signed_download {
return next.run(request).await;
}

// If no API key configured and no dashboard login is active, fail closed
// for anything that did not come from loopback. Opting out of this
// behavior requires setting `OPENFANG_ALLOW_NO_AUTH=1`, which is logged
Expand Down
Loading