-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(artifacts): define artifact storage types, config, and persistence layer #2801
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| pub mod ops; | ||
| pub mod schemas; | ||
| pub mod store; | ||
| pub mod types; | ||
|
|
||
| pub use schemas::{ | ||
| all_controller_schemas as all_artifacts_controller_schemas, | ||
| all_registered_controllers as all_artifacts_registered_controllers, | ||
| }; | ||
| pub use types::{ArtifactKind, ArtifactMeta, ArtifactStatus}; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,114 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| use serde_json::{json, Value}; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| use crate::openhuman::config::Config; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| use crate::rpc::RpcOutcome; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| use super::store; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Default page size for `ai_list_artifacts`. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const DEFAULT_LIMIT: usize = 50; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Maximum page size cap for `ai_list_artifacts`. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const MAX_LIMIT: usize = 200; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// List artifacts in the workspace with pagination. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Returns `{ "artifacts": [...], "total": N, "offset": M, "limit": L }`. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pub async fn ai_list_artifacts( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| config: &Config, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| offset: Option<usize>, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| limit: Option<usize>, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) -> Result<RpcOutcome<Value>, String> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let offset = offset.unwrap_or(0); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log::debug!( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "[artifacts] ai_list_artifacts: workspace={:?} offset={offset} limit={limit}", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| config.workspace_dir | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let (artifacts, total) = store::list_artifacts(&config.workspace_dir, offset, limit).await?; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log::debug!( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "[artifacts] ai_list_artifacts: returning {} of {total} total", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| artifacts.len() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let value = json!({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "artifacts": artifacts, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "total": total, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "offset": offset, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "limit": limit, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Ok(RpcOutcome::new(value, vec![])) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Retrieve a single artifact by ID. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Returns the serialized `ArtifactMeta` plus an `absolute_path` field | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// pointing to the full on-disk location of the artifact files. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pub async fn ai_get_artifact( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| config: &Config, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| artifact_id: &str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) -> Result<RpcOutcome<Value>, String> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log::debug!( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "[artifacts] ai_get_artifact: id={artifact_id} workspace={:?}", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| config.workspace_dir | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if artifact_id.is_empty() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Err("[artifacts] artifact_id must not be empty".to_string()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let meta = store::get_artifact(&config.workspace_dir, artifact_id).await?; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Compute absolute path for the caller's convenience. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let absolute_path = config | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .workspace_dir | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .join("artifacts") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .join(&meta.path) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .to_string_lossy() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .into_owned(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+63
to
+69
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Harden
Suggested fix+ use std::path::{Component, Path};
+
+ let rel = Path::new(&meta.path);
+ if rel.is_absolute()
+ || rel
+ .components()
+ .any(|c| matches!(c, Component::ParentDir | Component::RootDir | Component::Prefix(_)))
+ {
+ return Err(format!(
+ "[artifacts] invalid artifact path for id={artifact_id}: {:?}",
+ meta.path
+ ));
+ }
+
+ let artifacts_root = config.workspace_dir.join("artifacts");
let absolute_path = config
- .workspace_dir
- .join("artifacts")
+ .workspace_dir
+ .join("artifacts")
.join(&meta.path)
.to_string_lossy()
.into_owned();📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let mut value = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| serde_json::to_value(&meta).map_err(|e| format!("[artifacts] serialization error: {e}"))?; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if let Some(obj) = value.as_object_mut() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| obj.insert( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "absolute_path".to_string(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Value::String(absolute_path.clone()), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log::debug!( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "[artifacts] ai_get_artifact: found id={artifact_id} absolute_path={absolute_path}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Ok(RpcOutcome::new(value, vec![])) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Delete an artifact and all associated files. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Returns `{ "artifact_id": "...", "deleted": true }`. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pub async fn ai_delete_artifact( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| config: &Config, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| artifact_id: &str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) -> Result<RpcOutcome<Value>, String> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log::debug!( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "[artifacts] ai_delete_artifact: id={artifact_id} workspace={:?}", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| config.workspace_dir | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if artifact_id.is_empty() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Err("[artifacts] artifact_id must not be empty".to_string()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| store::delete_artifact(&config.workspace_dir, artifact_id).await?; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log::debug!("[artifacts] ai_delete_artifact: deleted id={artifact_id}"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let value = json!({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "artifact_id": artifact_id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "deleted": true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Ok(RpcOutcome::new(value, vec![])) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #[cfg(test)] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #[path = "ops_tests.rs"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mod tests; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| use tempfile::TempDir; | ||
|
|
||
| use super::*; | ||
| use crate::openhuman::config::Config; | ||
|
|
||
| fn test_config(tmp: &TempDir) -> Config { | ||
| Config { | ||
| workspace_dir: tmp.path().to_path_buf(), | ||
| config_path: tmp.path().join("config.toml"), | ||
| ..Config::default() | ||
| } | ||
| } | ||
|
|
||
| // ── ai_list_artifacts ────────────────────────────────────────────────────── | ||
|
|
||
| #[tokio::test] | ||
| async fn list_empty() { | ||
| let tmp = TempDir::new().unwrap(); | ||
| let config = test_config(&tmp); | ||
| let outcome = ai_list_artifacts(&config, None, None).await.unwrap(); | ||
| let value = outcome.into_cli_compatible_json().unwrap(); | ||
| assert_eq!(value["total"], 0); | ||
| assert_eq!(value["artifacts"].as_array().unwrap().len(), 0); | ||
| assert_eq!(value["offset"], 0); | ||
| assert_eq!(value["limit"], DEFAULT_LIMIT as u64); | ||
| } | ||
|
|
||
| // ── ai_get_artifact ──────────────────────────────────────────────────────── | ||
|
|
||
| #[tokio::test] | ||
| async fn get_missing_id_error() { | ||
| let tmp = TempDir::new().unwrap(); | ||
| let config = test_config(&tmp); | ||
| let err = ai_get_artifact(&config, "").await.unwrap_err(); | ||
| assert!(err.contains("must not be empty"), "unexpected error: {err}"); | ||
| } | ||
|
|
||
| // ── ai_delete_artifact ───────────────────────────────────────────────────── | ||
|
|
||
| #[tokio::test] | ||
| async fn delete_missing_id_error() { | ||
| let tmp = TempDir::new().unwrap(); | ||
| let config = test_config(&tmp); | ||
| let err = ai_delete_artifact(&config, "").await.unwrap_err(); | ||
| assert!(err.contains("must not be empty"), "unexpected error: {err}"); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct the security guidance and
assert_within_rootdescription.This line is currently misleading in two ways:
assert_within_roothere does not canonicalize paths (it checksstarts_with), and advising teams to “replicate this pattern” conflicts with the documented policy that file I/O validation must go throughsrc/openhuman/security/policy.rs(validate_path/validate_parent_path). Please update this note to avoid steering future domains toward a non-policy pattern.Suggested doc fix
📝 Committable suggestion
🤖 Prompt for AI Agents