diff --git a/.progress/002_20260725_contributor_owned_art_proofs.md b/.progress/002_20260725_contributor_owned_art_proofs.md new file mode 100644 index 000000000..8958da105 --- /dev/null +++ b/.progress/002_20260725_contributor_owned_art_proofs.md @@ -0,0 +1,29 @@ +# Contributor-owned art-style proofs + +## Objective + +Add a generic PawMedia image-edit operation for TemperPaw contributors while +keeping Katagami submissions contributor-owned. Katagami may validate submitted +proofs, but it must never invoke PawMedia or spend provider credits for an +outside contributor. + +## Plan + +1. Record the PawMedia boundary and provenance contract in an app ADR. +2. Add a failing contract test for the `temper.image_edit` surface. +3. Extend `MediaGenerationRequest` with a governed FAL edit action and immutable + source/result provenance. +4. Add the provider-family WASM and agent-facing tool. +5. Build, test, and exercise the flow locally with a real image. +6. Publish PawMedia through the normal TemperPaw/Genesis path and verify the + installed ref. + +## Acceptance criteria + +- `temper.image_edit` accepts one prompt and one PawFS source image. +- The prompt is forwarded unchanged to either supported FAL edit model. +- Source and result file identifiers, immutable version identifiers, byte + digests, provider model, and provider request id are retained. +- FAL credentials stay inside PawMedia. +- No Katagami app or MCP automatically invokes PawMedia. +- Existing `temper.image_generate` behavior remains unchanged. diff --git a/crates/temperpaw/src/setup_api.rs b/crates/temperpaw/src/setup_api.rs index cb581b743..7a379a552 100644 --- a/crates/temperpaw/src/setup_api.rs +++ b/crates/temperpaw/src/setup_api.rs @@ -36,7 +36,7 @@ use crate::transport_manager::{ DiscordConnectParams, SlackConnectParams, TransportManager, TransportStatus, }; -const DEFAULT_SETUP_AGENT_TOOLS_ENABLED: &str = "temper_create,temper_get,temper_list,temper_action,temper_patch,temper_submit_specs,temper_show_spec,temper_specs,temper_upload_wasm,temper_get_trajectories,temper_get_insights,temper_get_decisions,temper_poll_decision,temper_approve_decision,temper_deny_decision,temper_submit_policy,temper_list_policies,temper_get_policy,temper_update_policy,temper_delete_policy,temper_search_apps,temper_install_app,temper_publish_app,temper_update_app,temper_list_apps,temper_spawn_session,temper_list_sessions,temper_abort_session,temper_steer_session,temper_save_memory,temper_recall_memory,temper_write,temper_write_many,temper_read,temper_run_coding_agent,temper_get_secret,temper_datadog_query,temper_railway,temper_vercel,temper_web_search,temper_web_fetch,temper_image_generate,read,write,edit,bash"; +const DEFAULT_SETUP_AGENT_TOOLS_ENABLED: &str = "temper_create,temper_get,temper_list,temper_action,temper_patch,temper_submit_specs,temper_show_spec,temper_specs,temper_upload_wasm,temper_get_trajectories,temper_get_insights,temper_get_decisions,temper_poll_decision,temper_approve_decision,temper_deny_decision,temper_submit_policy,temper_list_policies,temper_get_policy,temper_update_policy,temper_delete_policy,temper_search_apps,temper_install_app,temper_publish_app,temper_update_app,temper_list_apps,temper_spawn_session,temper_list_sessions,temper_abort_session,temper_steer_session,temper_save_memory,temper_recall_memory,temper_write,temper_write_many,temper_read,temper_run_coding_agent,temper_get_secret,temper_datadog_query,temper_railway,temper_vercel,temper_web_search,temper_web_fetch,temper_image_generate,temper_image_edit,read,write,edit,bash"; pub(crate) const DISCORD_TRANSPORT_CONNECTION_ID: &str = "transport-discord"; const OPENAI_CODEX_AUTH_ENTITY_ID: &str = "openai-codex-auth"; const OPENAI_CODEX_AUTH_ENTITY_TYPE: &str = "OpenAICodexAuth"; @@ -96,6 +96,7 @@ fn allowed_secret_keys() -> HashSet<&'static str> { "slack_signing_secret", "github_token", "exa_api_key", + "fal_key", "tensorlake_api_key", "temper_api_key", "llm_provider", @@ -341,6 +342,13 @@ fn secrets_schema() -> Vec { required: false, description: "Web search via exa.ai — agents can research the internet", }, + SecretSchema { + key: "fal_key", + category: "media", + label: "FAL API Key", + required: false, + description: "Optional image-edit provider credential used only by PawMedia", + }, SecretSchema { key: "sandbox_provider", category: "sandbox", @@ -3957,6 +3965,21 @@ mod tests { } } + #[test] + fn paw_media_fal_secret_is_allowed_and_rendered() { + assert!( + allowed_secret_keys().contains("fal_key"), + "PawMedia's FAL credential should be accepted by the setup API" + ); + let schema = secrets_schema(); + let fal = schema + .iter() + .find(|secret| secret.key == "fal_key") + .expect("FAL credential should be visible in dashboard schema"); + assert_eq!(fal.category, "media"); + assert!(fal.description.contains("only by PawMedia")); + } + #[test] fn setup_api_accepts_safe_custom_secret_names() { assert!(validate_setup_secret_key("vendor_x_api_key").is_ok()); diff --git a/crates/temperpaw/src/startup.rs b/crates/temperpaw/src/startup.rs index a0bd74f1c..31ef8a0b5 100644 --- a/crates/temperpaw/src/startup.rs +++ b/crates/temperpaw/src/startup.rs @@ -40,7 +40,7 @@ use tokio::task::JoinHandle; use crate::config::Config; use crate::storage::PawStorage; -const DEFAULT_AGENT_TOOLS_ENABLED: &str = "temper_create,temper_get,temper_list,temper_action,temper_patch,temper_submit_specs,temper_show_spec,temper_specs,temper_upload_wasm,temper_get_trajectories,temper_get_insights,temper_get_decisions,temper_poll_decision,temper_approve_decision,temper_deny_decision,temper_submit_policy,temper_list_policies,temper_get_policy,temper_update_policy,temper_delete_policy,temper_search_apps,temper_install_app,temper_publish_app,temper_update_app,temper_list_apps,temper_spawn_session,temper_list_sessions,temper_abort_session,temper_steer_session,temper_save_memory,temper_recall_memory,temper_write,temper_write_many,temper_read,temper_run_coding_agent,temper_get_secret,temper_datadog_query,temper_railway,temper_vercel,temper_web_search,temper_web_fetch,temper_image_generate,read,write,edit,bash"; +const DEFAULT_AGENT_TOOLS_ENABLED: &str = "temper_create,temper_get,temper_list,temper_action,temper_patch,temper_submit_specs,temper_show_spec,temper_specs,temper_upload_wasm,temper_get_trajectories,temper_get_insights,temper_get_decisions,temper_poll_decision,temper_approve_decision,temper_deny_decision,temper_submit_policy,temper_list_policies,temper_get_policy,temper_update_policy,temper_delete_policy,temper_search_apps,temper_install_app,temper_publish_app,temper_update_app,temper_list_apps,temper_spawn_session,temper_list_sessions,temper_abort_session,temper_steer_session,temper_save_memory,temper_recall_memory,temper_write,temper_write_many,temper_read,temper_run_coding_agent,temper_get_secret,temper_datadog_query,temper_railway,temper_vercel,temper_web_search,temper_web_fetch,temper_image_generate,temper_image_edit,read,write,edit,bash"; const DEFAULT_AGENT_WORKDIR: &str = "/workspace"; const STARTUP_PHASE_DURATION_METRIC: &str = "temper_startup_phase_duration_ms"; const STARTUP_TIME_TO_READY_METRIC: &str = "temper_startup_time_to_healthy_ms"; diff --git a/crates/temperpaw/tests/paw_media_image_generation.rs b/crates/temperpaw/tests/paw_media_image_generation.rs index 5931d6553..76df8066b 100644 --- a/crates/temperpaw/tests/paw_media_image_generation.rs +++ b/crates/temperpaw/tests/paw_media_image_generation.rs @@ -123,6 +123,10 @@ fn image_generation_tool_is_exposed_through_default_agent_tools() { source.contains("temper_image_generate"), "{label} should include temper_image_generate in default tools" ); + assert!( + source.contains("temper_image_edit"), + "{label} should include temper_image_edit in default tools" + ); } for needle in [ @@ -156,6 +160,8 @@ fn image_generation_tool_is_exposed_through_default_agent_tools() { "temper.image_generate", "For user image requests, call this tool", "gpt-image-*", + "temper.image_edit", + "contributor", ] { assert!( paw_agent_manual.contains(needle), @@ -217,10 +223,12 @@ fn paw_media_wasm_is_built_into_ci_and_production_images() { identity_contract.contains("\"os-apps/paw-media/wasm/build.sh\""), "identity contract should keep paw-media in the audited WASM build-script set" ); - assert!( - build_script.contains("openai_codex_image_generate.wasm"), - "paw-media build.sh must publish openai_codex_image_generate.wasm outside target/" - ); + for module in ["openai_codex_image_generate", "fal_image_edit"] { + assert!( + build_script.contains(module), + "paw-media build.sh must build and publish {module}.wasm outside target/" + ); + } } #[test] @@ -232,11 +240,12 @@ fn paw_media_policy_limits_result_callbacks_to_runtime_modules() { Action::"create", Action::"read", Action::"list", - Action::"Generate" + Action::"Generate", + Action::"Edit" ]"#; assert!( policy.contains(user_actions), - "user-facing MediaGenerationRequest policy should only expose create/read/list/Generate" + "user-facing MediaGenerationRequest policy should only expose create/read/list/Generate/Edit" ); for forbidden in [ "Action::\"RecordAuthReady\"", @@ -305,6 +314,159 @@ fn image_generation_tool_defaults_to_session_or_default_workspace() { ); } +#[test] +fn paw_media_exposes_contributor_owned_fal_image_editing() { + let root = repo_root(); + let app = read(root.join("os-apps/paw-media/app.toml")); + let spec = read(root.join("os-apps/paw-media/specs/media_generation.ioa.toml")); + let model = read(root.join("os-apps/paw-media/specs/model.csdl.xml")); + let policy = read(root.join("os-apps/paw-media/policies/media_generation.cedar")); + let file_version_policy = read(root.join("os-apps/paw-fs/policies/file_version.cedar")); + let build_script = read(root.join("os-apps/paw-media/wasm/build.sh")); + let provider = format!( + "{}\n{}", + read(root.join("os-apps/paw-media/wasm/fal_image_edit/src/lib.rs")), + read(root.join("os-apps/paw-media/wasm/fal_image_edit/src/pawfs.rs")), + ); + let tool_catalog = read(root.join("os-apps/paw-agent/wasm/tool-catalog/src/lib.rs")); + let dispatch = read(root.join("os-apps/paw-agent/wasm/monty_repl/src/dispatch.rs")); + + for needle in [ + "name = \"fal_image_edit\"", + "fal_key = \"{secret:fal_key}\"", + ] { + assert!( + app.contains(needle), + "paw-media app should contain {needle}" + ); + } + + for needle in [ + "name = \"source_file_id\"", + "name = \"source_file_version_id\"", + "name = \"source_sha256\"", + "name = \"prompt_sha256\"", + "name = \"result_sha256\"", + "name = \"Edit\"", + "module = \"fal_image_edit\"", + ] { + assert!( + spec.contains(needle), + "MediaGenerationRequest should contain {needle}" + ); + } + let parsed_spec = spec + .parse::() + .expect("paw-media IOA spec should parse as TOML"); + let actions = parsed_spec + .get("action") + .and_then(toml::Value::as_array) + .expect("paw-media spec should declare actions"); + for (action_name, trigger_name, module_name) in [ + ("Generate", "ensure_provider_auth", "provider_auth_gate"), + ("Edit", "edit_fal_image", "fal_image_edit"), + ] { + let action = actions + .iter() + .find(|action| action.get("name").and_then(toml::Value::as_str) == Some(action_name)) + .unwrap_or_else(|| panic!("paw-media should declare {action_name}")); + let trigger = action + .get("triggers") + .and_then(toml::Value::as_array) + .and_then(|triggers| { + triggers.iter().find(|trigger| { + trigger.get("name").and_then(toml::Value::as_str) == Some(trigger_name) + }) + }) + .unwrap_or_else(|| panic!("{action_name} should own trigger {trigger_name}")); + assert_eq!( + trigger.get("module").and_then(toml::Value::as_str), + Some(module_name), + "{action_name} should dispatch only through {module_name}" + ); + } + + for needle in [ + " Some(\"temper_image_edit\")", + "\"image_edit\" => temper_image_edit", + "Temper.Edit?await_integration=true", + "source_file_id", + "source_file_version_id", + ] { + assert!( + dispatch.contains(needle), + "Monty dispatch should contain {needle}" + ); + } +} + #[test] fn discord_delivery_accepts_pawfs_image_attachments() { let root = repo_root(); diff --git a/dashboard/src/routes/settings/+page.svelte b/dashboard/src/routes/settings/+page.svelte index 3e5679ec0..775f1b1af 100644 --- a/dashboard/src/routes/settings/+page.svelte +++ b/dashboard/src/routes/settings/+page.svelte @@ -235,7 +235,7 @@ // Group vars by category, LLM always first let groupedVars = $derived.by(() => { - const catOrder = ['llm', 'web_search', 'sandbox', 'messaging', 'integrations', 'observability', 'custom']; + const catOrder = ['llm', 'media', 'web_search', 'sandbox', 'messaging', 'integrations', 'observability', 'custom']; const catMap = new Map(); for (const v of vars) { const cat = v.category || 'custom'; @@ -382,6 +382,7 @@ const CAT_LABELS: Record = { llm: 'LLM', + media: 'Media', web_search: 'Web Search', sandbox: 'Sandbox', messaging: 'Messaging', diff --git a/os-apps/paw-agent/agents/paw/AGENT.md b/os-apps/paw-agent/agents/paw/AGENT.md index 7cffb4b7b..17957cef9 100644 --- a/os-apps/paw-agent/agents/paw/AGENT.md +++ b/os-apps/paw-agent/agents/paw/AGENT.md @@ -92,6 +92,7 @@ The lead picks up the new soul on their next agent run. - `temper.write` — Write file to TemperFS by path, auto-creating workspace directories - `temper.read` — Read TemperFS file content by path - `temper.image_generate` — For user image requests, call this tool; do not answer from old unsupported-model context. If the user names `gpt-image-*` or `dall-e-*`, still call this tool because the Codex media backend normalizes that request to the supported image route. +- `temper.image_edit` — For contributor-owned image transformations, edit one existing PawFS image with an explicitly selected allow-listed model. Pass the contributor's prompt unchanged and preserve the returned source, prompt, result hashes, and provider request ID with the contribution. This is optional contributor tooling: external contributors use their own tools, and a consuming app such as Katagami must never invoke it or spend image credits on their behalf. - `temper.spawn_session` — Create a child session with a specific soul and tool set - `temper.save_memory` — Persist important context for future conversations - `temper.search_history` — Search the current conversation when you need to recover recent context diff --git a/os-apps/paw-agent/specs/cron_job.ioa.toml b/os-apps/paw-agent/specs/cron_job.ioa.toml index 2a02ccb32..6bc9b6306 100644 --- a/os-apps/paw-agent/specs/cron_job.ioa.toml +++ b/os-apps/paw-agent/specs/cron_job.ioa.toml @@ -71,7 +71,7 @@ initial = "1.0" [[state]] name = "tools_enabled" type = "string" -initial = "temper_create,temper_get,temper_list,temper_action,temper_patch,temper_submit_specs,temper_show_spec,temper_specs,temper_upload_wasm,temper_get_trajectories,temper_get_insights,temper_get_decisions,temper_poll_decision,temper_approve_decision,temper_deny_decision,temper_submit_policy,temper_list_policies,temper_get_policy,temper_update_policy,temper_delete_policy,temper_search_apps,temper_install_app,temper_publish_app,temper_update_app,temper_list_apps,temper_spawn_session,temper_list_sessions,temper_abort_session,temper_steer_session,temper_save_memory,temper_recall_memory,temper_write,temper_read,temper_run_coding_agent,temper_get_secret,temper_datadog_query,temper_railway,temper_vercel,temper_web_search,temper_web_fetch,temper_image_generate,read,write,edit,bash" +initial = "temper_create,temper_get,temper_list,temper_action,temper_patch,temper_submit_specs,temper_show_spec,temper_specs,temper_upload_wasm,temper_get_trajectories,temper_get_insights,temper_get_decisions,temper_poll_decision,temper_approve_decision,temper_deny_decision,temper_submit_policy,temper_list_policies,temper_get_policy,temper_update_policy,temper_delete_policy,temper_search_apps,temper_install_app,temper_publish_app,temper_update_app,temper_list_apps,temper_spawn_session,temper_list_sessions,temper_abort_session,temper_steer_session,temper_save_memory,temper_recall_memory,temper_write,temper_read,temper_run_coding_agent,temper_get_secret,temper_datadog_query,temper_railway,temper_vercel,temper_web_search,temper_web_fetch,temper_image_generate,temper_image_edit,read,write,edit,bash" [[state]] name = "sandbox_url" diff --git a/os-apps/paw-agent/specs/session.ioa.toml b/os-apps/paw-agent/specs/session.ioa.toml index a8a747fc1..329b035f6 100644 --- a/os-apps/paw-agent/specs/session.ioa.toml +++ b/os-apps/paw-agent/specs/session.ioa.toml @@ -215,7 +215,7 @@ initial = "/workspace" [[state]] name = "tools_enabled" type = "string" -initial = "temper_create,temper_get,temper_list,temper_action,temper_patch,temper_submit_specs,temper_show_spec,temper_specs,temper_upload_wasm,temper_get_trajectories,temper_get_insights,temper_get_decisions,temper_poll_decision,temper_approve_decision,temper_deny_decision,temper_submit_policy,temper_list_policies,temper_get_policy,temper_update_policy,temper_delete_policy,temper_search_apps,temper_install_app,temper_publish_app,temper_update_app,temper_list_apps,temper_spawn_session,temper_list_sessions,temper_abort_session,temper_steer_session,temper_save_memory,temper_recall_memory,temper_write,temper_read,temper_run_coding_agent,temper_get_secret,temper_datadog_query,temper_railway,temper_vercel,temper_web_search,temper_web_fetch,temper_image_generate,read,write,edit,bash" +initial = "temper_create,temper_get,temper_list,temper_action,temper_patch,temper_submit_specs,temper_show_spec,temper_specs,temper_upload_wasm,temper_get_trajectories,temper_get_insights,temper_get_decisions,temper_poll_decision,temper_approve_decision,temper_deny_decision,temper_submit_policy,temper_list_policies,temper_get_policy,temper_update_policy,temper_delete_policy,temper_search_apps,temper_install_app,temper_publish_app,temper_update_app,temper_list_apps,temper_spawn_session,temper_list_sessions,temper_abort_session,temper_steer_session,temper_save_memory,temper_recall_memory,temper_write,temper_read,temper_run_coding_agent,temper_get_secret,temper_datadog_query,temper_railway,temper_vercel,temper_web_search,temper_web_fetch,temper_image_generate,temper_image_edit,read,write,edit,bash" [[state]] name = "system_prompt" diff --git a/os-apps/paw-agent/wasm/monty_repl/src/dispatch.rs b/os-apps/paw-agent/wasm/monty_repl/src/dispatch.rs index d788543c4..392fa1804 100644 --- a/os-apps/paw-agent/wasm/monty_repl/src/dispatch.rs +++ b/os-apps/paw-agent/wasm/monty_repl/src/dispatch.rs @@ -726,6 +726,7 @@ fn temper_method_token(method: &str) -> Option<&'static str> { "web_search" => Some("temper_web_search"), "web_fetch" => Some("temper_web_fetch"), "image_generate" => Some("temper_image_generate"), + "image_edit" => Some("temper_image_edit"), "done" | "get_agent_id" | "get_session_id" | "switch_provider" | "switch_mode" => None, _ => None, } @@ -980,6 +981,7 @@ fn dispatch_temper( "web_search" => temper_web_search(ctx, api_url, tenant, args), "web_fetch" => temper_web_fetch(ctx, api_url, tenant, args), "image_generate" => temper_image_generate(ctx, api_url, tenant, args), + "image_edit" => temper_image_edit(ctx, api_url, tenant, args), _ => Err(format!( "unknown temper method '{method}'. Available: \ @@ -991,7 +993,7 @@ fn dispatch_temper( spawn_session, list_sessions, abort_session, steer_session, \ save_memory, recall_memory, write, read, ls, grep, glob, edit, rename, \ search_history, run_coding_agent, datadog_query, railway, vercel, \ - web_search, web_fetch, image_generate" + web_search, web_fetch, image_generate, image_edit" )), } } @@ -1443,6 +1445,159 @@ fn temper_image_generate( Ok(rendered) } +/// Edit one PawFS image through the PawMedia FAL provider. +fn temper_image_edit( + ctx: &Context, + api_url: &str, + tenant: &str, + args: &[Value], +) -> Result { + let input = image_edit_input(ctx, api_url, tenant, args)?; + let entity = http_post( + ctx, + api_url, + tenant, + "/tdata/MediaGenerationRequests", + &json!({ + "Prompt": input.prompt, + "MediaType": "image", + "Operation": "edit", + "Provider": "fal", + "Model": input.model, + "Size": input.size, + "Quality": input.quality, + "OutputFormat": input.output_format, + "Background": "auto", + "WorkspaceId": input.workspace_id, + "OutputPath": input.output_path, + "SourceFileId": input.source_file_id, + "SourceFileVersionId": input.source_file_version_id, + }), + )?; + let entity_id = entity + .get("entity_id") + .or_else(|| entity.get("EntityId")) + .or_else(|| entity.get("Id")) + .and_then(Value::as_str) + .or_else(|| entity_field_str(&entity, &["Id", "id"])) + .ok_or_else(|| { + "image_edit: failed to get entity_id from created MediaGenerationRequest".to_string() + })?; + + let key = escape_odata_key(entity_id); + http_post( + ctx, + api_url, + tenant, + &format!("/tdata/MediaGenerationRequests('{key}')/Temper.Edit?await_integration=true"), + &json!({ + "prompt": input.prompt, + "media_type": "image", + "operation": "edit", + "provider": "fal", + "model": input.model, + "size": input.size, + "quality": input.quality, + "output_format": input.output_format, + "background": "auto", + "workspace_id": input.workspace_id, + "output_path": input.output_path, + "source_file_id": input.source_file_id, + "source_file_version_id": input.source_file_version_id, + }), + )?; + + let result = http_get( + ctx, + api_url, + tenant, + &format!("/tdata/MediaGenerationRequests('{key}')"), + )?; + let rendered = render_media_generation_result(entity_id, &result, input.include_base64)?; + record_dispatch_image_result(&rendered); + Ok(rendered) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ImageEditInput { + prompt: String, + model: String, + size: String, + quality: String, + output_format: String, + workspace_id: String, + output_path: String, + source_file_id: String, + source_file_version_id: String, + include_base64: bool, +} + +fn image_edit_input( + ctx: &Context, + api_url: &str, + tenant: &str, + args: &[Value], +) -> Result { + let (prompt, opts) = match args.first() { + Some(Value::Object(input)) => { + let prompt = input + .get("prompt") + .or_else(|| input.get("Prompt")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + "temper.image_edit(): object input must include non-empty prompt".to_string() + })? + .trim() + .to_string(); + let opts = input + .get("opts") + .or_else(|| input.get("options")) + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| Value::Object(input.clone())); + (prompt, opts) + } + _ => { + let prompt = str_arg(args, 0, "prompt", "image_edit")?; + (prompt.trim().to_string(), obj_arg_or_empty(args, 1)) + } + }; + if prompt.is_empty() { + return Err("temper.image_edit(): prompt cannot be empty".to_string()); + } + let source_file_id = opts_string(&opts, &["source_file_id", "SourceFileId"]) + .ok_or_else(|| "temper.image_edit(): source_file_id is required".to_string())?; + let workspace_id = resolve_image_workspace_id(ctx, api_url, tenant, &opts)?; + Ok(ImageEditInput { + prompt, + model: opts_string(&opts, &["model", "Model"]) + .unwrap_or_else(|| "openai/gpt-image-2/edit".to_string()), + size: opts_string(&opts, &["size", "Size"]).unwrap_or_else(|| "auto".to_string()), + quality: opts_string(&opts, &["quality", "Quality"]) + .unwrap_or_else(|| "high".to_string()), + output_format: opts_string(&opts, &["output_format", "OutputFormat"]) + .or_else(|| opts_string(&opts, &["format", "Format"])) + .unwrap_or_else(|| "png".to_string()), + workspace_id, + output_path: opts_string(&opts, &["output_path", "OutputPath"]) + .or_else(|| opts_string(&opts, &["path", "Path"])) + .unwrap_or_default(), + source_file_id, + source_file_version_id: opts_string( + &opts, + &["source_file_version_id", "SourceFileVersionId"], + ) + .unwrap_or_default(), + include_base64: opts + .get("inline") + .or_else(|| opts.get("include_base64")) + .or_else(|| opts.get("base64")) + .and_then(Value::as_bool) + .unwrap_or(true), + }) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct ImageGenerateInput { prompt: String, @@ -1681,10 +1836,16 @@ fn render_media_generation_result( "path": path, "source_path": path, "prompt": entity_field_str(fields, &["Prompt", "prompt"]).unwrap_or(""), + "operation": entity_field_str(fields, &["Operation", "operation"]).unwrap_or("generate"), "revised_prompt": entity_field_str(fields, &["RevisedPrompt", "revised_prompt"]).unwrap_or(""), "provider": entity_field_str(fields, &["Provider", "provider"]).unwrap_or("openai_codex"), "model": entity_field_str(fields, &["Model", "model"]).unwrap_or(""), "provider_response_id": entity_field_str(fields, &["ProviderResponseId", "provider_response_id"]).unwrap_or(""), + "source_file_id": entity_field_str(fields, &["SourceFileId", "source_file_id"]).unwrap_or(""), + "source_file_version_id": entity_field_str(fields, &["SourceFileVersionId", "source_file_version_id"]).unwrap_or(""), + "source_sha256": entity_field_str(fields, &["SourceSha256", "source_sha256"]).unwrap_or(""), + "prompt_sha256": entity_field_str(fields, &["PromptSha256", "prompt_sha256"]).unwrap_or(""), + "result_sha256": entity_field_str(fields, &["ResultSha256", "result_sha256"]).unwrap_or(""), "byte_count": byte_count, }); if include_base64 && !base64_data.is_empty() { diff --git a/os-apps/paw-agent/wasm/tool-catalog/src/lib.rs b/os-apps/paw-agent/wasm/tool-catalog/src/lib.rs index 79765e417..0fbc5d5a2 100644 --- a/os-apps/paw-agent/wasm/tool-catalog/src/lib.rs +++ b/os-apps/paw-agent/wasm/tool-catalog/src/lib.rs @@ -1,6 +1,6 @@ use std::collections::BTreeSet; -pub const DEFAULT_TOOLS_ENABLED: &str = "temper_create,temper_get,temper_list,temper_action,temper_patch,temper_submit_specs,temper_show_spec,temper_specs,temper_upload_wasm,temper_get_trajectories,temper_get_insights,temper_get_decisions,temper_poll_decision,temper_approve_decision,temper_deny_decision,temper_submit_policy,temper_list_policies,temper_get_policy,temper_update_policy,temper_delete_policy,temper_search_apps,temper_install_app,temper_publish_app,temper_update_app,temper_list_apps,temper_spawn_session,temper_list_sessions,temper_abort_session,temper_steer_session,temper_save_memory,temper_recall_memory,temper_write,temper_write_many,temper_read,temper_ls,temper_grep,temper_glob,temper_edit,temper_rename,temper_search_history,temper_run_coding_agent,temper_get_secret,temper_datadog_query,temper_railway,temper_vercel,temper_web_search,temper_web_fetch,temper_image_generate,read,write,edit,bash"; +pub const DEFAULT_TOOLS_ENABLED: &str = "temper_create,temper_get,temper_list,temper_action,temper_patch,temper_submit_specs,temper_show_spec,temper_specs,temper_upload_wasm,temper_get_trajectories,temper_get_insights,temper_get_decisions,temper_poll_decision,temper_approve_decision,temper_deny_decision,temper_submit_policy,temper_list_policies,temper_get_policy,temper_update_policy,temper_delete_policy,temper_search_apps,temper_install_app,temper_publish_app,temper_update_app,temper_list_apps,temper_spawn_session,temper_list_sessions,temper_abort_session,temper_steer_session,temper_save_memory,temper_recall_memory,temper_write,temper_write_many,temper_read,temper_ls,temper_grep,temper_glob,temper_edit,temper_rename,temper_search_history,temper_run_coding_agent,temper_get_secret,temper_datadog_query,temper_railway,temper_vercel,temper_web_search,temper_web_fetch,temper_image_generate,temper_image_edit,read,write,edit,bash"; #[derive(Clone, Copy, Debug)] pub struct ReplMethodSpec { @@ -418,6 +418,13 @@ pub const REPL_METHOD_SPECS: &[ReplMethodSpec] = &[ description: "generate an image through the paw-media app. Use this tool for user image requests; gpt-image-2, gpt-image-1, and dall-e-* requests are normalized onto the Codex media backend. Returns PawFS file metadata and an image handle", token: Some("temper_image_generate"), }, + ReplMethodSpec { + object: "temper", + method: "image_edit", + signature: "(prompt, {source_file_id, model, ...}) or ({prompt, source_file_id, ...})", + description: "edit one PawFS image through the paw-media app. The prompt is sent unchanged to the selected FAL edit model. Returns PawFS file metadata and content-addressed provenance", + token: Some("temper_image_edit"), + }, ]; pub fn normalize_tool_token(token: &str) -> &str { diff --git a/os-apps/paw-channels/wasm/route_message/src/lib.rs b/os-apps/paw-channels/wasm/route_message/src/lib.rs index 6a3b4a0de..db111e1bf 100644 --- a/os-apps/paw-channels/wasm/route_message/src/lib.rs +++ b/os-apps/paw-channels/wasm/route_message/src/lib.rs @@ -6,7 +6,7 @@ use wasm_helpers::{ timestamp_millis_string, }; -const DEFAULT_TOOLS_ENABLED: &str = "temper_create,temper_get,temper_list,temper_action,temper_patch,temper_submit_specs,temper_show_spec,temper_specs,temper_upload_wasm,temper_get_trajectories,temper_get_insights,temper_get_decisions,temper_poll_decision,temper_approve_decision,temper_deny_decision,temper_submit_policy,temper_list_policies,temper_get_policy,temper_update_policy,temper_delete_policy,temper_search_apps,temper_install_app,temper_publish_app,temper_update_app,temper_list_apps,temper_spawn_session,temper_list_sessions,temper_abort_session,temper_steer_session,temper_save_memory,temper_recall_memory,temper_write,temper_read,temper_run_coding_agent,temper_get_secret,temper_datadog_query,temper_railway,temper_vercel,temper_web_search,temper_web_fetch,temper_image_generate,read,write,edit,bash"; +const DEFAULT_TOOLS_ENABLED: &str = "temper_create,temper_get,temper_list,temper_action,temper_patch,temper_submit_specs,temper_show_spec,temper_specs,temper_upload_wasm,temper_get_trajectories,temper_get_insights,temper_get_decisions,temper_poll_decision,temper_approve_decision,temper_deny_decision,temper_submit_policy,temper_list_policies,temper_get_policy,temper_update_policy,temper_delete_policy,temper_search_apps,temper_install_app,temper_publish_app,temper_update_app,temper_list_apps,temper_spawn_session,temper_list_sessions,temper_abort_session,temper_steer_session,temper_save_memory,temper_recall_memory,temper_write,temper_read,temper_run_coding_agent,temper_get_secret,temper_datadog_query,temper_railway,temper_vercel,temper_web_search,temper_web_fetch,temper_image_generate,temper_image_edit,read,write,edit,bash"; const PLAN_MODE_TOOLS: &str = "temper_create,temper_get,temper_list,temper_action,temper_specs,temper_show_spec,temper_save_memory,temper_recall_memory,temper_read,temper_write,temper_web_search,temper_web_fetch,temper_get_trajectories,temper_get_insights,read,bash"; const DEFAULT_WORKDIR: &str = "/workspace"; diff --git a/os-apps/paw-fs/policies/file_version.cedar b/os-apps/paw-fs/policies/file_version.cedar index 1de0f7d37..eb8ec3d5d 100644 --- a/os-apps/paw-fs/policies/file_version.cedar +++ b/os-apps/paw-fs/policies/file_version.cedar @@ -1,5 +1,14 @@ // TemperFS FileVersion Authorization Policies +// FileVersion metadata is the immutable counterpart to readable File metadata. +// Runtime modules need it to verify that bytes still match the exact version +// selected by the caller. +permit( + principal, + action in [Action::"read", Action::"list"], + resource is FileVersion +); + permit( principal is Agent, action in [Action::"Create", Action::"Supersede"], diff --git a/os-apps/paw-media/APP.md b/os-apps/paw-media/APP.md index 62e489e75..5cc77cff1 100644 --- a/os-apps/paw-media/APP.md +++ b/os-apps/paw-media/APP.md @@ -4,12 +4,18 @@ Paw Media provides governed media generation as Temper-native entity flows. ## Entities -- `MediaGenerationRequest`: request/result state for media generation. Version 1 supports `media_type = "image"`, `operation = "generate"`, and `provider = "openai_codex"`. +- `MediaGenerationRequest`: request/result state for media generation and editing. It supports `operation = "generate"` with `provider = "openai_codex"` and `operation = "edit"` with `provider = "fal"`. ## Agent Tool Agents call `temper.image_generate(prompt, opts=None)`. The tool creates a `MediaGenerationRequest`, dispatches `Generate`, waits for the WASM provider module, then returns PawFS file metadata plus a short-lived inline image marker for immediate multimodal feedback. +Agents call `temper.image_edit(prompt, opts)` with a PawFS `source_file_id`. +PawMedia sends that exact prompt without aesthetic rewriting to the selected +allow-listed FAL edit model, stores the result in PawFS, and records byte +digests for the source, prompt, and result. This is contributor tooling: +external collections do not invoke it automatically for submissions. + Durable bytes are stored through PawFS `File` streams. Inline base64 is only a transient tool-result convenience and uses the spec overflow TTL. The Codex provider reads generated image responses through the Temper WASM streaming HTTP host API, so provider responses are not constrained by the fixed non-streaming SDK response buffer. The default quality remains `low` to keep DM image generation fast and inexpensive unless callers request a higher quality. diff --git a/os-apps/paw-media/adrs/004-contributor-owned-image-editing.md b/os-apps/paw-media/adrs/004-contributor-owned-image-editing.md new file mode 100644 index 000000000..846c6ecae --- /dev/null +++ b/os-apps/paw-media/adrs/004-contributor-owned-image-editing.md @@ -0,0 +1,63 @@ +# ADR-004: Contributor-owned image editing + +## Status + +Accepted + +## Date + +2026-07-25 + +## Context + +TemperPaw agents can act as contributors to external collections such as +Katagami. Those agents need a governed image-to-image operation to make and +evaluate their own visual submissions. The collection itself must not become a +central image-generation service: outside contributors bring their own proof +images, while a TemperPaw contributor may choose to create proofs with its own +PawMedia tools. + +The existing PawMedia app supports text-to-image generation through the Codex +subscription route. It does not accept a source image and cannot perform the +same edit with two image models. A provider integration embedded in Katagami +would put provider credentials, billing, orchestration, and contributor +submission policy in the wrong service. + +## Decision + +PawMedia owns a second, explicit operation: `temper.image_edit`. + +The operation creates the existing `MediaGenerationRequest`, then dispatches an +`Edit` action with: + +- one exact prompt; +- one PawFS source file and, when available, its immutable version id; +- `provider = "fal"`; +- one allow-listed FAL edit model; +- mechanical output controls only. + +The first provider-family module is `fal_image_edit`. It supports +`openai/gpt-image-2/edit` and `fal-ai/nano-banana-2/edit`. The module reads the +source from PawFS, sends it as a base64 data URI, stores the result back in +PawFS, and records source, prompt, result, model, and provider-request +provenance. The prompt text is not rewritten or augmented by PawMedia. + +`Generate` and the existing Codex subscription path remain unchanged. `Edit` +has its own action and provider module so provider selection is visible in the +state machine and a non-Codex request never enters the Codex auth flow. + +Katagami is not a PawMedia caller. Katagami accepts proof files and provenance +from contributors and validates them. A TemperPaw agent may voluntarily use +PawMedia before submitting; an outside contributor uses their own tools. + +## Consequences + +- Image-generation spend belongs to the TemperPaw caller that requested it. +- Katagami does not hold FAL credentials or silently generate images for + submissions. +- Both edit models receive byte-for-byte identical aesthetic prompt text. +- Provider mechanics remain app-scoped and auditable through entity + transitions. +- The source image is an edit input, not a style-reference dependency. +- Additional providers require their own provider-family module and explicit + action routing; arbitrary endpoint strings are rejected. diff --git a/os-apps/paw-media/app.toml b/os-apps/paw-media/app.toml index af73717a0..9fbc7f46e 100644 --- a/os-apps/paw-media/app.toml +++ b/os-apps/paw-media/app.toml @@ -8,3 +8,11 @@ dependencies = ["paw-agent", "paw-fs"] name = "openai_codex_image_generate" criticality = "app-required" startup_loading = "lazy" + +[[wasm_modules]] +name = "fal_image_edit" +criticality = "app-required" +startup_loading = "lazy" + +[wasm_modules.config] +fal_key = "{secret:fal_key}" diff --git a/os-apps/paw-media/policies/media_generation.cedar b/os-apps/paw-media/policies/media_generation.cedar index aebd7c86f..5919a4831 100644 --- a/os-apps/paw-media/policies/media_generation.cedar +++ b/os-apps/paw-media/policies/media_generation.cedar @@ -5,7 +5,8 @@ permit( Action::"create", Action::"read", Action::"list", - Action::"Generate" + Action::"Generate", + Action::"Edit" ], resource is MediaGenerationRequest ); @@ -22,6 +23,35 @@ permit( context.module == "provider_auth_gate" }; +// The FAL editor owns callbacks for its explicit Edit path. +permit( + principal is Agent, + action in [ + Action::"RecordStoring", + Action::"RecordResult", + Action::"RecordError" + ], + resource is MediaGenerationRequest +) when { + context.module == "fal_image_edit" +}; + +permit( + principal is Agent, + action == Action::"http_call", + resource is HttpEndpoint +) when { + context.module == "fal_image_edit" +}; + +permit( + principal is Agent, + action == Action::"access_secret", + resource is Secret +) when { + context.module == "fal_image_edit" +}; + // The Codex renderer is the only module that can store or finalize image results. permit( principal is Agent, diff --git a/os-apps/paw-media/specs/media_generation.ioa.toml b/os-apps/paw-media/specs/media_generation.ioa.toml index 9074b6d02..3cc276a62 100644 --- a/os-apps/paw-media/specs/media_generation.ioa.toml +++ b/os-apps/paw-media/specs/media_generation.ioa.toml @@ -69,6 +69,16 @@ name = "output_path" type = "string" initial = "" +[[state]] +name = "source_file_id" +type = "string" +initial = "" + +[[state]] +name = "source_file_version_id" +type = "string" +initial = "" + # --- Provider auth gate fields --- [[state]] @@ -134,6 +144,21 @@ name = "usage_json" type = "string" initial = "" +[[state]] +name = "source_sha256" +type = "string" +initial = "" + +[[state]] +name = "prompt_sha256" +type = "string" +initial = "" + +[[state]] +name = "result_sha256" +type = "string" +initial = "" + [[state]] name = "result_image_base64" type = "string" @@ -173,6 +198,30 @@ default_llm_provider = "openai_codex" auth_action = "EnsureFresh" ready_action = "RecordAuthReady" +[[action]] +name = "Edit" +kind = "input" +from = ["Created", "Failed"] +to = "Generating" +params = ["prompt", "media_type", "operation", "provider", "model", "size", "quality", "output_format", "background", "workspace_id", "output_path", "source_file_id", "source_file_version_id"] +hint = "Edit one PawFS image with the exact supplied prompt through a governed provider." +effect = [{ type = "trigger", name = "edit_fal_image" }] + +[[action.triggers]] +name = "edit_fal_image" +kind = "wasm" +module = "fal_image_edit" +on_failure = "RecordError" +llm = true + +[action.triggers.config] +temper_api_url = "{secret:temper_api_url}" +fal_key = "{secret:fal_key}" +timeout_secs = "600" +max_source_bytes = "8388608" +max_result_bytes = "16777216" +max_memory = "268435456" + [[action]] name = "RecordAuthReady" kind = "input" @@ -212,7 +261,7 @@ name = "RecordResult" kind = "input" from = ["Generating", "Storing"] to = "Complete" -params = ["result_file_id", "result_file_version_id", "result_path", "mime_type", "revised_prompt", "provider_response_id", "usage_json", "result_image_base64"] +params = ["result_file_id", "result_file_version_id", "result_path", "mime_type", "revised_prompt", "provider_response_id", "usage_json", "result_image_base64", "source_sha256", "prompt_sha256", "result_sha256"] hint = "Generation completed and durable PawFS output is available." [[action]] diff --git a/os-apps/paw-media/specs/model.csdl.xml b/os-apps/paw-media/specs/model.csdl.xml index cac478cd0..0b5bef26d 100644 --- a/os-apps/paw-media/specs/model.csdl.xml +++ b/os-apps/paw-media/specs/model.csdl.xml @@ -17,6 +17,8 @@ + + @@ -29,6 +31,9 @@ + + + @@ -52,6 +57,24 @@ + + + + + + + + + + + + + + + + + + @@ -80,6 +103,9 @@ + + + diff --git a/os-apps/paw-media/wasm/build.sh b/os-apps/paw-media/wasm/build.sh index 8384ad15e..b7ce410ef 100644 --- a/os-apps/paw-media/wasm/build.sh +++ b/os-apps/paw-media/wasm/build.sh @@ -19,17 +19,17 @@ copy_artifact() { fi } -for module in openai_codex_image_generate; do +for module in openai_codex_image_generate fal_image_edit; do echo "Building $module..." (cd "$SCRIPT_DIR/$module" && cargo build --target wasm32-unknown-unknown --release) copy_artifact "$module" "wasm32-unknown-unknown" - test -f "$SCRIPT_DIR/openai_codex_image_generate/openai_codex_image_generate.wasm" + test -f "$SCRIPT_DIR/$module/$module.wasm" echo " -> $module built successfully" done echo "" echo "All paw-media WASM modules built. Binaries at:" -for module in openai_codex_image_generate; do +for module in openai_codex_image_generate fal_image_edit; do wasm_file="$SCRIPT_DIR/$module/target/wasm32-unknown-unknown/release/${module}.wasm" if [ ! -f "$wasm_file" ]; then wasm_file="$SCRIPT_DIR/$module/target/wasm32-unknown-unknown/release/$(echo "$module" | tr '_' '-').wasm" diff --git a/os-apps/paw-media/wasm/fal_image_edit/Cargo.toml b/os-apps/paw-media/wasm/fal_image_edit/Cargo.toml new file mode 100644 index 000000000..6aff41091 --- /dev/null +++ b/os-apps/paw-media/wasm/fal_image_edit/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "fal-image-edit" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[workspace] + +[dependencies] +temper-wasm-sdk = { git = "https://github.com/nerdsane/temper.git", rev = "a28fdb2ed8b0e1e0ea15a99c3b4a1dfba20e160e" } +wasm-helpers = { path = "../../../paw-agent/wasm/wasm-helpers" } +base64 = "0.22" +serde_json = "1" +sha2 = "0.10" diff --git a/os-apps/paw-media/wasm/fal_image_edit/src/lib.rs b/os-apps/paw-media/wasm/fal_image_edit/src/lib.rs new file mode 100644 index 000000000..a89b1ba9a --- /dev/null +++ b/os-apps/paw-media/wasm/fal_image_edit/src/lib.rs @@ -0,0 +1,488 @@ +//! FAL image editing for PawMedia. +//! +//! `MediaGenerationRequest.Edit` invokes this provider-family module. It reads +//! one immutable PawFS source, sends the caller's prompt unchanged to an +//! allow-listed FAL edit model, stores the output in PawFS, and records +//! content-addressed provenance. + +mod pawfs; + +use base64::{Engine as _, engine::general_purpose}; +use pawfs::{SourceImage, StoredImage, download_image, load_source_image, store_image}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use temper_wasm_sdk::prelude::*; +use wasm_helpers::{resolve_temper_api_url, runtime_headers_as}; + +const DEFAULT_MEDIA_TYPE: &str = "image"; +const DEFAULT_OPERATION: &str = "edit"; +const DEFAULT_PROVIDER: &str = "fal"; +const DEFAULT_OUTPUT_FORMAT: &str = "png"; +const GPT_IMAGE_EDIT: &str = "openai/gpt-image-2/edit"; +const NANO_BANANA_EDIT: &str = "fal-ai/nano-banana-2/edit"; +const FAL_RUN_BASE: &str = "https://fal.run"; +const MAX_PROVIDER_RESPONSE_BYTES: usize = 2 * 1024 * 1024; + +#[unsafe(no_mangle)] +pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 { + if let Err(error) = run_fal_image_edit() { + set_error_result(&error); + } + 0 +} + +fn run_fal_image_edit() -> Result<(), String> { + let ctx = Context::from_host()?; + let fields = ctx.entity_state.get("fields").cloned().unwrap_or(json!({})); + match edit_and_store(&ctx, &fields) { + Ok(result) => set_success_result("RecordResult", &record_result_params(&result)), + Err(error) => set_success_result( + "RecordError", + &json!({ + "error": error, + "last_error": "fal_image_edit failed", + }), + ), + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct EditResult { + stored: StoredImage, + source_sha256: String, + prompt_sha256: String, + result_sha256: String, + provider_response_id: String, + usage_json: String, +} + +fn edit_and_store(ctx: &Context, fields: &Value) -> Result { + let request = validated_request(fields)?; + let source = load_source_image(ctx, fields, &request.workspace_id, &request.source_file_id)?; + validate_requested_source_version(&request, &source)?; + + let source_sha256 = sha256_hex(&source.bytes); + let prompt_sha256 = sha256_hex(request.prompt.as_bytes()); + let image_data_uri = format!( + "data:{};base64,{}", + source.mime_type, + general_purpose::STANDARD.encode(&source.bytes) + ); + let provider_body = provider_request_body(&request, &image_data_uri); + let provider = call_fal(ctx, &request.model, &provider_body)?; + let provider_response_id = provider_response_id(&provider.body); + let usage_json = usage_json(&provider.body); + record_storing(ctx, fields, &provider_response_id, &usage_json)?; + let result_url = first_image_url(&provider.body)?; + let downloaded = download_image(ctx, &result_url)?; + let result_sha256 = sha256_hex(&downloaded.bytes); + let output_path = output_path(fields, ctx, extension_for_mime(&downloaded.mime_type)); + let stored = store_image( + ctx, + fields, + &request.workspace_id, + &output_path, + &downloaded.mime_type, + &downloaded.bytes, + )?; + + Ok(EditResult { + stored, + source_sha256, + prompt_sha256, + result_sha256, + provider_response_id, + usage_json, + }) +} + +fn record_result_params(result: &EditResult) -> Value { + json!({ + "result_file_id": result.stored.file_id, + "result_file_version_id": result.stored.file_version_id, + "result_path": result.stored.path, + "mime_type": result.stored.mime_type, + "revised_prompt": "", + "provider_response_id": result.provider_response_id, + "usage_json": result.usage_json, + "result_image_base64": "", + "source_sha256": result.source_sha256, + "prompt_sha256": result.prompt_sha256, + "result_sha256": result.result_sha256, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct EditRequest { + prompt: String, + model: String, + size: String, + quality: String, + output_format: String, + workspace_id: String, + source_file_id: String, + source_file_version_id: String, +} + +fn validated_request(fields: &Value) -> Result { + let media_type = field(fields, &["media_type", "MediaType"], DEFAULT_MEDIA_TYPE); + let operation = field(fields, &["operation", "Operation"], DEFAULT_OPERATION); + let provider = field(fields, &["provider", "Provider"], DEFAULT_PROVIDER) + .trim() + .to_ascii_lowercase(); + if !media_type.eq_ignore_ascii_case(DEFAULT_MEDIA_TYPE) { + return Err(format!( + "fal_image_edit: unsupported media_type '{media_type}'" + )); + } + if !operation.eq_ignore_ascii_case(DEFAULT_OPERATION) { + return Err(format!( + "fal_image_edit: unsupported operation '{operation}'" + )); + } + if provider != DEFAULT_PROVIDER { + return Err(format!("fal_image_edit: unsupported provider '{provider}'")); + } + + let model = field(fields, &["model", "Model"], ""); + if ![GPT_IMAGE_EDIT, NANO_BANANA_EDIT].contains(&model) { + return Err(format!("fal_image_edit: unsupported model '{model}'")); + } + let prompt = field(fields, &["prompt", "Prompt"], "").trim().to_string(); + let workspace_id = field(fields, &["workspace_id", "WorkspaceId"], "") + .trim() + .to_string(); + let source_file_id = field(fields, &["source_file_id", "SourceFileId"], "") + .trim() + .to_string(); + if prompt.is_empty() { + return Err("fal_image_edit: prompt is required".to_string()); + } + if workspace_id.is_empty() { + return Err("fal_image_edit: workspace_id is required".to_string()); + } + if source_file_id.is_empty() { + return Err("fal_image_edit: source_file_id is required".to_string()); + } + + Ok(EditRequest { + prompt, + model: model.to_string(), + size: field(fields, &["size", "Size"], "auto").to_string(), + quality: field(fields, &["quality", "Quality"], "high").to_string(), + output_format: normalize_output_format(field( + fields, + &["output_format", "OutputFormat"], + DEFAULT_OUTPUT_FORMAT, + )), + workspace_id, + source_file_id, + source_file_version_id: field( + fields, + &["source_file_version_id", "SourceFileVersionId"], + "", + ) + .trim() + .to_string(), + }) +} + +fn validate_requested_source_version( + request: &EditRequest, + source: &SourceImage, +) -> Result<(), String> { + if !request.source_file_version_id.is_empty() + && request.source_file_version_id != source.file_version_id + { + return Err(format!( + "fal_image_edit: source file version changed (requested '{}', current '{}')", + request.source_file_version_id, source.file_version_id + )); + } + Ok(()) +} + +fn provider_request_body(request: &EditRequest, image_data_uri: &str) -> Value { + if request.model == GPT_IMAGE_EDIT { + json!({ + "prompt": request.prompt, + "image_urls": [image_data_uri], + "image_size": normalize_gpt_size(&request.size), + "quality": normalize_quality(&request.quality), + "num_images": 1, + "output_format": request.output_format, + }) + } else { + json!({ + "prompt": request.prompt, + "image_urls": [image_data_uri], + "aspect_ratio": "auto", + "resolution": normalize_nano_resolution(&request.size), + "num_images": 1, + "output_format": request.output_format, + "limit_generations": true, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProviderResponse { + body: String, +} + +fn call_fal(ctx: &Context, model: &str, body: &Value) -> Result { + let fal_key = ctx + .config + .get("fal_key") + .map(String::as_str) + .filter(|value| !value.trim().is_empty() && !value.contains("{secret:")) + .ok_or_else(|| "fal_image_edit: fal_key is not configured".to_string())?; + let url = format!("{FAL_RUN_BASE}/{model}"); + let authorization = format!("Key {fal_key}"); + let headers = [ + ("Authorization", authorization.as_str()), + ("Content-Type", "application/json"), + ("Accept", "application/json"), + ]; + let serialized = body.to_string(); + let (status, response_bytes) = pawfs::streaming_http_call( + "POST", + &url, + &headers, + serialized.as_bytes(), + MAX_PROVIDER_RESPONSE_BYTES, + )?; + let response_body = String::from_utf8(response_bytes) + .map_err(|error| format!("fal_image_edit: provider response was not UTF-8: {error}"))?; + if !(200..300).contains(&status) { + return Err(format!( + "fal_image_edit: provider returned HTTP {status}: {}", + snippet(&response_body) + )); + } + serde_json::from_str::(&response_body) + .map_err(|error| format!("fal_image_edit: provider returned invalid JSON: {error}"))?; + Ok(ProviderResponse { + body: response_body, + }) +} + +fn first_image_url(body: &str) -> Result { + let parsed: Value = serde_json::from_str(body) + .map_err(|error| format!("fal_image_edit: parse provider response: {error}"))?; + let data = parsed.get("data").unwrap_or(&parsed); + let url = data + .get("images") + .and_then(Value::as_array) + .and_then(|images| images.first()) + .and_then(|image| image.get("url")) + .and_then(Value::as_str) + .unwrap_or(""); + if !url.starts_with("https://") { + return Err("fal_image_edit: provider response contained no HTTPS image URL".to_string()); + } + Ok(url.to_string()) +} + +fn provider_response_id(body: &str) -> String { + serde_json::from_str::(body) + .ok() + .and_then(|value| { + value + .get("request_id") + .or_else(|| value.get("id")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }) + .unwrap_or_default() +} + +fn usage_json(body: &str) -> String { + serde_json::from_str::(body) + .ok() + .and_then(|value| value.get("usage").cloned()) + .map(|value| value.to_string()) + .unwrap_or_else(|| "{}".to_string()) +} + +fn output_path(fields: &Value, ctx: &Context, extension: &str) -> String { + let configured = field(fields, &["output_path", "OutputPath"], "").trim(); + if !configured.is_empty() { + return with_extension(configured, extension); + } + let entity_id = ctx + .entity_state + .get("entity_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(ctx.entity_id.as_str()); + format!("/generated/edits/{entity_id}.{extension}") +} + +fn field<'a>(fields: &'a Value, names: &[&str], fallback: &'a str) -> &'a str { + names + .iter() + .find_map(|name| fields.get(*name).and_then(Value::as_str)) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback) +} + +fn normalize_output_format(value: &str) -> String { + match value.trim().to_ascii_lowercase().as_str() { + "jpg" | "jpeg" => "jpeg".to_string(), + "webp" => "webp".to_string(), + _ => "png".to_string(), + } +} + +fn normalize_quality(value: &str) -> &'static str { + match value.trim().to_ascii_lowercase().as_str() { + "low" => "low", + "medium" => "medium", + "auto" => "auto", + _ => "high", + } +} + +fn normalize_gpt_size(value: &str) -> &'static str { + match value.trim() { + "square_hd" => "square_hd", + "square" => "square", + "portrait_4_3" => "portrait_4_3", + "portrait_16_9" => "portrait_16_9", + "landscape_4_3" => "landscape_4_3", + "landscape_16_9" => "landscape_16_9", + _ => "auto", + } +} + +fn normalize_nano_resolution(value: &str) -> &'static str { + match value.trim().to_ascii_uppercase().as_str() { + "0.5K" => "0.5K", + "2K" => "2K", + "4K" => "4K", + _ => "1K", + } +} + +fn extension_for_mime(mime_type: &str) -> &'static str { + match mime_type { + "image/jpeg" => "jpg", + "image/webp" => "webp", + _ => "png", + } +} + +fn with_extension(path: &str, extension: &str) -> String { + let lower = path.to_ascii_lowercase(); + if [".png", ".jpg", ".jpeg", ".webp"] + .iter() + .any(|suffix| lower.ends_with(suffix)) + { + path.to_string() + } else { + format!("{path}.{extension}") + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn snippet(value: &str) -> String { + value.chars().take(300).collect() +} + +fn record_storing( + ctx: &Context, + fields: &Value, + provider_response_id: &str, + usage_json: &str, +) -> Result<(), String> { + let api_url = resolve_temper_api_url(ctx, fields); + let entity_id = ctx + .entity_state + .get("entity_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(ctx.entity_id.as_str()); + let url = format!( + "{api_url}/tdata/MediaGenerationRequests('{}')/Temper.RecordStoring", + entity_id.replace('\'', "''") + ); + let headers = runtime_headers_as( + ctx, + &ctx.tenant, + fields, + "system", + Some("application/json"), + Some("application/json"), + ); + let body = json!({ + "provider_response_id": provider_response_id, + "revised_prompt": "", + "usage_json": usage_json, + }); + let response = ctx.http_call("POST", &url, &headers, &body.to_string())?; + if !(200..300).contains(&response.status) { + return Err(format!( + "fal_image_edit: RecordStoring returned HTTP {}: {}", + response.status, + snippet(&response.body) + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fields(model: &str) -> Value { + json!({ + "Prompt": "Use dry charcoal marks and hard white-paper gaps.", + "MediaType": "image", + "Operation": "edit", + "Provider": "fal", + "Model": model, + "Size": "auto", + "Quality": "high", + "OutputFormat": "png", + "WorkspaceId": "workspace-1", + "SourceFileId": "source-1", + "SourceFileVersionId": "version-1", + }) + } + + #[test] + fn both_models_receive_the_exact_prompt() { + for model in [GPT_IMAGE_EDIT, NANO_BANANA_EDIT] { + let request = validated_request(&fields(model)).unwrap(); + let body = provider_request_body(&request, "data:image/png;base64,abc"); + assert_eq!( + body["prompt"], + "Use dry charcoal marks and hard white-paper gaps." + ); + } + } + + #[test] + fn arbitrary_fal_endpoints_are_rejected() { + let error = validated_request(&fields("someone/unknown/edit")).unwrap_err(); + assert!(error.contains("unsupported model")); + } + + #[test] + fn parses_wrapped_and_unwrapped_results() { + for body in [ + r#"{"images":[{"url":"https://files.test/output.png"}]}"#, + r#"{"data":{"images":[{"url":"https://files.test/output.png"}]}}"#, + ] { + assert_eq!( + first_image_url(body).unwrap(), + "https://files.test/output.png" + ); + } + } +} diff --git a/os-apps/paw-media/wasm/fal_image_edit/src/pawfs.rs b/os-apps/paw-media/wasm/fal_image_edit/src/pawfs.rs new file mode 100644 index 000000000..09bc26afe --- /dev/null +++ b/os-apps/paw-media/wasm/fal_image_edit/src/pawfs.rs @@ -0,0 +1,359 @@ +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use temper_wasm_sdk::prelude::*; +use wasm_helpers::{entity_field_str, resolve_temper_api_url, runtime_headers_for_workspace}; + +#[cfg(target_arch = "wasm32")] +const STREAM_CHUNK_BYTES: usize = 256 * 1024; +const DEFAULT_MAX_SOURCE_BYTES: usize = 8 * 1024 * 1024; +const DEFAULT_MAX_RESULT_BYTES: usize = 16 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SourceImage { + pub(crate) file_version_id: String, + pub(crate) mime_type: String, + pub(crate) bytes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DownloadedImage { + pub(crate) mime_type: String, + pub(crate) bytes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct StoredImage { + pub(crate) file_id: String, + pub(crate) file_version_id: String, + pub(crate) path: String, + pub(crate) mime_type: String, +} + +pub(crate) fn load_source_image( + ctx: &Context, + fields: &Value, + workspace_id: &str, + file_id: &str, +) -> Result { + let api_url = resolve_temper_api_url(ctx, fields); + let head_url = format!("{api_url}/tdata/Files('{}')", escape_key(file_id)); + let headers = runtime_headers_for_workspace( + ctx, + &ctx.tenant, + fields, + workspace_id, + None, + Some("application/json"), + ); + let response = ctx.http_call("GET", &head_url, &headers, "")?; + if !(200..300).contains(&response.status) { + return Err(format!( + "fal_image_edit: source File read failed (HTTP {}): {}", + response.status, + snippet(&response.body) + )); + } + let head: Value = serde_json::from_str(&response.body) + .map_err(|error| format!("fal_image_edit: parse source File: {error}"))?; + let source_workspace = entity_field_str(&head, &["WorkspaceId", "workspace_id"]).unwrap_or(""); + if !source_workspace.is_empty() && source_workspace != workspace_id { + return Err("fal_image_edit: source File belongs to another workspace".to_string()); + } + let file_version_id = entity_field_str(&head, &["LastVersionId", "last_version_id"]) + .unwrap_or("") + .to_string(); + let declared_mime = entity_field_str(&head, &["MimeType", "mime_type"]).unwrap_or(""); + let value_url = format!("{api_url}/tdata/Files('{}')/$value", escape_key(file_id)); + let max_bytes = config_usize(ctx, "max_source_bytes", DEFAULT_MAX_SOURCE_BYTES); + let (_, bytes) = + streaming_http_call("GET", &value_url, &to_borrowed(&headers), &[], max_bytes)?; + verify_file_version_bytes(ctx, &api_url, &headers, file_id, &file_version_id, &bytes)?; + let mime_type = detect_image_mime(&bytes) + .or_else(|| normalize_image_mime(declared_mime)) + .ok_or_else(|| "fal_image_edit: source File is not a supported raster image".to_string())?; + Ok(SourceImage { + file_version_id, + mime_type, + bytes, + }) +} + +fn verify_file_version_bytes( + ctx: &Context, + api_url: &str, + headers: &[(String, String)], + file_id: &str, + file_version_id: &str, + bytes: &[u8], +) -> Result<(), String> { + if file_version_id.is_empty() { + return Err("fal_image_edit: source File has no immutable version id".to_string()); + } + let version_url = format!( + "{api_url}/tdata/FileVersions('{}')", + escape_key(file_version_id) + ); + let version = ctx.http_call("GET", &version_url, headers, "")?; + if !(200..300).contains(&version.status) { + return Err(format!( + "fal_image_edit: source FileVersion read failed (HTTP {}): {}", + version.status, + snippet(&version.body) + )); + } + let version: Value = serde_json::from_str(&version.body) + .map_err(|error| format!("fal_image_edit: parse source FileVersion: {error}"))?; + let version_file_id = entity_field_str(&version, &["FileId", "file_id"]).unwrap_or(""); + if version_file_id != file_id { + return Err("fal_image_edit: source FileVersion belongs to another File".to_string()); + } + let declared_hash = entity_field_str(&version, &["ContentHash", "content_hash"]).unwrap_or(""); + let actual_hash = format!("{:x}", Sha256::digest(bytes)); + if !normalize_sha256(declared_hash) + .is_some_and(|declared| declared.eq_ignore_ascii_case(&actual_hash)) + { + return Err( + "fal_image_edit: source bytes do not match the immutable FileVersion hash".to_string(), + ); + } + Ok(()) +} + +pub(crate) fn download_image(ctx: &Context, url: &str) -> Result { + if !url.starts_with("https://") { + return Err("fal_image_edit: result URL must use HTTPS".to_string()); + } + let max_bytes = config_usize(ctx, "max_result_bytes", DEFAULT_MAX_RESULT_BYTES); + let (status, bytes) = streaming_http_call("GET", url, &[], &[], max_bytes)?; + if !(200..300).contains(&status) { + return Err(format!( + "fal_image_edit: result download returned HTTP {status}" + )); + } + let mime_type = detect_image_mime(&bytes) + .ok_or_else(|| "fal_image_edit: result is not a supported raster image".to_string())?; + Ok(DownloadedImage { mime_type, bytes }) +} + +pub(crate) fn store_image( + ctx: &Context, + fields: &Value, + workspace_id: &str, + path: &str, + mime_type: &str, + bytes: &[u8], +) -> Result { + let api_url = resolve_temper_api_url(ctx, fields); + let name = path.rsplit('/').next().unwrap_or("edited-image.png"); + let headers = runtime_headers_for_workspace( + ctx, + &ctx.tenant, + fields, + workspace_id, + Some("application/json"), + Some("application/json"), + ); + let create = ctx.http_call( + "POST", + &format!("{api_url}/tdata/Files"), + &headers, + &json!({ + "Name": name, + "Path": path, + "WorkspaceId": workspace_id, + "MimeType": mime_type, + }) + .to_string(), + )?; + if !(200..300).contains(&create.status) { + return Err(format!( + "fal_image_edit: result File create failed (HTTP {}): {}", + create.status, + snippet(&create.body) + )); + } + let created: Value = serde_json::from_str(&create.body) + .map_err(|error| format!("fal_image_edit: parse result File create: {error}"))?; + let file_id = entity_field_str(&created, &["Id", "id"]) + .or_else(|| created.get("entity_id").and_then(Value::as_str)) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "fal_image_edit: result File create returned no id".to_string())? + .to_string(); + + let value_headers = runtime_headers_for_workspace( + ctx, + &ctx.tenant, + fields, + workspace_id, + Some(mime_type), + None, + ); + let value_url = format!("{api_url}/tdata/Files('{}')/$value", escape_key(&file_id)); + let (put_status, _) = + streaming_http_call("PUT", &value_url, &to_borrowed(&value_headers), bytes, 1024)?; + if !(200..300).contains(&put_status) { + return Err(format!( + "fal_image_edit: result File upload returned HTTP {put_status}" + )); + } + + let head = ctx.http_call( + "GET", + &format!("{api_url}/tdata/Files('{}')", escape_key(&file_id)), + &headers, + "", + )?; + if !(200..300).contains(&head.status) { + return Err(format!( + "fal_image_edit: result File read-after-write returned HTTP {}", + head.status + )); + } + let head_value: Value = serde_json::from_str(&head.body) + .map_err(|error| format!("fal_image_edit: parse result File head: {error}"))?; + let file_version_id = entity_field_str(&head_value, &["LastVersionId", "last_version_id"]) + .unwrap_or("") + .to_string(); + + Ok(StoredImage { + file_id, + file_version_id, + path: path.to_string(), + mime_type: mime_type.to_string(), + }) +} + +pub(crate) fn streaming_http_call( + method: &str, + url: &str, + headers: &[(&str, &str)], + request_bytes: &[u8], + max_response_bytes: usize, +) -> Result<(u16, Vec), String> { + #[cfg(target_arch = "wasm32")] + { + let (mut request_body, mut response_body, response_head) = + temper_wasm_sdk::http_stream::streaming_call(method, url, headers) + .map_err(|error| format!("fal_image_edit: open HTTP stream: {error}"))?; + for chunk in request_bytes.chunks(STREAM_CHUNK_BYTES) { + request_body + .write_all_chunk(chunk) + .map_err(|error| format!("fal_image_edit: write HTTP request: {error}"))?; + } + request_body + .finish() + .map_err(|error| format!("fal_image_edit: finish HTTP request: {error}"))?; + let head = response_head() + .map_err(|error| format!("fal_image_edit: read HTTP response head: {error}"))?; + let mut bytes = Vec::new(); + let mut buffer = vec![0_u8; STREAM_CHUNK_BYTES]; + loop { + match response_body.read_next_chunk(&mut buffer) { + Ok(Some(0)) | Ok(None) => break, + Ok(Some(count)) => { + if bytes.len().saturating_add(count) > max_response_bytes { + let _ = response_body.close(); + return Err(format!( + "fal_image_edit: HTTP response exceeds {max_response_bytes} bytes" + )); + } + bytes.extend_from_slice(&buffer[..count]); + } + Err(error) => { + let _ = response_body.close(); + return Err(format!("fal_image_edit: read HTTP response: {error}")); + } + } + } + response_body + .close() + .map_err(|error| format!("fal_image_edit: close HTTP response: {error}"))?; + Ok((head.status, bytes)) + } + + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (method, url, headers, request_bytes, max_response_bytes); + Err("fal_image_edit: streaming HTTP is available in WASM only".to_string()) + } +} + +fn config_usize(ctx: &Context, key: &str, fallback: usize) -> usize { + ctx.config + .get(key) + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(fallback) +} + +fn detect_image_mime(bytes: &[u8]) -> Option { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("image/png".to_string()) + } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + Some("image/jpeg".to_string()) + } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + Some("image/webp".to_string()) + } else { + None + } +} + +fn normalize_image_mime(value: &str) -> Option { + match value.split(';').next().unwrap_or("").trim() { + "image/png" => Some("image/png".to_string()), + "image/jpeg" | "image/jpg" => Some("image/jpeg".to_string()), + "image/webp" => Some("image/webp".to_string()), + _ => None, + } +} + +fn to_borrowed(headers: &[(String, String)]) -> Vec<(&str, &str)> { + headers + .iter() + .map(|(name, value)| (name.as_str(), value.as_str())) + .collect() +} + +fn escape_key(value: &str) -> String { + value.replace('\'', "''") +} + +fn snippet(value: &str) -> String { + value.chars().take(300).collect() +} + +fn normalize_sha256(value: &str) -> Option<&str> { + let value = value.trim(); + let digest = value.strip_prefix("sha256:").unwrap_or(value); + (digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit())).then_some(digest) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_supported_raster_formats() { + assert_eq!( + detect_image_mime(b"\x89PNG\r\n\x1a\npayload").as_deref(), + Some("image/png") + ); + assert_eq!( + detect_image_mime(&[0xff, 0xd8, 0xff, 0x00]).as_deref(), + Some("image/jpeg") + ); + assert_eq!( + detect_image_mime(b"RIFFxxxxWEBPpayload").as_deref(), + Some("image/webp") + ); + assert!(detect_image_mime(b"").is_none()); + } + + #[test] + fn normalizes_content_addressed_sha256_values() { + let hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + assert_eq!(normalize_sha256(hash), Some(hash)); + assert_eq!(normalize_sha256(&format!("sha256:{hash}")), Some(hash)); + assert_eq!(normalize_sha256("sha256:not-a-digest"), None); + } +}