fix(telegram): resolve send_attachment paths in the user's workspace
Nightly Build / build (push) Successful in 8m6s

`send_attachment` handed its `file_path` argument straight to
`InputFile::file`, which resolves against the **server process's** working
directory. Every path the model can actually have — relative to the user's
home, or absolute inside their container — failed the `path.exists()` check,
and the one class that didn't (a name that happens to exist next to the
binary) would have sent the wrong file.

The routing already exists for the fs-tools, so expose it rather than repeat
it: `UserFilesApi` (core-api) reads a path in the agent's own vocabulary and
is obtained from `UserChannelHandle::files()`, so it is scoped to one user by
construction. skald-core implements it over `resolve_view_target` — host
mount read directly, container-only path through `docker exec` — holding the
`SharedFs` cell rather than a snapshot, so a remount lands without a login.

The size cap is checked before the read (a new `exec_fs::size` for the
container branch): the point of a cap is to keep an oversized file out of RAM,
so checking it afterwards would protect nothing. A photo above `sendPhoto`'s
narrower 10 MB ceiling goes out as a document instead of as an API error.
This commit is contained in:
Daniele
2026-08-10 00:08:16 +01:00
parent 5765941758
commit 55dcb48299
7 changed files with 171 additions and 12 deletions
+3 -1
View File
@@ -418,7 +418,9 @@ async fn handle_llm_message(
client_name,
extra_system_context: Some(TELEGRAM_FORMAT_CONTEXT.to_string()),
tail_reminder: Some(super::TELEGRAM_FORMAT_REMINDER.to_string()),
interface_tools: super::tools::interface_tools(bot.clone(), chat_id, &*shared.tts).await,
interface_tools: super::tools::interface_tools(
bot.clone(), chat_id, &*shared.tts, handle.files(),
).await,
metadata,
..Default::default()
};
+39 -11
View File
@@ -8,6 +8,7 @@ use teloxide::types::InputFile;
use core_api::interface_tool::InterfaceTool;
use core_api::tool::{Tool, ToolCategory, ToolDescriptionLength};
use core_api::tts::{TextToSpeech, TtsProvider};
use core_api::user_files::UserFilesApi;
use super::auth::{Binding, load_config, save_config};
use super::TelegramPlugin;
@@ -26,8 +27,9 @@ pub(crate) async fn interface_tools(
bot: Bot,
chat_id: ChatId,
tts: &dyn TtsProvider,
files: Arc<dyn UserFilesApi>,
) -> Vec<InterfaceTool> {
let mut tools = vec![send_attachment_tool(bot.clone(), chat_id)];
let mut tools = vec![send_attachment_tool(bot.clone(), chat_id, files)];
if let Some(synth) = tts.get().await {
tools.push(send_voice_tool(bot, chat_id, synth));
@@ -38,19 +40,37 @@ pub(crate) async fn interface_tools(
// ── send_attachment ───────────────────────────────────────────────────────────
fn send_attachment_tool(bot: Bot, chat_id: ChatId) -> InterfaceTool {
/// What the Bot API accepts in one upload (50 MB). Checked before the file is
/// read, so an oversized one costs a `stat` rather than a rejected 50 MB POST.
const TELEGRAM_UPLOAD_LIMIT: u64 = 50 * 1000 * 1000;
/// The narrower ceiling `sendPhoto` enforces — above it an image is sent as a
/// document instead, which is the same bytes without the inline preview.
const TELEGRAM_PHOTO_LIMIT: u64 = 10 * 1000 * 1000;
/// Sends a file from the **user's** workspace, resolved through
/// [`UserFilesApi`] — the same routing the fs-tools use, so `~/report.pdf`,
/// `uploads/{session}/photo.jpg` and the container-only `/tmp/out.png` all work.
///
/// It used to hand the raw argument to `InputFile::file`, which resolves against
/// the **server process's** working directory: every agent path the model has
/// ever been given (each of them relative to the user's home, or absolute inside
/// their container) failed the `path.exists()` check, and the one class that did
/// not — a name that happens to exist next to the binary — would have sent the
/// wrong file entirely.
fn send_attachment_tool(bot: Bot, chat_id: ChatId, files: Arc<dyn UserFilesApi>) -> InterfaceTool {
InterfaceTool {
definition: json!({
"type": "function",
"function": {
"name": "send_attachment",
"description": "Send a file from the local filesystem to the user on Telegram. Images (jpg/png/webp) and videos (mp4/mov/webm) are sent inline by default; any other type is sent as a document. Set as_document=true to force sending as a downloadable file.",
"description": "Send a file to the user on Telegram. Images (jpg/png/webp) and videos (mp4/mov/webm) are sent inline by default; any other type is sent as a document. Set as_document=true to force sending as a downloadable file.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the file to send."
"description": "Path to the file, in your usual vocabulary: `~/report.pdf`, `uploads/…`, `shared/{folder}/…`, `projects/…`, or an absolute path inside your sandbox (`/tmp/out.png`). Memory notes cannot be sent."
},
"caption": {
"type": "string",
@@ -67,6 +87,7 @@ fn send_attachment_tool(bot: Bot, chat_id: ChatId) -> InterfaceTool {
}),
handler: Arc::new(move |args| {
let bot = bot.clone();
let files = Arc::clone(&files);
Box::pin(async move {
let file_path = args["file_path"]
.as_str()
@@ -74,18 +95,17 @@ fn send_attachment_tool(bot: Bot, chat_id: ChatId) -> InterfaceTool {
let caption = args["caption"].as_str().map(str::to_string);
let as_document = args["as_document"].as_bool().unwrap_or(false);
let path = std::path::Path::new(file_path);
if !path.exists() {
anyhow::bail!("send_attachment: file not found: {file_path}");
}
let read = files.read(file_path, TELEGRAM_UPLOAD_LIMIT).await
.map_err(|e| anyhow::anyhow!("send_attachment: {e}"))?;
// Present images/videos inline by default; everything else (and
// anything when as_document=true) as a downloadable document.
let ext = path.extension()
let ext = std::path::Path::new(&read.name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let kind = if as_document {
let mut kind = if as_document {
"document"
} else {
match ext.as_str() {
@@ -94,8 +114,16 @@ fn send_attachment_tool(bot: Bot, chat_id: ChatId) -> InterfaceTool {
_ => "document",
}
};
// `sendPhoto` caps at 10 MB where `sendDocument` takes 50, so a big
// image goes out as a file rather than as an API error.
if kind == "photo" && read.bytes.len() as u64 > TELEGRAM_PHOTO_LIMIT {
kind = "document";
}
let file = InputFile::file(path);
// The bytes are already in hand — a container file has no host path
// to point Telegram at, and a mounted one would only be re-read.
let file = InputFile::memory(read.bytes).file_name(read.name);
let file_path = read.display;
let result = match kind {
"photo" => {
let mut req = bot.send_photo(chat_id, file);