use super::super::runtime::ServiceRuntime;
use super::*;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SourceRegistration {
#[serde(deserialize_with = "Deserialize::deserialize")]
expected_revision: Option<String>,
resource: SourceResource,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SourceResource {
kind: ResourceKind,
name: String,
description: String,
source: Source,
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum ResourceKind {
Tool,
Skill,
}
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum Source {
Inline { content: String },
WorkspaceFile { path: String },
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SourceContract {
input_schema: Schema,
output_schema: Schema,
#[serde(default = "default_timeout")]
timeout_ms: u64,
}
pub(in crate::service) struct PreparedSource {
pub payload: Value,
pub descriptor: Value,
pub content: Arc<str>,
}
pub(in crate::service) fn prepare_source(
payload: &Value,
runtime: &ServiceRuntime,
) -> Result<PreparedSource, Code> {
let registration: SourceRegistration = decode(payload)?;
let resource = registration.resource;
registry::validate_resource_name(&resource.name)?;
registry::validate_catalog_name(&resource.name, runtime)?;
if resource.description.len() > 512 || resource.description.contains('\0') {
return Err(Code::InvalidPayload);
}
let (kind, limit) = match resource.kind {
ResourceKind::Tool => ("tool", 32768),
ResourceKind::Skill => ("skill", 16384),
};
let content = match resource.source {
Source::Inline { content } => content,
Source::WorkspaceFile { path } => read_workspace_source(runtime, &path, limit)?,
};
if content.len() > limit {
return Err(Code::LimitExceeded);
}
if content.contains('\0') {
return Err(Code::InvalidPayload);
}
let mut revision = Sha256::new();
revision.update(b"magi-application-resource-v1");
for bytes in [
kind.as_bytes(),
resource.name.as_bytes(),
resource.description.as_bytes(),
content.as_bytes(),
] {
revision.update((bytes.len() as u64).to_be_bytes());
revision.update(bytes);
}
let descriptor = json!({"kind":kind,"name":resource.name,"description":resource.description,
"resource_id":uuid::Uuid::new_v4().to_string(),"revision_id":uuid::Uuid::new_v4().to_string(),
"content_sha256":crate::hex::lower_hex(Sha256::digest(content.as_bytes())),
"revision_sha256":crate::hex::lower_hex(revision.finalize()),"captured_bytes":content.len()});
let body = match resource.kind {
ResourceKind::Tool => {
let value = super::super::persistent::parse_unique_json(content.as_bytes())?;
let source: SourceContract = decode(&value)?;
let contract = Contract {
name: resource.name,
description: resource.description,
input_schema: source.input_schema,
output_schema: source.output_schema,
timeout_ms: source.timeout_ms,
};
validate_contract(&contract)?;
json!({"kind":"tool","contract":contract})
}
ResourceKind::Skill => {
json!({"kind":"skill","name":resource.name,"description":resource.description,"content":content})
}
};
Ok(PreparedSource {
payload: json!({"expected_revision":registration.expected_revision,"resource":body}),
descriptor,
content: Arc::from(content),
})
}
#[cfg(unix)]
fn read_workspace_source(
runtime: &ServiceRuntime,
relative: &str,
limit: usize,
) -> Result<String, Code> {
use std::{
fs::{File, OpenOptions},
os::{
fd::{AsRawFd, FromRawFd},
unix::fs::{MetadataExt, OpenOptionsExt},
},
path::Path,
};
let invalid = || Code::InvalidPayload;
if relative.len() > 1024
|| relative
.split('/')
.any(|part| part.is_empty() || part == "." || part == "..")
|| Path::new(relative).is_absolute()
|| relative.contains('\0')
{
return Err(invalid());
}
let path = runtime.cwd.join(relative);
let resolved = path.canonicalize().map_err(|_| invalid())?;
if !resolved.starts_with(&runtime.cwd)
|| resolved.starts_with(&runtime.config.paths.root)
|| !matches!(
crate::checkpoints::classify_snapshot_eligibility(
&resolved,
&runtime.cwd,
&runtime.config.paths
),
crate::checkpoints::SnapshotEligibility::Eligible
)
|| relative.split('/').any(|part| {
let part = part.to_ascii_lowercase();
matches!(
part.as_str(),
".git"
| ".magi-code"
| ".ssh"
| ".aws"
| ".config"
| "auth.json"
| "id_rsa"
| "id_ed25519"
) || part.starts_with(".env")
|| part.ends_with(".pem")
|| part.ends_with(".key")
})
{
return Err(invalid());
}
let mut file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open("/")
.map_err(|_| invalid())?;
let components: Vec<_> = path
.components()
.filter_map(|component| match component {
std::path::Component::Normal(name) => Some(name),
_ => None,
})
.collect();
for (index, component) in components.iter().enumerate() {
let name = std::ffi::CString::new(component.as_encoded_bytes()).map_err(|_| invalid())?;
let directory_flag = if index + 1 == components.len() {
0
} else {
libc::O_DIRECTORY
};
let fd = unsafe {
libc::openat(
file.as_raw_fd(),
name.as_ptr(),
libc::O_RDONLY
| libc::O_NOFOLLOW
| libc::O_CLOEXEC
| libc::O_NONBLOCK
| directory_flag,
)
};
if fd < 0 {
return Err(invalid());
}
file = unsafe { File::from_raw_fd(fd) };
}
let before = file.metadata().map_err(|_| invalid())?;
if !before.is_file() || before.nlink() != 1 {
return Err(invalid());
}
if before.len() > limit as u64 {
return Err(Code::LimitExceeded);
}
let mut bytes = Vec::new();
(&mut file)
.take(limit as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|_| invalid())?;
let after = file.metadata().map_err(|_| invalid())?;
let identity = |metadata: &std::fs::Metadata| {
(
metadata.dev(),
metadata.ino(),
metadata.nlink(),
metadata.len(),
metadata.mtime(),
metadata.mtime_nsec(),
metadata.ctime(),
metadata.ctime_nsec(),
)
};
let current = std::fs::symlink_metadata(&path).map_err(|_| invalid())?;
if identity(&before) != identity(&after)
|| identity(&after) != identity(¤t)
|| !current.is_file()
|| bytes.len() as u64 != after.len()
{
return Err(invalid());
}
if bytes.len() > limit {
return Err(Code::LimitExceeded);
}
String::from_utf8(bytes).map_err(|_| invalid())
}
#[cfg(not(unix))]
fn read_workspace_source(_: &ServiceRuntime, _: &str, _: usize) -> Result<String, Code> {
Err(Code::UnsupportedCapability)
}