Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .progress/002_20260725_contributor_owned_art_proofs.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 24 additions & 1 deletion crates/temperpaw/src/setup_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -341,6 +342,13 @@ fn secrets_schema() -> Vec<SecretSchema> {
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",
Expand Down Expand Up @@ -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());
Expand Down
2 changes: 1 addition & 1 deletion crates/temperpaw/src/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
174 changes: 168 additions & 6 deletions crates/temperpaw/tests/paw_media_image_generation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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]
Expand All @@ -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\"",
Expand Down Expand Up @@ -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::<toml::Value>()
.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 [
"<Property Name=\"SourceFileId\"",
"<Property Name=\"SourceFileVersionId\"",
"<Property Name=\"SourceSha256\"",
"<Property Name=\"PromptSha256\"",
"<Property Name=\"ResultSha256\"",
"<Action Name=\"Edit\"",
] {
assert!(
model.contains(needle),
"paw-media CSDL should contain {needle}"
);
}

for needle in [
"Action::\"Edit\"",
"context.module == \"fal_image_edit\"",
"Action::\"http_call\"",
"Action::\"access_secret\"",
] {
assert!(
policy.contains(needle),
"paw-media Cedar should contain {needle}"
);
}
for needle in [
"Action::\"read\"",
"Action::\"list\"",
"resource is FileVersion",
] {
assert!(
file_version_policy.contains(needle),
"PawFS must allow immutable FileVersion metadata reads for contributor media verification: {needle}"
);
}

for needle in [
"openai/gpt-image-2/edit",
"fal-ai/nano-banana-2/edit",
"\"data:{};base64",
"/tdata/Files",
"/tdata/FileVersions",
"RecordResult",
"source_sha256",
"prompt_sha256",
"result_sha256",
] {
assert!(
provider.contains(needle),
"FAL image-edit provider should contain {needle}"
);
}

assert!(
build_script.contains("fal_image_edit"),
"paw-media build must package the FAL image-edit provider"
);

for needle in [
"method: \"image_edit\"",
"token: Some(\"temper_image_edit\")",
] {
assert!(
tool_catalog.contains(needle),
"tool catalog should expose {needle}"
);
}
for needle in [
"\"image_edit\" => 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();
Expand Down
3 changes: 2 additions & 1 deletion dashboard/src/routes/settings/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, VarRow[]>();
for (const v of vars) {
const cat = v.category || 'custom';
Expand Down Expand Up @@ -382,6 +382,7 @@

const CAT_LABELS: Record<string, string> = {
llm: 'LLM',
media: 'Media',
web_search: 'Web Search',
sandbox: 'Sandbox',
messaging: 'Messaging',
Expand Down
1 change: 1 addition & 0 deletions os-apps/paw-agent/agents/paw/AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion os-apps/paw-agent/specs/cron_job.ioa.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion os-apps/paw-agent/specs/session.ioa.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading