fix(telegram): resolve send_attachment paths in the user's workspace
Nightly Build / build (push) Successful in 8m6s
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:
@@ -93,6 +93,20 @@ pub async fn write(container: &str, path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Byte size of a file inside the container — for the callers that must decide
|
||||
/// whether to read it *before* pulling it through the pipe. `wc -c` rather than
|
||||
/// `stat`, so the answer is the same on any of the image's shells.
|
||||
pub async fn size(container: &str, path: &Path) -> Result<u64> {
|
||||
let p = path.to_string_lossy();
|
||||
let raw = sh(container, r#"wc -c < "$1""#, &[&p])
|
||||
.await
|
||||
.with_context(|| format!("Cannot stat file: {p}"))?;
|
||||
String::from_utf8_lossy(&raw)
|
||||
.trim()
|
||||
.parse()
|
||||
.with_context(|| format!("Cannot stat file: {p}"))
|
||||
}
|
||||
|
||||
pub async fn exists(container: &str, path: &Path) -> bool {
|
||||
sh_ok(container, r#"test -e "$1""#, &[&path.to_string_lossy()]).await
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ use core_api::events::GlobalEvent;
|
||||
use core_api::inbox::InboxApi;
|
||||
use core_api::system_bus::SystemEventBus;
|
||||
use core_api::user_channel::UserChannelHandle;
|
||||
use core_api::user_files::{UserFile, UserFilesApi};
|
||||
use core_api::user_fs::SharedFs;
|
||||
|
||||
use crate::approval::ApprovalManager;
|
||||
@@ -560,7 +561,70 @@ impl UserChannelHandle for UserContextHandle {
|
||||
Arc::new(self.ctx.inbox.clone()) as Arc<dyn InboxApi>
|
||||
}
|
||||
|
||||
fn files(&self) -> Arc<dyn UserFilesApi> {
|
||||
Arc::new(UserContextFiles { fs: self.ctx.fs.clone() }) as Arc<dyn UserFilesApi>
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> broadcast::Receiver<GlobalEvent> {
|
||||
self.ctx.global_tx.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
// ── UserFilesApi impl ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Reads one user's files for a channel plugin, with the fs-tools' own routing.
|
||||
///
|
||||
/// It holds the [`SharedFs`] rather than a snapshot of it, so a membership change
|
||||
/// that remounts the user's container (§6) is picked up on the next read instead
|
||||
/// of at the next login.
|
||||
struct UserContextFiles {
|
||||
fs: SharedFs,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl UserFilesApi for UserContextFiles {
|
||||
async fn read(&self, path: &str, max_bytes: u64) -> Result<UserFile> {
|
||||
let fs = self.fs.load();
|
||||
let (target, display) = crate::tools::fs::resolve_view_target(fs.as_ref(), path)?;
|
||||
let name = std::path::Path::new(&display)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| display.clone());
|
||||
|
||||
// Size first, in both branches: the cap exists to keep an oversized file
|
||||
// out of RAM, so checking it after the read would be decoration.
|
||||
let too_big = |size: u64| {
|
||||
anyhow::anyhow!(
|
||||
"{display} is {:.1} MB — larger than the {:.0} MB this can send",
|
||||
size as f64 / 1e6,
|
||||
max_bytes as f64 / 1e6,
|
||||
)
|
||||
};
|
||||
|
||||
let bytes = match target {
|
||||
crate::tools::fs::FsTarget::Host(abs) => {
|
||||
let meta = tokio::fs::metadata(&abs)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("file not found: {display}"))?;
|
||||
if meta.is_dir() {
|
||||
anyhow::bail!("{display} is a directory, not a file");
|
||||
}
|
||||
if meta.len() > max_bytes {
|
||||
anyhow::bail!(too_big(meta.len()));
|
||||
}
|
||||
tokio::fs::read(&abs).await?
|
||||
}
|
||||
crate::tools::fs::FsTarget::Container { container, path } => {
|
||||
let size = crate::container::exec_fs::size(&container, &path)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("file not found: {display}"))?;
|
||||
if size > max_bytes {
|
||||
anyhow::bail!(too_big(size));
|
||||
}
|
||||
crate::container::exec_fs::read(&container, &path).await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(UserFile { display, name, bytes })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user