diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 7034bf789de..78a97df7662 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -336,6 +336,7 @@ Trace Commons issuer/TenantCtx note: the server-side `zmanian/tracedao-server` s | Tool-level streaming | ✅ | ❌ | | | Z.AI tool_stream | ✅ | ❌ | Real-time tool call streaming | | Plugin tools | ✅ | ✅ | WASM tools | +| GSuite WASM tools | ✅ | 🚧 | Reborn bundles operation-level Google Drive/Docs/Sheets/Slides WASM packages with host-mediated HTTP egress and product-auth scoped bearer injection; live OAuth/setup UX and full live-recorded parity remain follow-up | | Hosted MCP extensions | ✅ | 🚧 | Reborn composes host-mediated MCP runtime and bundles the current Notion MCP supported tool set; Notion ProductAuth OAuth exchange/refresh backend is wired, while dynamic schema discovery and user-start/DCR OAuth setup parity remain pending | | NEAR AI MCP extension | ✅ | 🚧 | Host-bundled Reborn MCP extension exposes `nearai.search` via host-mediated HTTP and `llm_nearai_api_key`; this is a static NEAR adapter, while the generic product-auth-to-MCP staged credential bridge remains tracked by #4176 and dynamic MCP tool discovery remains pending | | Tool policies (allow/deny) | ✅ | ✅ | | diff --git a/crates/ironclaw_auth/src/oauth.rs b/crates/ironclaw_auth/src/oauth.rs index 1fe4ad19bac..12afa374701 100644 --- a/crates/ironclaw_auth/src/oauth.rs +++ b/crates/ironclaw_auth/src/oauth.rs @@ -32,6 +32,24 @@ pub const GOOGLE_CALENDAR_READONLY_SCOPE: &str = "https://www.googleapis.com/auth/calendar.readonly"; /// Read/write access to Google Calendar events. pub const GOOGLE_CALENDAR_EVENTS_SCOPE: &str = "https://www.googleapis.com/auth/calendar.events"; +/// Read-only access to Google Drive files. +pub const GOOGLE_DRIVE_READONLY_SCOPE: &str = "https://www.googleapis.com/auth/drive.readonly"; +/// Read/write access to Google Drive files. +pub const GOOGLE_DRIVE_SCOPE: &str = "https://www.googleapis.com/auth/drive"; +/// Read-only access to Google Docs documents. +pub const GOOGLE_DOCS_READONLY_SCOPE: &str = "https://www.googleapis.com/auth/documents.readonly"; +/// Read/write access to Google Docs documents. +pub const GOOGLE_DOCS_SCOPE: &str = "https://www.googleapis.com/auth/documents"; +/// Read-only access to Google Sheets spreadsheets. +pub const GOOGLE_SHEETS_READONLY_SCOPE: &str = + "https://www.googleapis.com/auth/spreadsheets.readonly"; +/// Read/write access to Google Sheets spreadsheets. +pub const GOOGLE_SHEETS_SCOPE: &str = "https://www.googleapis.com/auth/spreadsheets"; +/// Read-only access to Google Slides presentations. +pub const GOOGLE_SLIDES_READONLY_SCOPE: &str = + "https://www.googleapis.com/auth/presentations.readonly"; +/// Read/write access to Google Slides presentations. +pub const GOOGLE_SLIDES_SCOPE: &str = "https://www.googleapis.com/auth/presentations"; /// Read-only access to Gmail messages and metadata. pub const GOOGLE_GMAIL_READONLY_SCOPE: &str = "https://www.googleapis.com/auth/gmail.readonly"; /// Permission to send Gmail messages. @@ -586,6 +604,14 @@ pub fn is_allowed_google_scope(scope: &str) -> bool { scope, GOOGLE_CALENDAR_READONLY_SCOPE | GOOGLE_CALENDAR_EVENTS_SCOPE + | GOOGLE_DRIVE_READONLY_SCOPE + | GOOGLE_DRIVE_SCOPE + | GOOGLE_DOCS_READONLY_SCOPE + | GOOGLE_DOCS_SCOPE + | GOOGLE_SHEETS_READONLY_SCOPE + | GOOGLE_SHEETS_SCOPE + | GOOGLE_SLIDES_READONLY_SCOPE + | GOOGLE_SLIDES_SCOPE | GOOGLE_GMAIL_READONLY_SCOPE | GOOGLE_GMAIL_SEND_SCOPE | GOOGLE_GMAIL_MODIFY_SCOPE @@ -690,4 +716,21 @@ mod tests { assert!(OAuthRedirectUri::new("http://localhost:8080/callback").is_ok()); assert!(OAuthRedirectUri::new("http://127.0.0.1:8080/callback").is_ok()); } + + #[test] + fn google_oauth_allowlist_includes_gsuite_wasm_scopes() { + for scope in [ + GOOGLE_DRIVE_READONLY_SCOPE, + GOOGLE_DRIVE_SCOPE, + GOOGLE_DOCS_READONLY_SCOPE, + GOOGLE_DOCS_SCOPE, + GOOGLE_SHEETS_READONLY_SCOPE, + GOOGLE_SHEETS_SCOPE, + GOOGLE_SLIDES_READONLY_SCOPE, + GOOGLE_SLIDES_SCOPE, + ] { + assert!(is_allowed_google_scope(scope), "{scope} must be allowed"); + assert!(parse_google_requested_scopes(&[scope.to_string()]).is_ok()); + } + } } diff --git a/crates/ironclaw_authorization/src/lib.rs b/crates/ironclaw_authorization/src/lib.rs index 43080d137f3..f015c24dcdc 100644 --- a/crates/ironclaw_authorization/src/lib.rs +++ b/crates/ironclaw_authorization/src/lib.rs @@ -1178,6 +1178,7 @@ fn obligations_for_grant( obligations.push(Obligation::InjectCredentialAccountOnce { handle: credential.handle.clone(), provider: provider.clone(), + provider_scopes: credential.provider_scopes.clone(), requester_extension: descriptor.provider.clone(), }); } diff --git a/crates/ironclaw_authorization/tests/runtime_credentials_contract.rs b/crates/ironclaw_authorization/tests/runtime_credentials_contract.rs index 0540db81d72..6d857a16b5c 100644 --- a/crates/ironclaw_authorization/tests/runtime_credentials_contract.rs +++ b/crates/ironclaw_authorization/tests/runtime_credentials_contract.rs @@ -181,6 +181,7 @@ async fn capability_access_resolves_product_auth_account_runtime_credentials() { source: RuntimeCredentialRequirementSource::ProductAuthAccount { provider: RuntimeCredentialAccountProviderId::new("github").unwrap(), }, + provider_scopes: vec!["repo".to_string()], ..runtime_credential(slot.clone(), github_audience(), true) }], ..wasm_descriptor() @@ -209,6 +210,7 @@ async fn capability_access_resolves_product_auth_account_runtime_credentials() { &[Obligation::InjectCredentialAccountOnce { handle: slot, provider: RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: vec!["repo".to_string()], requester_extension: ExtensionId::new("echo").unwrap(), }] ); @@ -258,6 +260,7 @@ fn runtime_credential( RuntimeCredentialRequirement { handle, source: Default::default(), + provider_scopes: Vec::new(), audience, target: RuntimeCredentialTarget::Header { name: "authorization".to_string(), diff --git a/crates/ironclaw_extensions/src/v2.rs b/crates/ironclaw_extensions/src/v2.rs index 7dd3ab430ef..ffe7a732861 100644 --- a/crates/ironclaw_extensions/src/v2.rs +++ b/crates/ironclaw_extensions/src/v2.rs @@ -869,9 +869,15 @@ impl CapabilityDeclV2 { ), })?; validate_runtime_credential_audience(&id, &raw_credential.audience)?; + let provider_scopes = validate_runtime_credential_provider_scopes( + &id, + &raw_credential.source, + raw_credential.provider_scopes, + )?; runtime_credentials.push(RuntimeCredentialRequirement { handle, source: raw_credential.source, + provider_scopes, audience: raw_credential.audience, target: raw_credential.target, required: raw_credential.required, @@ -1431,6 +1437,8 @@ struct RawRuntimeCredentialV2 { handle: String, #[serde(default)] source: RuntimeCredentialRequirementSource, + #[serde(default)] + provider_scopes: Vec, audience: NetworkTargetPattern, target: RuntimeCredentialTarget, #[serde(default = "default_runtime_credential_required")] @@ -1440,3 +1448,96 @@ struct RawRuntimeCredentialV2 { fn default_runtime_credential_required() -> bool { true } + +fn validate_runtime_credential_provider_scopes( + capability_id: &CapabilityId, + source: &RuntimeCredentialRequirementSource, + raw_scopes: Vec, +) -> Result, ManifestV2Error> { + if !raw_scopes.is_empty() + && !matches!( + source, + RuntimeCredentialRequirementSource::ProductAuthAccount { .. } + ) + { + return Err(ManifestV2Error::Invalid { + reason: format!( + "capability {capability_id} declares runtime credential provider scopes for a non product-auth credential source" + ), + }); + } + let mut seen = BTreeSet::new(); + let mut scopes = Vec::with_capacity(raw_scopes.len()); + for raw_scope in raw_scopes { + if raw_scope.trim() != raw_scope || raw_scope.is_empty() { + return Err(ManifestV2Error::Invalid { + reason: format!( + "capability {capability_id} declares invalid runtime credential provider scope" + ), + }); + } + if !seen.insert(raw_scope.clone()) { + return Err(ManifestV2Error::Invalid { + reason: format!( + "capability {capability_id} declares duplicate runtime credential provider scope {raw_scope}" + ), + }); + } + scopes.push(raw_scope); + } + Ok(scopes) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn capability_id() -> CapabilityId { + CapabilityId::new("acme.echo").unwrap() + } + + fn product_auth_source() -> RuntimeCredentialRequirementSource { + RuntimeCredentialRequirementSource::ProductAuthAccount { + provider: ironclaw_host_api::RuntimeCredentialAccountProviderId::new("google").unwrap(), + } + } + + #[test] + fn validate_runtime_credential_provider_scopes_rejects_empty_scope() { + let err = validate_runtime_credential_provider_scopes( + &capability_id(), + &product_auth_source(), + vec!["".to_string()], + ) + .unwrap_err(); + + assert!(matches!(err, ManifestV2Error::Invalid { .. }), "{err:?}"); + } + + #[test] + fn validate_runtime_credential_provider_scopes_rejects_whitespace_padded_scope() { + let err = validate_runtime_credential_provider_scopes( + &capability_id(), + &product_auth_source(), + vec![" https://www.googleapis.com/auth/drive".to_string()], + ) + .unwrap_err(); + + assert!(matches!(err, ManifestV2Error::Invalid { .. }), "{err:?}"); + } + + #[test] + fn validate_runtime_credential_provider_scopes_rejects_duplicate_scope() { + let err = validate_runtime_credential_provider_scopes( + &capability_id(), + &product_auth_source(), + vec![ + "https://www.googleapis.com/auth/drive".to_string(), + "https://www.googleapis.com/auth/drive".to_string(), + ], + ) + .unwrap_err(); + + assert!(matches!(err, ManifestV2Error::Invalid { .. }), "{err:?}"); + } +} diff --git a/crates/ironclaw_extensions/tests/manifest_v2_contract.rs b/crates/ironclaw_extensions/tests/manifest_v2_contract.rs index 2b27a0997e4..a05060fd8a2 100644 --- a/crates/ironclaw_extensions/tests/manifest_v2_contract.rs +++ b/crates/ironclaw_extensions/tests/manifest_v2_contract.rs @@ -135,6 +135,74 @@ default_permission = "allow""#, ); } +#[test] +fn parses_product_auth_account_runtime_credential_provider_scopes() { + let toml = third_party_wasm_manifest("acme-tools", "acme-tools.echo").replace( + r#"default_permission = "allow""#, + r#"effects = ["network", "use_secret"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/drive.readonly"], audience = { scheme = "https", host_pattern = "www.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "allow""#, + ); + let manifest = + ExtensionManifestV2::parse(&toml, ManifestSource::InstalledLocal, &catalog()).unwrap(); + + assert_eq!( + manifest.capabilities[0].runtime_credentials[0].provider_scopes, + vec!["https://www.googleapis.com/auth/drive.readonly".to_string()] + ); +} + +#[test] +fn rejects_invalid_runtime_credential_provider_scopes() { + for provider_scopes in [ + r#"["https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/drive"]"#, + r#"[""]"#, + r#"[" https://www.googleapis.com/auth/drive"]"#, + r#"["https://www.googleapis.com/auth/drive "]"#, + ] { + let toml = third_party_wasm_manifest("acme-tools", "acme-tools.echo").replace( + r#"default_permission = "allow""#, + &format!( + r#"effects = ["network", "use_secret"] +runtime_credentials = [ + {{ handle = "google_runtime_token", source = {{ type = "product_auth_account", provider = "google" }}, provider_scopes = {provider_scopes}, audience = {{ scheme = "https", host_pattern = "www.googleapis.com" }}, target = {{ type = "header", name = "authorization", prefix = "Bearer " }} }}, +] +default_permission = "allow""# + ), + ); + + let err = ExtensionManifestV2::parse(&toml, ManifestSource::InstalledLocal, &catalog()) + .unwrap_err(); + assert!(matches!(err, ManifestV2Error::Invalid { .. }), "{err:?}"); + assert!( + err.to_string().contains("provider scope"), + "expected provider scope validation error, got {err:?}" + ); + } +} + +#[test] +fn rejects_provider_scopes_for_non_product_auth_runtime_credentials() { + let toml = third_party_wasm_manifest("acme-tools", "acme-tools.echo").replace( + r#"default_permission = "allow""#, + r#"effects = ["network", "use_secret"] +runtime_credentials = [ + { handle = "api_token", provider_scopes = ["https://www.googleapis.com/auth/drive"], audience = { scheme = "https", host_pattern = "api.example.com" }, target = { type = "header", name = "authorization" } }, +] +default_permission = "allow""#, + ); + let err = + ExtensionManifestV2::parse(&toml, ManifestSource::InstalledLocal, &catalog()).unwrap_err(); + + assert!(matches!(err, ManifestV2Error::Invalid { .. }), "{err:?}"); + assert!( + err.to_string().contains("non product-auth"), + "expected non product-auth provider scope rejection, got {err:?}" + ); +} + #[test] fn rejects_runtime_credentials_without_use_secret_effect() { let toml = third_party_wasm_manifest("acme-tools", "acme-tools.echo").replace( diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/manifest.toml b/crates/ironclaw_first_party_extensions/assets/google-docs/manifest.toml new file mode 100644 index 00000000000..26f65d3959b --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/manifest.toml @@ -0,0 +1,164 @@ +schema_version = "reborn.extension_manifest.v2" +id = "google-docs" +name = "Google Docs" +version = "0.1.0" +description = "Google Docs capabilities for creating, reading, editing, and formatting documents." +trust = "first_party_requested" + +[runtime] +kind = "wasm" +module = "wasm/google_docs_tool.wasm" + +[[capabilities]] +id = "google-docs.create_document" +description = "Create a new Google Docs document." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/documents"], audience = { scheme = "https", host_pattern = "docs.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-docs/create_document.input.v1.json" +output_schema_ref = "schemas/google-docs/raw_output.v1.json" +prompt_doc_ref = "prompts/google-docs/create_document.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-docs.get_document" +description = "Get document metadata and structure." +effects = ["dispatch_capability", "network", "use_secret"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/documents.readonly"], audience = { scheme = "https", host_pattern = "docs.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-docs/get_document.input.v1.json" +output_schema_ref = "schemas/google-docs/raw_output.v1.json" +prompt_doc_ref = "prompts/google-docs/get_document.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-docs.read_content" +description = "Read the document body as plain text." +effects = ["dispatch_capability", "network", "use_secret"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/documents.readonly"], audience = { scheme = "https", host_pattern = "docs.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-docs/read_content.input.v1.json" +output_schema_ref = "schemas/google-docs/raw_output.v1.json" +prompt_doc_ref = "prompts/google-docs/read_content.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-docs.insert_text" +description = "Insert text at a document position." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/documents"], audience = { scheme = "https", host_pattern = "docs.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-docs/insert_text.input.v1.json" +output_schema_ref = "schemas/google-docs/raw_output.v1.json" +prompt_doc_ref = "prompts/google-docs/insert_text.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-docs.delete_content" +description = "Delete document content in a range." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/documents"], audience = { scheme = "https", host_pattern = "docs.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-docs/delete_content.input.v1.json" +output_schema_ref = "schemas/google-docs/raw_output.v1.json" +prompt_doc_ref = "prompts/google-docs/delete_content.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-docs.replace_text" +description = "Find and replace text." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/documents"], audience = { scheme = "https", host_pattern = "docs.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-docs/replace_text.input.v1.json" +output_schema_ref = "schemas/google-docs/raw_output.v1.json" +prompt_doc_ref = "prompts/google-docs/replace_text.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-docs.format_text" +description = "Format text in a range." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/documents"], audience = { scheme = "https", host_pattern = "docs.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-docs/format_text.input.v1.json" +output_schema_ref = "schemas/google-docs/raw_output.v1.json" +prompt_doc_ref = "prompts/google-docs/format_text.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-docs.format_paragraph" +description = "Set paragraph style." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/documents"], audience = { scheme = "https", host_pattern = "docs.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-docs/format_paragraph.input.v1.json" +output_schema_ref = "schemas/google-docs/raw_output.v1.json" +prompt_doc_ref = "prompts/google-docs/format_paragraph.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-docs.insert_table" +description = "Insert a table." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/documents"], audience = { scheme = "https", host_pattern = "docs.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-docs/insert_table.input.v1.json" +output_schema_ref = "schemas/google-docs/raw_output.v1.json" +prompt_doc_ref = "prompts/google-docs/insert_table.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-docs.create_list" +description = "Create a bulleted or numbered list." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/documents"], audience = { scheme = "https", host_pattern = "docs.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-docs/create_list.input.v1.json" +output_schema_ref = "schemas/google-docs/raw_output.v1.json" +prompt_doc_ref = "prompts/google-docs/create_list.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-docs.batch_update" +description = "Execute raw Docs API batchUpdate requests." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/documents"], audience = { scheme = "https", host_pattern = "docs.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-docs/batch_update.input.v1.json" +output_schema_ref = "schemas/google-docs/raw_output.v1.json" +prompt_doc_ref = "prompts/google-docs/batch_update.md" +required_host_ports = ["host.runtime.http_egress"] diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/batch_update.md b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/batch_update.md new file mode 100644 index 00000000000..f11c3dbcc4b --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/batch_update.md @@ -0,0 +1,3 @@ +Execute raw Docs API batchUpdate requests. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/create_document.md b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/create_document.md new file mode 100644 index 00000000000..8bdf86e3a0c --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/create_document.md @@ -0,0 +1,3 @@ +Create a new Google Docs document. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/create_list.md b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/create_list.md new file mode 100644 index 00000000000..908547fe703 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/create_list.md @@ -0,0 +1,3 @@ +Create a bulleted or numbered list. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/delete_content.md b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/delete_content.md new file mode 100644 index 00000000000..2c1582a9aa7 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/delete_content.md @@ -0,0 +1,3 @@ +Delete document content in a range. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/format_paragraph.md b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/format_paragraph.md new file mode 100644 index 00000000000..a9d6164fa5e --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/format_paragraph.md @@ -0,0 +1,3 @@ +Set paragraph style. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/format_text.md b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/format_text.md new file mode 100644 index 00000000000..b4f43a02405 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/format_text.md @@ -0,0 +1,3 @@ +Format text in a range. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/get_document.md b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/get_document.md new file mode 100644 index 00000000000..0acd22418dd --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/get_document.md @@ -0,0 +1,3 @@ +Get document metadata and structure. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/insert_table.md b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/insert_table.md new file mode 100644 index 00000000000..808b2548a0f --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/insert_table.md @@ -0,0 +1,3 @@ +Insert a table. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/insert_text.md b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/insert_text.md new file mode 100644 index 00000000000..089502eaeee --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/insert_text.md @@ -0,0 +1,3 @@ +Insert text at a document position. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/read_content.md b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/read_content.md new file mode 100644 index 00000000000..b8256dcd765 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/read_content.md @@ -0,0 +1,3 @@ +Read the document body as plain text. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/replace_text.md b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/replace_text.md new file mode 100644 index 00000000000..280266951d6 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/prompts/google-docs/replace_text.md @@ -0,0 +1,3 @@ +Find and replace text. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/batch_update.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/batch_update.input.v1.json new file mode 100644 index 00000000000..71b3e25ecd6 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/batch_update.input.v1.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Docs batch_update", + "description": "Execute raw Docs API batchUpdate requests.", + "type": "object", + "required": [ + "document_id", + "requests" + ], + "properties": { + "document_id": { + "type": "string", + "description": "The document ID." + }, + "requests": { + "type": "array", + "items": { + "description": "Raw Docs API request object." + }, + "description": "Docs API request objects." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/create_document.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/create_document.input.v1.json new file mode 100644 index 00000000000..7c3cb0df7cc --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/create_document.input.v1.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Docs create_document", + "description": "Create a new Google Docs document.", + "type": "object", + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "description": "Document title." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/create_list.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/create_list.input.v1.json new file mode 100644 index 00000000000..27904f60142 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/create_list.input.v1.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Docs create_list", + "description": "Create a bulleted or numbered list.", + "type": "object", + "required": [ + "document_id", + "start_index", + "end_index" + ], + "properties": { + "document_id": { + "type": "string", + "description": "The document ID." + }, + "start_index": { + "type": "integer", + "description": "Start index inclusive." + }, + "end_index": { + "type": "integer", + "description": "End index exclusive." + }, + "bullet_preset": { + "type": "string", + "description": "Bullet preset." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/delete_content.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/delete_content.input.v1.json new file mode 100644 index 00000000000..5e5f818b68d --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/delete_content.input.v1.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Docs delete_content", + "description": "Delete document content in a range.", + "type": "object", + "required": [ + "document_id", + "start_index", + "end_index" + ], + "properties": { + "document_id": { + "type": "string", + "description": "The document ID." + }, + "start_index": { + "type": "integer", + "description": "Start index inclusive." + }, + "end_index": { + "type": "integer", + "description": "End index exclusive." + }, + "segment_id": { + "type": "string", + "description": "Segment ID. Defaults to body." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/format_paragraph.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/format_paragraph.input.v1.json new file mode 100644 index 00000000000..b45d50eda63 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/format_paragraph.input.v1.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Docs format_paragraph", + "description": "Set paragraph style.", + "type": "object", + "required": [ + "document_id", + "start_index", + "end_index" + ], + "properties": { + "document_id": { + "type": "string", + "description": "The document ID." + }, + "start_index": { + "type": "integer", + "description": "Start index inclusive." + }, + "end_index": { + "type": "integer", + "description": "End index exclusive." + }, + "named_style": { + "type": [ + "string", + "null" + ], + "description": "Named paragraph style." + }, + "alignment": { + "type": [ + "string", + "null" + ], + "description": "Paragraph alignment." + }, + "line_spacing": { + "type": [ + "number", + "null" + ], + "description": "Line spacing percentage." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/format_text.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/format_text.input.v1.json new file mode 100644 index 00000000000..a2076b3ea82 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/format_text.input.v1.json @@ -0,0 +1,82 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Docs format_text", + "description": "Format text in a range.", + "type": "object", + "required": [ + "document_id", + "start_index", + "end_index" + ], + "properties": { + "document_id": { + "type": "string", + "description": "The document ID." + }, + "start_index": { + "type": "integer", + "description": "Start index inclusive." + }, + "end_index": { + "type": "integer", + "description": "End index exclusive." + }, + "bold": { + "type": [ + "boolean", + "null" + ], + "description": "Make text bold." + }, + "italic": { + "type": [ + "boolean", + "null" + ], + "description": "Make text italic." + }, + "underline": { + "type": [ + "boolean", + "null" + ], + "description": "Underline text." + }, + "strikethrough": { + "type": [ + "boolean", + "null" + ], + "description": "Strikethrough text." + }, + "font_size": { + "type": [ + "number", + "null" + ], + "description": "Font size in points." + }, + "font_family": { + "type": [ + "string", + "null" + ], + "description": "Font family." + }, + "foreground_color": { + "type": [ + "string", + "null" + ], + "description": "Text color hex." + }, + "background_color": { + "type": [ + "string", + "null" + ], + "description": "Background color hex." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/get_document.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/get_document.input.v1.json new file mode 100644 index 00000000000..4264b80990a --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/get_document.input.v1.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Docs get_document", + "description": "Get document metadata and structure.", + "type": "object", + "required": [ + "document_id" + ], + "properties": { + "document_id": { + "type": "string", + "description": "The document ID." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/insert_table.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/insert_table.input.v1.json new file mode 100644 index 00000000000..2bf1f555915 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/insert_table.input.v1.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Docs insert_table", + "description": "Insert a table.", + "type": "object", + "required": [ + "document_id", + "rows", + "columns", + "index" + ], + "properties": { + "document_id": { + "type": "string", + "description": "The document ID." + }, + "rows": { + "type": "integer", + "description": "Number of rows." + }, + "columns": { + "type": "integer", + "description": "Number of columns." + }, + "index": { + "type": "integer", + "description": "Character index." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/insert_text.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/insert_text.input.v1.json new file mode 100644 index 00000000000..9504aaeefc1 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/insert_text.input.v1.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Docs insert_text", + "description": "Insert text at a document position.", + "type": "object", + "required": [ + "document_id", + "text" + ], + "properties": { + "document_id": { + "type": "string", + "description": "The document ID." + }, + "text": { + "type": "string", + "description": "Text to insert." + }, + "index": { + "type": "integer", + "description": "Character index. Defaults to -1 to append." + }, + "segment_id": { + "type": "string", + "description": "Segment ID. Defaults to body." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/raw_output.v1.json b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/raw_output.v1.json new file mode 100644 index 00000000000..df8935f40c7 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/raw_output.v1.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Raw Google API Output", + "description": "Google API response serialized by the WASM tool.", + "type": "object", + "additionalProperties": true +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/read_content.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/read_content.input.v1.json new file mode 100644 index 00000000000..cf801bfdd57 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/read_content.input.v1.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Docs read_content", + "description": "Read the document body as plain text.", + "type": "object", + "required": [ + "document_id" + ], + "properties": { + "document_id": { + "type": "string", + "description": "The document ID." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/replace_text.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/replace_text.input.v1.json new file mode 100644 index 00000000000..ff927c08248 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/schemas/google-docs/replace_text.input.v1.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Docs replace_text", + "description": "Find and replace text.", + "type": "object", + "required": [ + "document_id", + "find", + "replace" + ], + "properties": { + "document_id": { + "type": "string", + "description": "The document ID." + }, + "find": { + "type": "string", + "description": "Text to search for." + }, + "replace": { + "type": "string", + "description": "Replacement text." + }, + "match_case": { + "type": "boolean", + "description": "Case-sensitive match. Defaults to true." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/wasm-src/Cargo.toml b/crates/ironclaw_first_party_extensions/assets/google-docs/wasm-src/Cargo.toml new file mode 100644 index 00000000000..64ca25edf2e --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/wasm-src/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "google-docs-tool" +version = "0.2.0" +edition = "2021" +description = "Google Docs integration tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +urlencoding = "2" +# Auto-derives JSON Schema from the GoogleDocsAction tagged enum so the +# advertised schema mirrors the serde-enforced contract. +schemars = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 + +[workspace] diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/wasm-src/src/api.rs b/crates/ironclaw_first_party_extensions/assets/google-docs/wasm-src/src/api.rs new file mode 100644 index 00000000000..76aaaa71808 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/wasm-src/src/api.rs @@ -0,0 +1,570 @@ +//! Google Docs API v1 implementation. +//! +//! All API calls go through the host's HTTP capability, which handles +//! credential injection and rate limiting. The WASM tool never sees +//! the actual OAuth token. + +use crate::near::agent::host; +use crate::types::*; + +const DOCS_API_BASE: &str = "https://docs.googleapis.com/v1/documents"; + +/// Make a Google Docs API call. +fn api_call(method: &str, path: &str, body: Option<&str>) -> Result { + let url = if path.is_empty() { + DOCS_API_BASE.to_string() + } else { + format!("{}/{}", DOCS_API_BASE, path) + }; + + let headers = if body.is_some() { + r#"{"Content-Type": "application/json"}"# + } else { + "{}" + }; + + let body_bytes = body.map(|b| b.as_bytes().to_vec()); + + host::log( + host::LogLevel::Debug, + &format!("Google Docs API: {} {}", method, url), + ); + + let response = host::http_request(method, &url, headers, body_bytes.as_deref(), None)?; + + if response.status < 200 || response.status >= 300 { + let body_text = String::from_utf8_lossy(&response.body); + return Err(format!( + "Google Docs API returned status {}: {}", + response.status, body_text + )); + } + + if response.body.is_empty() { + return Ok(String::new()); + } + + String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 in response: {}", e)) +} + +/// Send a batchUpdate to the document and return the parsed response. +fn batch_update_raw( + document_id: &str, + requests: Vec, +) -> Result { + let path = format!("{}:batchUpdate", url_encode(document_id)); + + let body = serde_json::json!({ "requests": requests }); + let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; + + let response = api_call("POST", &path, Some(&body_str))?; + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e)) +} + +/// Extract revision ID from a batchUpdate response. +fn extract_revision_id(parsed: &serde_json::Value) -> String { + parsed["writeControl"]["requiredRevisionId"] + .as_str() + .unwrap_or("") + .to_string() +} + +/// Create a new document. +pub fn create_document(title: &str) -> Result { + let body = serde_json::json!({ "title": title }); + let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; + + let response = api_call("POST", "", Some(&body_str))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(CreateDocumentResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + title: parsed["title"].as_str().unwrap_or("").to_string(), + }) +} + +/// Get document metadata. +pub fn get_document(document_id: &str) -> Result { + let path = url_encode(document_id); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + // Calculate body length from the last element's endIndex + let body_length = parsed["body"]["content"] + .as_array() + .and_then(|arr| arr.last()) + .and_then(|el| el["endIndex"].as_i64()) + .unwrap_or(1); + + // Extract named ranges + let mut named_ranges = Vec::new(); + if let Some(nr_map) = parsed["namedRanges"].as_object() { + for (_name, nr_group) in nr_map { + if let Some(ranges) = nr_group["namedRanges"].as_array() { + for nr in ranges { + let name = nr["name"].as_str().unwrap_or("").to_string(); + let id = nr["namedRangeId"].as_str().unwrap_or("").to_string(); + if let Some(range_list) = nr["ranges"].as_array() { + for range in range_list { + named_ranges.push(DocumentNamedRange { + name: name.clone(), + named_range_id: id.clone(), + start_index: range["startIndex"].as_i64().unwrap_or(0), + end_index: range["endIndex"].as_i64().unwrap_or(0), + }); + } + } + } + } + } + } + + Ok(DocumentMetadata { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + title: parsed["title"].as_str().unwrap_or("").to_string(), + revision_id: parsed["revisionId"].as_str().unwrap_or("").to_string(), + body_length, + named_ranges, + }) +} + +/// Read the document body as plain text by walking the structural elements. +pub fn read_content(document_id: &str) -> Result { + let path = url_encode(document_id); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let mut text = String::new(); + if let Some(content) = parsed["body"]["content"].as_array() { + extract_text_from_elements(content, &mut text); + } + + Ok(ReadContentResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + title: parsed["title"].as_str().unwrap_or("").to_string(), + content: text, + }) +} + +/// Recursively extract plain text from structural elements. +fn extract_text_from_elements(elements: &[serde_json::Value], out: &mut String) { + for el in elements { + // Paragraph + if let Some(para) = el.get("paragraph") { + if let Some(para_elements) = para["elements"].as_array() { + for pe in para_elements { + if let Some(text_run) = pe.get("textRun") { + if let Some(content) = text_run["content"].as_str() { + out.push_str(content); + } + } + } + } + } + // Table: recurse into cells + if let Some(table) = el.get("table") { + if let Some(rows) = table["tableRows"].as_array() { + for row in rows { + if let Some(cells) = row["tableCells"].as_array() { + for cell in cells { + if let Some(cell_content) = cell["content"].as_array() { + extract_text_from_elements(cell_content, out); + } + } + } + } + } + } + if let Some(table_of_contents) = el.get("tableOfContents") { + if let Some(content) = table_of_contents["content"].as_array() { + extract_text_from_elements(content, out); + } + } + } +} + +/// Insert text at a position. +pub fn insert_text( + document_id: &str, + text: &str, + index: i64, + segment_id: &str, +) -> Result { + let request = if index == -1 { + // Append at end of segment + let mut loc = serde_json::json!({}); + if !segment_id.is_empty() { + loc["segmentId"] = serde_json::Value::String(segment_id.to_string()); + } + serde_json::json!({ + "insertText": { + "text": text, + "endOfSegmentLocation": loc, + } + }) + } else if index < 0 { + return Err(format!( + "invalid index {index}: only -1 (append) or non-negative indexes are accepted" + )); + } else { + let mut loc = serde_json::json!({ "index": index }); + if !segment_id.is_empty() { + loc["segmentId"] = serde_json::Value::String(segment_id.to_string()); + } + serde_json::json!({ + "insertText": { + "text": text, + "location": loc, + } + }) + }; + + let parsed = batch_update_raw(document_id, vec![request])?; + + Ok(UpdateResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + revision_id: extract_revision_id(&parsed), + }) +} + +/// Delete content in a range. +pub fn delete_content( + document_id: &str, + start_index: i64, + end_index: i64, + segment_id: &str, +) -> Result { + let mut range = serde_json::json!({ + "startIndex": start_index, + "endIndex": end_index, + }); + if !segment_id.is_empty() { + range["segmentId"] = serde_json::Value::String(segment_id.to_string()); + } + + let request = serde_json::json!({ + "deleteContentRange": { "range": range } + }); + + let parsed = batch_update_raw(document_id, vec![request])?; + + Ok(UpdateResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + revision_id: extract_revision_id(&parsed), + }) +} + +/// Find and replace all occurrences of text. +pub fn replace_text( + document_id: &str, + find: &str, + replace: &str, + match_case: bool, +) -> Result { + let request = serde_json::json!({ + "replaceAllText": { + "containsText": { + "text": find, + "matchCase": match_case, + }, + "replaceText": replace, + } + }); + + let parsed = batch_update_raw(document_id, vec![request])?; + + let first_reply = parsed["replies"].as_array().and_then(|arr| arr.first()); + let occurrences = first_reply + .map(|r| { + r["replaceAllText"]["occurrencesChanged"] + .as_i64() + .unwrap_or(0) + }) + .unwrap_or(0); + + Ok(ReplaceResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + revision_id: extract_revision_id(&parsed), + occurrences_changed: occurrences, + }) +} + +/// Parse a hex color like "#FF0000" into Docs API color format. +fn parse_hex_color(hex: &str) -> Option { + let hex = hex.strip_prefix('#').unwrap_or(hex); + if hex.len() != 6 { + return None; + } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(serde_json::json!({ + "color": { + "rgbColor": { + "red": r as f64 / 255.0, + "green": g as f64 / 255.0, + "blue": b as f64 / 255.0, + } + } + })) +} + +/// Parameters for text formatting. +pub struct FormatTextOptions<'a> { + pub document_id: &'a str, + pub start_index: i64, + pub end_index: i64, + pub bold: Option, + pub italic: Option, + pub underline: Option, + pub strikethrough: Option, + pub font_size: Option, + pub font_family: Option<&'a str>, + pub foreground_color: Option<&'a str>, + pub background_color: Option<&'a str>, +} + +/// Format text in a range. +pub fn format_text(opts: FormatTextOptions<'_>) -> Result { + let mut style = serde_json::json!({}); + let mut fields = Vec::new(); + + if let Some(b) = opts.bold { + style["bold"] = serde_json::Value::Bool(b); + fields.push("bold"); + } + if let Some(i) = opts.italic { + style["italic"] = serde_json::Value::Bool(i); + fields.push("italic"); + } + if let Some(u) = opts.underline { + style["underline"] = serde_json::Value::Bool(u); + fields.push("underline"); + } + if let Some(s) = opts.strikethrough { + style["strikethrough"] = serde_json::Value::Bool(s); + fields.push("strikethrough"); + } + if let Some(size) = opts.font_size { + style["fontSize"] = serde_json::json!({ "magnitude": size, "unit": "PT" }); + fields.push("fontSize"); + } + if let Some(family) = opts.font_family { + style["weightedFontFamily"] = serde_json::json!({ "fontFamily": family }); + fields.push("weightedFontFamily"); + } + if let Some(color) = opts.foreground_color { + let Some(c) = parse_hex_color(color) else { + return Err(format!("invalid foreground_color hex: {color}")); + }; + style["foregroundColor"] = c; + fields.push("foregroundColor"); + } + if let Some(color) = opts.background_color { + let Some(c) = parse_hex_color(color) else { + return Err(format!("invalid background_color hex: {color}")); + }; + style["backgroundColor"] = c; + fields.push("backgroundColor"); + } + + if fields.is_empty() { + return Err("No formatting options specified".to_string()); + } + + let request = serde_json::json!({ + "updateTextStyle": { + "range": { + "startIndex": opts.start_index, + "endIndex": opts.end_index, + }, + "textStyle": style, + "fields": fields.join(","), + } + }); + + let parsed = batch_update_raw(opts.document_id, vec![request])?; + + Ok(UpdateResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + revision_id: extract_revision_id(&parsed), + }) +} + +/// Format paragraph style. +pub fn format_paragraph( + document_id: &str, + start_index: i64, + end_index: i64, + named_style: Option<&str>, + alignment: Option<&str>, + line_spacing: Option, +) -> Result { + let mut para_style = serde_json::json!({}); + let mut fields = Vec::new(); + + if let Some(style) = named_style { + para_style["namedStyleType"] = serde_json::Value::String(style.to_string()); + fields.push("namedStyleType"); + } + if let Some(align) = alignment { + para_style["alignment"] = serde_json::Value::String(align.to_string()); + fields.push("alignment"); + } + if let Some(spacing) = line_spacing { + para_style["lineSpacing"] = serde_json::json!(spacing); + fields.push("lineSpacing"); + } + + if fields.is_empty() { + return Err("No paragraph style options specified".to_string()); + } + + let request = serde_json::json!({ + "updateParagraphStyle": { + "range": { + "startIndex": start_index, + "endIndex": end_index, + }, + "paragraphStyle": para_style, + "fields": fields.join(","), + } + }); + + let parsed = batch_update_raw(document_id, vec![request])?; + + Ok(UpdateResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + revision_id: extract_revision_id(&parsed), + }) +} + +/// Insert a table at a position. +pub fn insert_table( + document_id: &str, + rows: i64, + columns: i64, + index: i64, +) -> Result { + let request = serde_json::json!({ + "insertTable": { + "rows": rows, + "columns": columns, + "location": { "index": index }, + } + }); + + let parsed = batch_update_raw(document_id, vec![request])?; + + Ok(UpdateResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + revision_id: extract_revision_id(&parsed), + }) +} + +/// Create a bulleted or numbered list from paragraphs in a range. +pub fn create_list( + document_id: &str, + start_index: i64, + end_index: i64, + bullet_preset: &str, +) -> Result { + let request = serde_json::json!({ + "createParagraphBullets": { + "range": { + "startIndex": start_index, + "endIndex": end_index, + }, + "bulletPreset": bullet_preset, + } + }); + + let parsed = batch_update_raw(document_id, vec![request])?; + + Ok(UpdateResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + revision_id: extract_revision_id(&parsed), + }) +} + +/// Execute a raw batch update with arbitrary requests. +pub fn batch_update( + document_id: &str, + requests: Vec, +) -> Result { + let parsed = batch_update_raw(document_id, requests)?; + + let replies = parsed["replies"] + .as_array() + .map(|arr| arr.to_vec()) + .unwrap_or_default(); + + Ok(BatchUpdateResult { + document_id: parsed["documentId"].as_str().unwrap_or("").to_string(), + revision_id: extract_revision_id(&parsed), + replies, + }) +} + +fn url_encode(s: &str) -> String { + urlencoding::encode(s).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_text_from_elements_includes_table_of_contents_content() { + let elements = vec![serde_json::json!({ + "tableOfContents": { + "content": [ + { + "paragraph": { + "elements": [ + { "textRun": { "content": "Heading 1\n" } } + ] + } + } + ] + } + })]; + let mut text = String::new(); + + extract_text_from_elements(&elements, &mut text); + + assert_eq!(text, "Heading 1\n"); + } + + #[test] + fn insert_text_rejects_negative_indexes_other_than_append() { + let err = insert_text("doc-1", "text", -2, "").unwrap_err(); + + assert!(err.contains("invalid index -2"), "{err}"); + } + + #[test] + fn format_text_rejects_invalid_hex_colors() { + let err = format_text(FormatTextOptions { + document_id: "doc-1", + start_index: 1, + end_index: 2, + bold: None, + italic: None, + underline: None, + strikethrough: None, + font_size: None, + font_family: None, + foreground_color: Some("#GG0000"), + background_color: None, + }) + .unwrap_err(); + + assert_eq!(err, "invalid foreground_color hex: #GG0000"); + } +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/wasm-src/src/lib.rs b/crates/ironclaw_first_party_extensions/assets/google-docs/wasm-src/src/lib.rs new file mode 100644 index 00000000000..99aa6ff9a94 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/wasm-src/src/lib.rs @@ -0,0 +1,283 @@ +//! Google Docs WASM Tool for IronClaw. +//! +//! Provides Google Docs integration for creating, reading, editing, +//! and formatting documents. Use Google Drive tool to search for +//! existing documents by name. +//! +//! # Capabilities Required +//! +//! - HTTP: `docs.googleapis.com/v1/documents*` +//! - Credentials: staged Google product-auth account token injected by the host. +//! +//! # Supported Actions +//! +//! - `create_document`: Create a new blank document +//! - `get_document`: Get document metadata (title, length, named ranges) +//! - `read_content`: Read entire document body as plain text +//! - `insert_text`: Insert text at a position (or append at end) +//! - `delete_content`: Delete text in a range +//! - `replace_text`: Find and replace all occurrences +//! - `format_text`: Format text (bold, italic, font, color, size) +//! - `format_paragraph`: Set heading level, alignment, spacing +//! - `insert_table`: Insert a table at a position +//! - `create_list`: Create bulleted/numbered list from paragraphs +//! - `batch_update`: Execute multiple raw Docs API operations atomically +//! +//! # Tips +//! +//! - Document IDs are the same as Google Drive file IDs. Use google-drive +//! tool's list_files to find documents. +//! - Indexes are 0-based character offsets. An empty document body starts +//! with a newline at index 0, so insert at index 1 to prepend text. +//! - Use index -1 to append at the end of the document. +//! - When doing multiple edits, process from highest index to lowest +//! to avoid index shifting issues. +//! +//! # Example Usage +//! +//! ```json +//! {"action": "create_document", "title": "Meeting Notes"} +//! {"action": "read_content", "document_id": "abc123"} +//! {"action": "insert_text", "document_id": "abc123", "text": "Hello World\n", "index": 1} +//! {"action": "replace_text", "document_id": "abc123", "find": "Hello", "replace": "Hi"} +//! {"action": "format_text", "document_id": "abc123", "start_index": 1, "end_index": 12, "bold": true, "font_size": 18} +//! {"action": "format_paragraph", "document_id": "abc123", "start_index": 1, "end_index": 12, "named_style": "HEADING_1"} +//! ``` + +mod api; +mod types; + +use types::{GoogleDocsAction, ToolContext}; + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../../../../wit/tool.wit", +}); + +struct GoogleDocsTool; + +impl exports::near::agent::tool::Guest for GoogleDocsTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params, req.context.as_deref()) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + // Derived from `GoogleDocsAction` via `schemars::JsonSchema` so the + // advertised schema can never drift from the serde contract. + let schema = schemars::schema_for!(types::GoogleDocsAction); + serde_json::to_string(&schema).unwrap_or_else(|_| "{}".to_string()) + } + + fn description() -> String { + "Google Docs integration for creating, reading, editing, and formatting documents. \ + Supports text operations (insert, delete, find-replace), text formatting (bold, italic, \ + font, color, size), paragraph styling (headings, alignment, spacing), tables, and \ + bulleted/numbered lists. Also provides a batch_update action for complex multi-step \ + edits executed atomically. Document IDs are the same as Google Drive file IDs, so use \ + the google-drive tool to search for existing documents. The host injects a Google \ + product-auth credential with the documents scope. \ + To discover all available API operations, use http GET to fetch \ + (public, no auth needed)." + .to_string() + } +} + +fn execute_inner(params: &str, context: Option<&str>) -> Result { + let action_name = action_from_context(context)?; + let params = params_with_action(params, action_name)?; + let action: GoogleDocsAction = + serde_json::from_value(params).map_err(|e| format!("Invalid parameters: {}", e))?; + + crate::near::agent::host::log( + crate::near::agent::host::LogLevel::Debug, + &format!("Executing Google Docs action: {action_name}"), + ); + + let result = match action { + GoogleDocsAction::CreateDocument { title } => { + let result = api::create_document(&title)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::GetDocument { document_id } => { + let result = api::get_document(&document_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::ReadContent { document_id } => { + let result = api::read_content(&document_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::InsertText { + document_id, + text, + index, + segment_id, + } => { + let result = api::insert_text(&document_id, &text, index, &segment_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::DeleteContent { + document_id, + start_index, + end_index, + segment_id, + } => { + let result = api::delete_content(&document_id, start_index, end_index, &segment_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::ReplaceText { + document_id, + find, + replace, + match_case, + } => { + let result = api::replace_text(&document_id, &find, &replace, match_case)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::FormatText { + document_id, + start_index, + end_index, + bold, + italic, + underline, + strikethrough, + font_size, + font_family, + foreground_color, + background_color, + } => { + let result = api::format_text(api::FormatTextOptions { + document_id: &document_id, + start_index, + end_index, + bold, + italic, + underline, + strikethrough, + font_size, + font_family: font_family.as_deref(), + foreground_color: foreground_color.as_deref(), + background_color: background_color.as_deref(), + })?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::FormatParagraph { + document_id, + start_index, + end_index, + named_style, + alignment, + line_spacing, + } => { + let result = api::format_paragraph( + &document_id, + start_index, + end_index, + named_style.as_deref(), + alignment.as_deref(), + line_spacing, + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::InsertTable { + document_id, + rows, + columns, + index, + } => { + let result = api::insert_table(&document_id, rows, columns, index)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::CreateList { + document_id, + start_index, + end_index, + bullet_preset, + } => { + let result = api::create_list(&document_id, start_index, end_index, &bullet_preset)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDocsAction::BatchUpdate { + document_id, + requests, + } => { + let result = api::batch_update(&document_id, requests)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + }; + + Ok(result) +} + +fn action_from_context(context: Option<&str>) -> Result<&'static str, String> { + let context = context.ok_or_else(|| "missing_invocation_context".to_string())?; + let context: ToolContext = + serde_json::from_str(context).map_err(|_| "invalid_invocation_context".to_string())?; + match context.capability_id.as_str() { + "google-docs.create_document" => Ok("create_document"), + "google-docs.get_document" => Ok("get_document"), + "google-docs.read_content" => Ok("read_content"), + "google-docs.insert_text" => Ok("insert_text"), + "google-docs.delete_content" => Ok("delete_content"), + "google-docs.replace_text" => Ok("replace_text"), + "google-docs.format_text" => Ok("format_text"), + "google-docs.format_paragraph" => Ok("format_paragraph"), + "google-docs.insert_table" => Ok("insert_table"), + "google-docs.create_list" => Ok("create_list"), + "google-docs.batch_update" => Ok("batch_update"), + _ => Err("unsupported_google_docs_capability".to_string()), + } +} + +fn params_with_action(params: &str, action: &str) -> Result { + let mut params: serde_json::Value = if params.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(params).map_err(|_| "invalid_parameters".to_string())? + }; + let obj = params + .as_object_mut() + .ok_or_else(|| "invalid_parameters".to_string())?; + if obj.contains_key("action") { + return Err("invalid_parameters".to_string()); + } + obj.insert( + "action".to_string(), + serde_json::Value::String(action.to_string()), + ); + Ok(params) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn params_with_action_rejects_caller_supplied_action() { + let result = + params_with_action(r#"{"action":"delete_all","document_id":"doc-1"}"#, "get_document"); + + assert_eq!(result, Err("invalid_parameters".to_string())); + } +} + +export!(GoogleDocsTool); diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/wasm-src/src/types.rs b/crates/ironclaw_first_party_extensions/assets/google-docs/wasm-src/src/types.rs new file mode 100644 index 00000000000..f28198bab10 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-docs/wasm-src/src/types.rs @@ -0,0 +1,236 @@ +//! Types for Google Docs API requests and responses. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub(crate) struct ToolContext { + pub(crate) capability_id: String, +} + +/// Input parameters for the Google Docs tool. +/// +/// `JsonSchema` is derived so the advertised tool schema mirrors the +/// serde-enforced contract: each variant becomes a `oneOf` entry with +/// its own `required` array. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum GoogleDocsAction { + /// Create a new document. + CreateDocument { + /// Document title. + title: String, + }, + + /// Get document metadata and structure (title, body text, named ranges). + GetDocument { + /// The document ID (same as Google Drive file ID). + document_id: String, + }, + + /// Read the document body as plain text. + ReadContent { + /// The document ID. + document_id: String, + }, + + /// Insert text at a position. + InsertText { + /// The document ID. + document_id: String, + /// Text to insert. + text: String, + /// Character index to insert at (1-based, since 0 is before the body). + /// Use -1 to append at end. + #[serde(default = "default_insert_index")] + index: i64, + /// Segment ID ("" for body, or a header/footer ID). + #[serde(default)] + segment_id: String, + }, + + /// Delete content in a range. + DeleteContent { + /// The document ID. + document_id: String, + /// Start index (inclusive). + start_index: i64, + /// End index (exclusive). + end_index: i64, + /// Segment ID ("" for body). + #[serde(default)] + segment_id: String, + }, + + /// Find and replace all occurrences of text. + ReplaceText { + /// The document ID. + document_id: String, + /// Text to search for. + find: String, + /// Replacement text. + replace: String, + /// Case-sensitive match (default: true). + #[serde(default = "default_true")] + match_case: bool, + }, + + /// Format text in a range (bold, italic, font size, color, etc.). + FormatText { + /// The document ID. + document_id: String, + /// Start index (inclusive). + start_index: i64, + /// End index (exclusive). + end_index: i64, + /// Make text bold. + #[serde(default)] + bold: Option, + /// Make text italic. + #[serde(default)] + italic: Option, + /// Underline text. + #[serde(default)] + underline: Option, + /// Strikethrough text. + #[serde(default)] + strikethrough: Option, + /// Font size in points. + #[serde(default)] + font_size: Option, + /// Font family name (e.g., "Arial", "Times New Roman"). + #[serde(default)] + font_family: Option, + /// Text color as hex (e.g., "#FF0000"). + #[serde(default)] + foreground_color: Option, + /// Text background color as hex. + #[serde(default)] + background_color: Option, + }, + + /// Set paragraph style (heading level, alignment, spacing). + FormatParagraph { + /// The document ID. + document_id: String, + /// Start index (inclusive). + start_index: i64, + /// End index (exclusive). + end_index: i64, + /// Named style: "NORMAL_TEXT", "TITLE", "SUBTITLE", "HEADING_1" through "HEADING_6". + #[serde(default)] + named_style: Option, + /// Alignment: "START", "CENTER", "END", "JUSTIFIED". + #[serde(default)] + alignment: Option, + /// Line spacing as percentage (e.g., 115 for 1.15x). + #[serde(default)] + line_spacing: Option, + }, + + /// Insert a table at a position. + InsertTable { + /// The document ID. + document_id: String, + /// Number of rows. + rows: i64, + /// Number of columns. + columns: i64, + /// Character index to insert at. + index: i64, + }, + + /// Create a bulleted or numbered list from a range of paragraphs. + CreateList { + /// The document ID. + document_id: String, + /// Start index (inclusive). + start_index: i64, + /// End index (exclusive). + end_index: i64, + /// Bullet preset. Bulleted: "BULLET_DISC_CIRCLE_SQUARE" (default). + /// Numbered: "NUMBERED_DECIMAL_ALPHA_ROMAN". + #[serde(default = "default_bullet_preset")] + bullet_preset: String, + }, + + /// Execute multiple operations in a single atomic batch. + /// Each operation is an object with one key (the request type name) + /// and a value matching the Docs API batchUpdate request format. + BatchUpdate { + /// The document ID. + document_id: String, + /// Array of raw request objects as per Google Docs API. + requests: Vec, + }, +} + +fn default_insert_index() -> i64 { + -1 +} + +fn default_true() -> bool { + true +} + +fn default_bullet_preset() -> String { + "BULLET_DISC_CIRCLE_SQUARE".to_string() +} + +/// Result from create_document. +#[derive(Debug, Serialize)] +pub struct CreateDocumentResult { + pub document_id: String, + pub title: String, +} + +/// Result from get_document. +#[derive(Debug, Serialize)] +pub struct DocumentMetadata { + pub document_id: String, + pub title: String, + pub revision_id: String, + pub body_length: i64, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub named_ranges: Vec, +} + +/// Named range within a document. +#[derive(Debug, Serialize)] +pub struct DocumentNamedRange { + pub name: String, + pub named_range_id: String, + pub start_index: i64, + pub end_index: i64, +} + +/// Result from read_content. +#[derive(Debug, Serialize)] +pub struct ReadContentResult { + pub document_id: String, + pub title: String, + pub content: String, +} + +/// Result from insert_text, delete_content, replace_text. +#[derive(Debug, Serialize)] +pub struct UpdateResult { + pub document_id: String, + pub revision_id: String, +} + +/// Result from replace_text with occurrence count. +#[derive(Debug, Serialize)] +pub struct ReplaceResult { + pub document_id: String, + pub revision_id: String, + pub occurrences_changed: i64, +} + +/// Result from batch_update. +#[derive(Debug, Serialize)] +pub struct BatchUpdateResult { + pub document_id: String, + pub revision_id: String, + pub replies: Vec, +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-docs/wasm/google_docs_tool.wasm b/crates/ironclaw_first_party_extensions/assets/google-docs/wasm/google_docs_tool.wasm new file mode 100644 index 00000000000..9909160eed1 Binary files /dev/null and b/crates/ironclaw_first_party_extensions/assets/google-docs/wasm/google_docs_tool.wasm differ diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/manifest.toml b/crates/ironclaw_first_party_extensions/assets/google-drive/manifest.toml new file mode 100644 index 00000000000..29a114bd50c --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/manifest.toml @@ -0,0 +1,178 @@ +schema_version = "reborn.extension_manifest.v2" +id = "google-drive" +name = "Google Drive" +version = "0.1.0" +description = "Google Drive capabilities for searching, accessing, uploading, sharing, and organizing files and folders." +trust = "first_party_requested" + +[runtime] +kind = "wasm" +module = "wasm/google_drive_tool.wasm" + +[[capabilities]] +id = "google-drive.list_files" +description = "Search or list files and folders." +effects = ["dispatch_capability", "network", "use_secret"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/drive.readonly"], audience = { scheme = "https", host_pattern = "www.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-drive/list_files.input.v1.json" +output_schema_ref = "schemas/google-drive/raw_output.v1.json" +prompt_doc_ref = "prompts/google-drive/list_files.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-drive.get_file" +description = "Get file metadata." +effects = ["dispatch_capability", "network", "use_secret"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/drive.readonly"], audience = { scheme = "https", host_pattern = "www.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-drive/get_file.input.v1.json" +output_schema_ref = "schemas/google-drive/raw_output.v1.json" +prompt_doc_ref = "prompts/google-drive/get_file.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-drive.download_file" +description = "Download file content as text or export a Google Workspace file." +effects = ["dispatch_capability", "network", "use_secret"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/drive.readonly"], audience = { scheme = "https", host_pattern = "www.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-drive/download_file.input.v1.json" +output_schema_ref = "schemas/google-drive/raw_output.v1.json" +prompt_doc_ref = "prompts/google-drive/download_file.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-drive.upload_file" +description = "Upload a new text file." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/drive"], audience = { scheme = "https", host_pattern = "www.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-drive/upload_file.input.v1.json" +output_schema_ref = "schemas/google-drive/raw_output.v1.json" +prompt_doc_ref = "prompts/google-drive/upload_file.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-drive.update_file" +description = "Update file metadata or move/star a file." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/drive"], audience = { scheme = "https", host_pattern = "www.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-drive/update_file.input.v1.json" +output_schema_ref = "schemas/google-drive/raw_output.v1.json" +prompt_doc_ref = "prompts/google-drive/update_file.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-drive.create_folder" +description = "Create a folder." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/drive"], audience = { scheme = "https", host_pattern = "www.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-drive/create_folder.input.v1.json" +output_schema_ref = "schemas/google-drive/raw_output.v1.json" +prompt_doc_ref = "prompts/google-drive/create_folder.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-drive.delete_file" +description = "Permanently delete a file or folder." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/drive"], audience = { scheme = "https", host_pattern = "www.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-drive/delete_file.input.v1.json" +output_schema_ref = "schemas/google-drive/raw_output.v1.json" +prompt_doc_ref = "prompts/google-drive/delete_file.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-drive.trash_file" +description = "Move a file or folder to trash." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/drive"], audience = { scheme = "https", host_pattern = "www.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-drive/trash_file.input.v1.json" +output_schema_ref = "schemas/google-drive/raw_output.v1.json" +prompt_doc_ref = "prompts/google-drive/trash_file.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-drive.share_file" +description = "Share a file or folder with someone." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/drive"], audience = { scheme = "https", host_pattern = "www.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-drive/share_file.input.v1.json" +output_schema_ref = "schemas/google-drive/raw_output.v1.json" +prompt_doc_ref = "prompts/google-drive/share_file.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-drive.list_permissions" +description = "List file permissions." +effects = ["dispatch_capability", "network", "use_secret"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/drive.readonly"], audience = { scheme = "https", host_pattern = "www.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-drive/list_permissions.input.v1.json" +output_schema_ref = "schemas/google-drive/raw_output.v1.json" +prompt_doc_ref = "prompts/google-drive/list_permissions.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-drive.remove_permission" +description = "Revoke a file permission." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/drive"], audience = { scheme = "https", host_pattern = "www.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-drive/remove_permission.input.v1.json" +output_schema_ref = "schemas/google-drive/raw_output.v1.json" +prompt_doc_ref = "prompts/google-drive/remove_permission.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-drive.list_shared_drives" +description = "List shared drives." +effects = ["dispatch_capability", "network", "use_secret"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/drive.readonly"], audience = { scheme = "https", host_pattern = "www.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-drive/list_shared_drives.input.v1.json" +output_schema_ref = "schemas/google-drive/raw_output.v1.json" +prompt_doc_ref = "prompts/google-drive/list_shared_drives.md" +required_host_ports = ["host.runtime.http_egress"] diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/create_folder.md b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/create_folder.md new file mode 100644 index 00000000000..9e14e384767 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/create_folder.md @@ -0,0 +1,3 @@ +Create a folder. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/delete_file.md b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/delete_file.md new file mode 100644 index 00000000000..8c3e96665a6 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/delete_file.md @@ -0,0 +1,3 @@ +Permanently delete a file or folder. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/download_file.md b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/download_file.md new file mode 100644 index 00000000000..db9192c5ccb --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/download_file.md @@ -0,0 +1,3 @@ +Download file content as text or export a Google Workspace file. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/get_file.md b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/get_file.md new file mode 100644 index 00000000000..2bbda952d47 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/get_file.md @@ -0,0 +1,3 @@ +Get file metadata. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/list_files.md b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/list_files.md new file mode 100644 index 00000000000..f1166b356c7 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/list_files.md @@ -0,0 +1,3 @@ +Search or list files and folders. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/list_permissions.md b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/list_permissions.md new file mode 100644 index 00000000000..f279d07b97d --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/list_permissions.md @@ -0,0 +1,3 @@ +List file permissions. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/list_shared_drives.md b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/list_shared_drives.md new file mode 100644 index 00000000000..c0f75397589 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/list_shared_drives.md @@ -0,0 +1,3 @@ +List shared drives. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/remove_permission.md b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/remove_permission.md new file mode 100644 index 00000000000..567b0214634 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/remove_permission.md @@ -0,0 +1,3 @@ +Revoke a file permission. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/share_file.md b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/share_file.md new file mode 100644 index 00000000000..b38baeceed7 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/share_file.md @@ -0,0 +1,3 @@ +Share a file or folder with someone. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/trash_file.md b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/trash_file.md new file mode 100644 index 00000000000..b95098d6841 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/trash_file.md @@ -0,0 +1,3 @@ +Move a file or folder to trash. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/update_file.md b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/update_file.md new file mode 100644 index 00000000000..cea27209b01 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/update_file.md @@ -0,0 +1,3 @@ +Update file metadata or move/star a file. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/upload_file.md b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/upload_file.md new file mode 100644 index 00000000000..fd7388dc0e4 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/prompts/google-drive/upload_file.md @@ -0,0 +1,3 @@ +Upload a new text file. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/create_folder.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/create_folder.input.v1.json new file mode 100644 index 00000000000..8ea67999ea2 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/create_folder.input.v1.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Drive create_folder", + "description": "Create a folder.", + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Folder name." + }, + "parent_id": { + "type": [ + "string", + "null" + ], + "description": "Parent folder ID." + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "Folder description." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/delete_file.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/delete_file.input.v1.json new file mode 100644 index 00000000000..7c7314bf7f2 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/delete_file.input.v1.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Drive delete_file", + "description": "Permanently delete a file or folder.", + "type": "object", + "required": [ + "file_id" + ], + "properties": { + "file_id": { + "type": "string", + "description": "The file ID to delete." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/download_file.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/download_file.input.v1.json new file mode 100644 index 00000000000..a874e625f3e --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/download_file.input.v1.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Drive download_file", + "description": "Download file content as text or export a Google Workspace file.", + "type": "object", + "required": [ + "file_id" + ], + "properties": { + "file_id": { + "type": "string", + "description": "The file ID." + }, + "export_mime_type": { + "type": [ + "string", + "null" + ], + "description": "Export MIME type for Google Workspace files." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/get_file.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/get_file.input.v1.json new file mode 100644 index 00000000000..e1c7f1f48f2 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/get_file.input.v1.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Drive get_file", + "description": "Get file metadata.", + "type": "object", + "required": [ + "file_id" + ], + "properties": { + "file_id": { + "type": "string", + "description": "The file ID." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/list_files.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/list_files.input.v1.json new file mode 100644 index 00000000000..3050794c2f9 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/list_files.input.v1.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Drive list_files", + "description": "Search or list files and folders.", + "type": "object", + "required": [], + "properties": { + "query": { + "type": [ + "string", + "null" + ], + "description": "Drive search query." + }, + "page_size": { + "type": "integer", + "description": "Maximum number of results. Defaults to 25." + }, + "order_by": { + "type": [ + "string", + "null" + ], + "description": "Sort order, such as modifiedTime desc or name." + }, + "corpora": { + "type": "string", + "description": "Search corpus. Defaults to user." + }, + "drive_id": { + "type": [ + "string", + "null" + ], + "description": "Shared drive ID when corpora is drive." + }, + "page_token": { + "type": [ + "string", + "null" + ], + "description": "Page token for pagination." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/list_permissions.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/list_permissions.input.v1.json new file mode 100644 index 00000000000..fe24089b3b0 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/list_permissions.input.v1.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Drive list_permissions", + "description": "List file permissions.", + "type": "object", + "required": [ + "file_id" + ], + "properties": { + "file_id": { + "type": "string", + "description": "The file ID." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/list_shared_drives.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/list_shared_drives.input.v1.json new file mode 100644 index 00000000000..676a9aa173f --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/list_shared_drives.input.v1.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Drive list_shared_drives", + "description": "List shared drives.", + "type": "object", + "required": [], + "properties": { + "page_size": { + "type": "integer", + "description": "Maximum number of results. Defaults to 25." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/raw_output.v1.json b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/raw_output.v1.json new file mode 100644 index 00000000000..df8935f40c7 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/raw_output.v1.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Raw Google API Output", + "description": "Google API response serialized by the WASM tool.", + "type": "object", + "additionalProperties": true +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/remove_permission.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/remove_permission.input.v1.json new file mode 100644 index 00000000000..084e39255b6 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/remove_permission.input.v1.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Drive remove_permission", + "description": "Revoke a file permission.", + "type": "object", + "required": [ + "file_id", + "permission_id" + ], + "properties": { + "file_id": { + "type": "string", + "description": "The file ID." + }, + "permission_id": { + "type": "string", + "description": "The permission ID to remove." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/share_file.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/share_file.input.v1.json new file mode 100644 index 00000000000..1449e8d3187 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/share_file.input.v1.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Drive share_file", + "description": "Share a file or folder with someone.", + "type": "object", + "required": [ + "file_id", + "email" + ], + "properties": { + "file_id": { + "type": "string", + "description": "The file ID to share." + }, + "email": { + "type": "string", + "description": "Recipient email address." + }, + "role": { + "type": "string", + "description": "Permission role. Defaults to reader." + }, + "message": { + "type": [ + "string", + "null" + ], + "description": "Optional sharing notification message." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/trash_file.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/trash_file.input.v1.json new file mode 100644 index 00000000000..76d0f0e05f0 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/trash_file.input.v1.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Drive trash_file", + "description": "Move a file or folder to trash.", + "type": "object", + "required": [ + "file_id" + ], + "properties": { + "file_id": { + "type": "string", + "description": "The file ID to trash." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/update_file.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/update_file.input.v1.json new file mode 100644 index 00000000000..118ee9e8419 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/update_file.input.v1.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Drive update_file", + "description": "Update file metadata or move/star a file.", + "type": "object", + "required": [ + "file_id" + ], + "properties": { + "file_id": { + "type": "string", + "description": "The file ID." + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "New file name." + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "New description." + }, + "move_to_parent": { + "type": [ + "string", + "null" + ], + "description": "Parent folder ID to move into." + }, + "starred": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to star the file." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/upload_file.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/upload_file.input.v1.json new file mode 100644 index 00000000000..6e5c5aaf5d8 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/schemas/google-drive/upload_file.input.v1.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Drive upload_file", + "description": "Upload a new text file.", + "type": "object", + "required": [ + "name", + "content" + ], + "properties": { + "name": { + "type": "string", + "description": "File name." + }, + "content": { + "type": "string", + "description": "File content." + }, + "mime_type": { + "type": "string", + "description": "MIME type. Defaults to text/plain." + }, + "parent_id": { + "type": [ + "string", + "null" + ], + "description": "Parent folder ID." + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "File description." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/wasm-src/Cargo.toml b/crates/ironclaw_first_party_extensions/assets/google-drive/wasm-src/Cargo.toml new file mode 100644 index 00000000000..19d34db532a --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/wasm-src/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "google-drive-tool" +version = "0.2.0" +edition = "2021" +description = "Google Drive integration tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +urlencoding = "2" +# Auto-derives the JSON Schema from the GoogleDriveAction tagged enum so the +# advertised schema can never drift from the serde-enforced contract. Without +# this we'd hand-write a flat schema that tells the LLM "all fields optional" +# while serde rejects calls that omit per-variant required fields. +schemars = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 + +[workspace] diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/wasm-src/src/api.rs b/crates/ironclaw_first_party_extensions/assets/google-drive/wasm-src/src/api.rs new file mode 100644 index 00000000000..0299d9a1c25 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/wasm-src/src/api.rs @@ -0,0 +1,553 @@ +//! Google Drive API v3 implementation. +//! +//! All API calls go through the host's HTTP capability, which handles +//! credential injection and rate limiting. The WASM tool never sees +//! the actual OAuth token. + +use crate::near::agent::host; +use crate::types::*; + +const DRIVE_API_BASE: &str = "https://www.googleapis.com/drive/v3"; +const UPLOAD_API_BASE: &str = "https://www.googleapis.com/upload/drive/v3"; +const MAX_DOWNLOAD_TEXT_BYTES: usize = 1_000_000; + +/// Standard fields to request for file metadata. +const FILE_FIELDS: &str = "id,name,mimeType,description,size,createdTime,modifiedTime,\ + webViewLink,parents,shared,starred,trashed,ownedByMe,driveId,\ + owners(emailAddress,displayName)"; + +/// Make a Drive API call. +fn api_call(method: &str, path: &str, body: Option<&str>) -> Result { + let url = format!("{}/{}", DRIVE_API_BASE, path); + + let headers = if body.is_some() { + r#"{"Content-Type": "application/json"}"# + } else { + "{}" + }; + + let body_bytes = body.map(|b| b.as_bytes().to_vec()); + + host::log( + host::LogLevel::Debug, + &format!("Drive API: {} {}", method, path), + ); + + let response = host::http_request(method, &url, headers, body_bytes.as_deref(), None)?; + + if response.status < 200 || response.status >= 300 { + let body_text = String::from_utf8_lossy(&response.body); + return Err(format!( + "Drive API returned status {}: {}", + response.status, body_text + )); + } + + if response.body.is_empty() { + return Ok(String::new()); + } + + String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 in response: {}", e)) +} + +/// Make a raw API call that returns bytes (for file downloads). +fn api_call_raw(method: &str, url: &str) -> Result, String> { + host::log( + host::LogLevel::Debug, + &format!("Drive API raw: {} {}", method, url), + ); + + let response = host::http_request(method, url, "{}", None, None)?; + + if response.status < 200 || response.status >= 300 { + let body_text = String::from_utf8_lossy(&response.body); + return Err(format!( + "Drive API returned status {}: {}", + response.status, body_text + )); + } + + Ok(response.body) +} + +/// Parse a file resource from the API response. +fn parse_file(v: &serde_json::Value) -> DriveFile { + let mime_type = v["mimeType"].as_str().unwrap_or("").to_string(); + DriveFile { + id: v["id"].as_str().unwrap_or("").to_string(), + name: v["name"].as_str().unwrap_or("").to_string(), + is_folder: mime_type == "application/vnd.google-apps.folder", + mime_type, + description: v["description"].as_str().map(|s| s.to_string()), + size: v["size"].as_str().map(|s| s.to_string()), + created_time: v["createdTime"].as_str().map(|s| s.to_string()), + modified_time: v["modifiedTime"].as_str().map(|s| s.to_string()), + web_view_link: v["webViewLink"].as_str().map(|s| s.to_string()), + parents: v["parents"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|p| p.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(), + shared: v["shared"].as_bool().unwrap_or(false), + starred: v["starred"].as_bool().unwrap_or(false), + trashed: v["trashed"].as_bool().unwrap_or(false), + owned_by_me: v["ownedByMe"].as_bool().unwrap_or(false), + drive_id: v["driveId"].as_str().map(|s| s.to_string()), + owners: v["owners"] + .as_array() + .map(|arr| { + arr.iter() + .map(|o| Owner { + email: o["emailAddress"].as_str().unwrap_or("").to_string(), + display_name: o["displayName"].as_str().map(|s| s.to_string()), + }) + .collect() + }) + .unwrap_or_default(), + } +} + +/// List/search files. +pub fn list_files( + query: Option<&str>, + page_size: u32, + order_by: Option<&str>, + corpora: &str, + drive_id: Option<&str>, + page_token: Option<&str>, +) -> Result { + let mut params = vec![ + format!("pageSize={}", page_size), + format!("fields=nextPageToken,files({})", FILE_FIELDS), + format!("corpora={}", corpora), + "supportsAllDrives=true".to_string(), + "includeItemsFromAllDrives=true".to_string(), + ]; + + if let Some(q) = query { + params.push(format!("q={}", url_encode(q))); + } + if let Some(ob) = order_by { + params.push(format!("orderBy={}", url_encode(ob))); + } + if let Some(did) = drive_id { + params.push(format!("driveId={}", url_encode(did))); + } + if let Some(pt) = page_token { + params.push(format!("pageToken={}", url_encode(pt))); + } + + let path = format!("files?{}", params.join("&")); + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let files = parsed["files"] + .as_array() + .map(|arr| arr.iter().map(parse_file).collect()) + .unwrap_or_default(); + + Ok(ListFilesResult { + files, + next_page_token: parsed["nextPageToken"].as_str().map(|s| s.to_string()), + }) +} + +/// Get file metadata. +pub fn get_file(file_id: &str) -> Result { + let path = format!( + "files/{}?fields={}&supportsAllDrives=true", + url_encode(file_id), + FILE_FIELDS + ); + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(FileResult { + file: parse_file(&parsed), + }) +} + +/// Download file content as text. +pub fn download_file( + file_id: &str, + export_mime_type: Option<&str>, +) -> Result { + // First get metadata to know the file type and name + let meta = get_file(file_id)?; + let mime = &meta.file.mime_type; + + let bytes = if mime.starts_with("application/vnd.google-apps.") { + // Google Workspace file, must export + let export_type = export_mime_type.unwrap_or(match mime.as_str() { + "application/vnd.google-apps.document" => "text/plain", + "application/vnd.google-apps.spreadsheet" => "text/csv", + "application/vnd.google-apps.presentation" => "text/plain", + "application/vnd.google-apps.drawing" => "image/svg+xml", + _ => "text/plain", + }); + let url = format!( + "{}/files/{}/export?mimeType={}", + DRIVE_API_BASE, + url_encode(file_id), + url_encode(export_type) + ); + api_call_raw("GET", &url)? + } else { + // Regular file, download directly + ensure_declared_download_size(meta.file.size.as_deref(), file_id)?; + let url = format!("{}/files/{}?alt=media", DRIVE_API_BASE, url_encode(file_id)); + api_call_raw("GET", &url)? + }; + ensure_download_size(bytes.len(), file_id)?; + + let content = String::from_utf8(bytes).map_err(|_| { + "File content is binary, cannot display as text. Use get_file for metadata only." + .to_string() + })?; + + Ok(DownloadResult { + file_id: file_id.to_string(), + name: meta.file.name, + mime_type: meta.file.mime_type, + content, + }) +} + +fn ensure_declared_download_size(size: Option<&str>, file_id: &str) -> Result<(), String> { + let Some(size) = size else { + return Ok(()); + }; + let Ok(size) = size.parse::() else { + return Ok(()); + }; + ensure_download_size(size, file_id) +} + +fn ensure_download_size(size: usize, file_id: &str) -> Result<(), String> { + if size <= MAX_DOWNLOAD_TEXT_BYTES { + return Ok(()); + } + Err(format!( + "File {file_id} is too large to download into the WASM tool as text ({size} bytes; limit {MAX_DOWNLOAD_TEXT_BYTES} bytes). Use get_file for metadata or request a smaller export." + )) +} + +/// Upload a text file using multipart upload. +pub fn upload_file( + name: &str, + content: &str, + mime_type: &str, + parent_id: Option<&str>, + description: Option<&str>, +) -> Result { + let mut metadata = serde_json::json!({ + "name": name, + "mimeType": mime_type, + }); + if let Some(pid) = parent_id { + metadata["parents"] = serde_json::json!([pid]); + } + if let Some(desc) = description { + metadata["description"] = serde_json::Value::String(desc.to_string()); + } + + let metadata_str = serde_json::to_string(&metadata).map_err(|e| e.to_string())?; + let boundary = multipart_boundary(&metadata_str, content); + + // Build multipart body + let mut body = String::new(); + body.push_str(&format!("--{}\r\n", boundary)); + body.push_str("Content-Type: application/json; charset=UTF-8\r\n\r\n"); + body.push_str(&metadata_str); + body.push_str(&format!("\r\n--{}\r\n", boundary)); + body.push_str(&format!("Content-Type: {}\r\n\r\n", mime_type)); + body.push_str(content); + body.push_str(&format!("\r\n--{}--", boundary)); + + let url = format!( + "{}/files?uploadType=multipart&fields={}&supportsAllDrives=true", + UPLOAD_API_BASE, FILE_FIELDS + ); + let headers = format!( + r#"{{"Content-Type": "multipart/related; boundary={}"}}"#, + boundary + ); + + host::log( + host::LogLevel::Debug, + "Drive API: POST upload/files (multipart)", + ); + + let response = host::http_request("POST", &url, &headers, Some(body.as_bytes()), None)?; + + if response.status < 200 || response.status >= 300 { + let body_text = String::from_utf8_lossy(&response.body); + return Err(format!( + "Upload failed with status {}: {}", + response.status, body_text + )); + } + + let parsed: serde_json::Value = serde_json::from_str( + &String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8: {}", e))?, + ) + .map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(FileResult { + file: parse_file(&parsed), + }) +} + +fn multipart_boundary(metadata: &str, content: &str) -> String { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for b in metadata.bytes().chain(content.bytes()) { + hash ^= b as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + + let mut attempt = 0_u64; + loop { + let boundary = format!("ironclaw_upload_boundary_{hash:016x}_{attempt:016x}"); + if !metadata.contains(&boundary) && !content.contains(&boundary) { + return boundary; + } + attempt = attempt.wrapping_add(1); + } +} + +/// Update file metadata. +pub fn update_file( + file_id: &str, + name: Option<&str>, + description: Option<&str>, + move_to_parent: Option<&str>, + starred: Option, +) -> Result { + let mut patch = serde_json::json!({}); + + if let Some(n) = name { + patch["name"] = serde_json::Value::String(n.to_string()); + } + if let Some(d) = description { + patch["description"] = serde_json::Value::String(d.to_string()); + } + if let Some(s) = starred { + patch["starred"] = serde_json::Value::Bool(s); + } + + let mut params = vec![ + format!("fields={}", FILE_FIELDS), + "supportsAllDrives=true".to_string(), + ]; + + if let Some(new_parent) = move_to_parent { + // To move, we need to know current parents first + let current = get_file(file_id)?; + let remove_parents = current + .file + .parents + .iter() + .map(|p| p.as_str()) + .collect::>() + .join(","); + params.push(format!("addParents={}", url_encode(new_parent))); + if !remove_parents.is_empty() { + params.push(format!("removeParents={}", url_encode(&remove_parents))); + } + } + + let body = serde_json::to_string(&patch).map_err(|e| e.to_string())?; + let path = format!("files/{}?{}", url_encode(file_id), params.join("&")); + + let response = api_call("PATCH", &path, Some(&body))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(FileResult { + file: parse_file(&parsed), + }) +} + +/// Create a folder. +pub fn create_folder( + name: &str, + parent_id: Option<&str>, + description: Option<&str>, +) -> Result { + let mut metadata = serde_json::json!({ + "name": name, + "mimeType": "application/vnd.google-apps.folder", + }); + if let Some(pid) = parent_id { + metadata["parents"] = serde_json::json!([pid]); + } + if let Some(desc) = description { + metadata["description"] = serde_json::Value::String(desc.to_string()); + } + + let body = serde_json::to_string(&metadata).map_err(|e| e.to_string())?; + let path = format!("files?fields={}&supportsAllDrives=true", FILE_FIELDS); + + let response = api_call("POST", &path, Some(&body))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(FileResult { + file: parse_file(&parsed), + }) +} + +/// Delete a file permanently. +pub fn delete_file(file_id: &str) -> Result { + let path = format!("files/{}?supportsAllDrives=true", url_encode(file_id)); + api_call("DELETE", &path, None)?; + + Ok(DeleteResult { + file_id: file_id.to_string(), + deleted: true, + }) +} + +/// Move a file to trash. +pub fn trash_file(file_id: &str) -> Result { + let body = r#"{"trashed": true}"#; + let path = format!( + "files/{}?fields={}&supportsAllDrives=true", + url_encode(file_id), + FILE_FIELDS + ); + + api_call("PATCH", &path, Some(body))?; + + Ok(DeleteResult { + file_id: file_id.to_string(), + deleted: true, + }) +} + +/// Share a file with someone. +pub fn share_file( + file_id: &str, + email: &str, + role: &str, + message: Option<&str>, +) -> Result { + let permission = serde_json::json!({ + "type": "user", + "role": role, + "emailAddress": email, + }); + + let body = serde_json::to_string(&permission).map_err(|e| e.to_string())?; + + let mut path = format!( + "files/{}/permissions?supportsAllDrives=true", + url_encode(file_id) + ); + if let Some(msg) = message { + path.push_str(&format!("&emailMessage={}", url_encode(msg))); + } + + let response = api_call("POST", &path, Some(&body))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(ShareResult { + permission_id: parsed["id"].as_str().unwrap_or("").to_string(), + role: parsed["role"].as_str().unwrap_or(role).to_string(), + email: email.to_string(), + }) +} + +/// List permissions on a file. +pub fn list_permissions(file_id: &str) -> Result { + let path = format!( + "files/{}/permissions?fields=permissions(id,role,type,emailAddress,displayName)&supportsAllDrives=true", + url_encode(file_id) + ); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let permissions = parsed["permissions"] + .as_array() + .map(|arr| { + arr.iter() + .map(|p| Permission { + id: p["id"].as_str().unwrap_or("").to_string(), + role: p["role"].as_str().unwrap_or("").to_string(), + permission_type: p["type"].as_str().unwrap_or("").to_string(), + email_address: p["emailAddress"].as_str().map(|s| s.to_string()), + display_name: p["displayName"].as_str().map(|s| s.to_string()), + }) + .collect() + }) + .unwrap_or_default(); + + Ok(ListPermissionsResult { permissions }) +} + +/// Remove a sharing permission. +pub fn remove_permission(file_id: &str, permission_id: &str) -> Result { + let path = format!( + "files/{}/permissions/{}?supportsAllDrives=true", + url_encode(file_id), + url_encode(permission_id) + ); + + api_call("DELETE", &path, None)?; + + Ok(DeleteResult { + file_id: file_id.to_string(), + deleted: true, + }) +} + +/// List shared drives. +pub fn list_shared_drives(page_size: u32) -> Result { + let path = format!("drives?pageSize={}", page_size); + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let drives = parsed["drives"] + .as_array() + .map(|arr| { + arr.iter() + .map(|d| SharedDrive { + id: d["id"].as_str().unwrap_or("").to_string(), + name: d["name"].as_str().unwrap_or("").to_string(), + }) + .collect() + }) + .unwrap_or_default(); + + Ok(ListSharedDrivesResult { drives }) +} + +fn url_encode(s: &str) -> String { + urlencoding::encode(s).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn multipart_boundary_does_not_appear_in_metadata_or_content() { + let metadata = r#"{"name":"ironclaw_upload_boundary_marker"}"#; + let content = "body with ironclaw_upload_boundary_marker"; + + let boundary = multipart_boundary(metadata, content); + + assert!(!metadata.contains(&boundary)); + assert!(!content.contains(&boundary)); + assert!(boundary.starts_with("ironclaw_upload_boundary_")); + } +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/wasm-src/src/lib.rs b/crates/ironclaw_first_party_extensions/assets/google-drive/wasm-src/src/lib.rs new file mode 100644 index 00000000000..4dcf4977762 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/wasm-src/src/lib.rs @@ -0,0 +1,252 @@ +//! Google Drive WASM Tool for IronClaw. +//! +//! Provides Google Drive integration for searching, accessing, uploading, +//! sharing, and organizing files and folders. Supports both personal and +//! shared (organizational) drives. +//! +//! # Capabilities Required +//! +//! - HTTP: `www.googleapis.com/drive/v3/*` and `www.googleapis.com/upload/drive/v3/*` +//! - Credentials: staged Google product-auth account token injected by the host. +//! +//! # Supported Actions +//! +//! - `list_files`: Search/list files with Drive query syntax and corpora selection +//! - `get_file`: Get file metadata +//! - `download_file`: Download file content as text (exports Google Docs/Sheets) +//! - `upload_file`: Upload a text file (multipart) +//! - `update_file`: Rename, move, star, or update description +//! - `create_folder`: Create a new folder +//! - `delete_file`: Permanently delete a file +//! - `trash_file`: Move to trash +//! - `share_file`: Share with a user (reader, commenter, writer, organizer) +//! - `list_permissions`: See who has access +//! - `remove_permission`: Revoke access +//! - `list_shared_drives`: List organizational shared drives +//! +//! # Example Usage +//! +//! ```json +//! {"action": "list_files", "query": "name contains 'report' and mimeType = 'application/pdf'"} +//! {"action": "list_files", "corpora": "drive", "drive_id": "0ABcd...", "query": "trashed = false"} +//! {"action": "share_file", "file_id": "abc123", "email": "alice@company.com", "role": "writer"} +//! ``` + +mod api; +mod types; + +use types::{GoogleDriveAction, ToolContext}; + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../../../../wit/tool.wit", +}); + +struct GoogleDriveTool; + +impl exports::near::agent::tool::Guest for GoogleDriveTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params, req.context.as_deref()) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + // Derived from `GoogleDriveAction` via `schemars::JsonSchema` so the + // advertised schema can never drift from the serde contract. Each + // enum variant becomes a `oneOf` entry with its own `required` + // array — the agent sees that `file_id` is required when + // `action == "get_file"`, etc. + let schema = schemars::schema_for!(types::GoogleDriveAction); + serde_json::to_string(&schema).unwrap_or_else(|_| "{}".to_string()) + } + + fn description() -> String { + "Google Drive integration for searching, accessing, uploading, sharing, and organizing \ + files and folders. Supports personal drives and shared (organizational) drives via the \ + corpora parameter. Can search with Drive query syntax, download text files, upload new \ + files, manage folder structure, and control sharing permissions. The host injects a \ + Google product-auth credential with the Drive scope. \ + To discover all available API operations, use http GET to fetch \ + (public, no auth needed)." + .to_string() + } +} + +fn execute_inner(params: &str, context: Option<&str>) -> Result { + let action_name = action_from_context(context)?; + let params = params_with_action(params, action_name)?; + let action: GoogleDriveAction = + serde_json::from_value(params).map_err(|e| format!("Invalid parameters: {}", e))?; + + crate::near::agent::host::log( + crate::near::agent::host::LogLevel::Debug, + &format!("Executing Google Drive action: {action_name}"), + ); + + let result = match action { + GoogleDriveAction::ListFiles { + query, + page_size, + order_by, + corpora, + drive_id, + page_token, + } => { + let result = api::list_files( + query.as_deref(), + page_size, + order_by.as_deref(), + &corpora, + drive_id.as_deref(), + page_token.as_deref(), + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::GetFile { file_id } => { + let result = api::get_file(&file_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::DownloadFile { + file_id, + export_mime_type, + } => { + let result = api::download_file(&file_id, export_mime_type.as_deref())?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::UploadFile { + name, + content, + mime_type, + parent_id, + description, + } => { + let result = api::upload_file( + &name, + &content, + &mime_type, + parent_id.as_deref(), + description.as_deref(), + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::UpdateFile { + file_id, + name, + description, + move_to_parent, + starred, + } => { + let result = api::update_file( + &file_id, + name.as_deref(), + description.as_deref(), + move_to_parent.as_deref(), + starred, + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::CreateFolder { + name, + parent_id, + description, + } => { + let result = api::create_folder(&name, parent_id.as_deref(), description.as_deref())?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::DeleteFile { file_id } => { + let result = api::delete_file(&file_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::TrashFile { file_id } => { + let result = api::trash_file(&file_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::ShareFile { + file_id, + email, + role, + message, + } => { + let result = api::share_file(&file_id, &email, &role, message.as_deref())?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::ListPermissions { file_id } => { + let result = api::list_permissions(&file_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::RemovePermission { + file_id, + permission_id, + } => { + let result = api::remove_permission(&file_id, &permission_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleDriveAction::ListSharedDrives { page_size } => { + let result = api::list_shared_drives(page_size)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + }; + + Ok(result) +} + +fn action_from_context(context: Option<&str>) -> Result<&'static str, String> { + let context = context.ok_or_else(|| "missing_invocation_context".to_string())?; + let context: ToolContext = + serde_json::from_str(context).map_err(|_| "invalid_invocation_context".to_string())?; + match context.capability_id.as_str() { + "google-drive.list_files" => Ok("list_files"), + "google-drive.get_file" => Ok("get_file"), + "google-drive.download_file" => Ok("download_file"), + "google-drive.upload_file" => Ok("upload_file"), + "google-drive.update_file" => Ok("update_file"), + "google-drive.create_folder" => Ok("create_folder"), + "google-drive.delete_file" => Ok("delete_file"), + "google-drive.trash_file" => Ok("trash_file"), + "google-drive.share_file" => Ok("share_file"), + "google-drive.list_permissions" => Ok("list_permissions"), + "google-drive.remove_permission" => Ok("remove_permission"), + "google-drive.list_shared_drives" => Ok("list_shared_drives"), + _ => Err("unsupported_google_drive_capability".to_string()), + } +} + +fn params_with_action(params: &str, action: &str) -> Result { + let mut params: serde_json::Value = if params.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(params).map_err(|_| "invalid_parameters".to_string())? + }; + let obj = params + .as_object_mut() + .ok_or_else(|| "invalid_parameters".to_string())?; + if obj.contains_key("action") { + return Err("invalid_parameters".to_string()); + } + obj.insert( + "action".to_string(), + serde_json::Value::String(action.to_string()), + ); + Ok(params) +} + +export!(GoogleDriveTool); diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/wasm-src/src/types.rs b/crates/ironclaw_first_party_extensions/assets/google-drive/wasm-src/src/types.rs new file mode 100644 index 00000000000..b1b477fc969 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-drive/wasm-src/src/types.rs @@ -0,0 +1,389 @@ +//! Types for Google Drive API requests and responses. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub(crate) struct ToolContext { + pub(crate) capability_id: String, +} + +/// Input parameters for the Google Drive tool. +/// +/// `JsonSchema` is derived so the advertised tool schema mirrors the +/// serde-enforced contract: each variant becomes a `oneOf` entry with +/// its own `required` array, so the agent knows which fields apply to +/// which `action`. Hand-writing the schema previously declared every +/// per-variant field as top-level optional, leading to runtime +/// `missing field 'file_id'` errors when the LLM omitted them. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum GoogleDriveAction { + /// Search/list files and folders. + ListFiles { + /// Drive search query (same syntax as Drive search). + /// Examples: "name contains 'report'", "mimeType = 'application/pdf'", + /// "'folderId' in parents", "sharedWithMe = true". + #[serde(default)] + query: Option, + /// Maximum number of results (default: 25, max: 1000). + #[serde(default = "default_page_size")] + page_size: u32, + /// Sort order (e.g., "modifiedTime desc", "name"). + #[serde(default)] + order_by: Option, + /// Search corpus: "user" (personal, default), "drive" (specific shared drive), + /// "domain" (org-wide), "allDrives" (everything accessible). + #[serde(default = "default_corpora")] + corpora: String, + /// Shared drive ID (required when corpora is "drive"). + #[serde(default)] + drive_id: Option, + /// Page token for pagination. + #[serde(default)] + page_token: Option, + }, + + /// Get file metadata. + GetFile { + /// The file ID. + file_id: String, + }, + + /// Download file content as text. + /// Only works for text-based files. For Google Docs/Sheets/Slides, + /// exports as plain text / CSV / plain text respectively. + DownloadFile { + /// The file ID. + file_id: String, + /// Export MIME type for Google Workspace files. + /// Defaults: Docs -> "text/plain", Sheets -> "text/csv", + /// Slides -> "text/plain", Drawings -> "image/svg+xml". + #[serde(default)] + export_mime_type: Option, + }, + + /// Upload a new file (text content). + UploadFile { + /// File name. + name: String, + /// File content (text). + content: String, + /// MIME type (default: "text/plain"). + #[serde(default = "default_mime_type")] + mime_type: String, + /// Parent folder ID. Omit for root. + #[serde(default)] + parent_id: Option, + /// File description. + #[serde(default)] + description: Option, + }, + + /// Update file metadata (rename, move, change description). + UpdateFile { + /// The file ID. + file_id: String, + /// New file name. + #[serde(default)] + name: Option, + /// New description. + #[serde(default)] + description: Option, + /// Move to this parent folder (removes from current parents). + #[serde(default)] + move_to_parent: Option, + /// Star or unstar the file. + #[serde(default)] + starred: Option, + }, + + /// Create a folder. + CreateFolder { + /// Folder name. + name: String, + /// Parent folder ID. Omit for root. + #[serde(default)] + parent_id: Option, + /// Folder description. + #[serde(default)] + description: Option, + }, + + /// Delete a file or folder (permanent). + DeleteFile { + /// The file ID to delete. + file_id: String, + }, + + /// Move a file to trash. + TrashFile { + /// The file ID to trash. + file_id: String, + }, + + /// Share a file or folder with someone. + ShareFile { + /// The file ID to share. + file_id: String, + /// Recipient email address. + email: String, + /// Permission role: "reader", "commenter", "writer", "organizer". + #[serde(default = "default_role")] + role: String, + /// Optional message to include in the sharing notification. + #[serde(default)] + message: Option, + }, + + /// List who a file is shared with. + ListPermissions { + /// The file ID. + file_id: String, + }, + + /// Remove sharing (revoke a permission). + RemovePermission { + /// The file ID. + file_id: String, + /// The permission ID to remove. + permission_id: String, + }, + + /// List shared drives the user has access to. + ListSharedDrives { + /// Maximum results (default: 25). + #[serde(default = "default_page_size")] + page_size: u32, + }, +} + +fn default_page_size() -> u32 { + 25 +} + +fn default_corpora() -> String { + "user".to_string() +} + +fn default_mime_type() -> String { + "text/plain".to_string() +} + +fn default_role() -> String { + "reader".to_string() +} + +/// A Google Drive file or folder. +#[derive(Debug, Serialize)] +pub struct DriveFile { + pub id: String, + pub name: String, + pub mime_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub modified_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub web_view_link: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub parents: Vec, + pub shared: bool, + pub starred: bool, + pub trashed: bool, + pub owned_by_me: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub drive_id: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub owners: Vec, + pub is_folder: bool, +} + +/// File owner info. +#[derive(Debug, Serialize)] +pub struct Owner { + pub email: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, +} + +/// A sharing permission. +#[derive(Debug, Serialize)] +pub struct Permission { + pub id: String, + pub role: String, + #[serde(rename = "type")] + pub permission_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub email_address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, +} + +/// A shared drive. +#[derive(Debug, Serialize)] +pub struct SharedDrive { + pub id: String, + pub name: String, +} + +/// Result from list_files. +#[derive(Debug, Serialize)] +pub struct ListFilesResult { + pub files: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, +} + +/// Result from get_file or upload/update. +#[derive(Debug, Serialize)] +pub struct FileResult { + pub file: DriveFile, +} + +/// Result from download_file. +#[derive(Debug, Serialize)] +pub struct DownloadResult { + pub file_id: String, + pub name: String, + pub mime_type: String, + pub content: String, +} + +/// Result from delete/trash. +#[derive(Debug, Serialize)] +pub struct DeleteResult { + pub file_id: String, + pub deleted: bool, +} + +/// Result from share_file. +#[derive(Debug, Serialize)] +pub struct ShareResult { + pub permission_id: String, + pub role: String, + pub email: String, +} + +/// Result from list_permissions. +#[derive(Debug, Serialize)] +pub struct ListPermissionsResult { + pub permissions: Vec, +} + +/// Result from list_shared_drives. +#[derive(Debug, Serialize)] +pub struct ListSharedDrivesResult { + pub drives: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The whole motivation for the schemars derive: when the agent calls + /// `{"action": "get_file"}` without `file_id`, serde must reject it + /// (matching the schema, which now also requires file_id under that + /// variant). Previously the schema said "file_id is optional" while + /// the code rejected it, so the agent kept making malformed calls. + #[test] + fn get_file_requires_file_id_at_serde_layer() { + let bad: Result = serde_json::from_str(r#"{"action":"get_file"}"#); + assert!( + bad.is_err(), + "serde must reject get_file without file_id" + ); + let good: Result = + serde_json::from_str(r#"{"action":"get_file","file_id":"abc123"}"#); + assert!(good.is_ok(), "serde must accept get_file with file_id"); + } + + /// The schema must reflect the same requirement so the agent can see + /// it before constructing a call. Each enum variant should appear as + /// a `oneOf` entry with `file_id` in `required` when applicable. + #[test] + fn schema_marks_file_id_required_for_get_file() { + let schema = schemars::schema_for!(GoogleDriveAction); + let json = serde_json::to_value(&schema).unwrap(); + + let one_of = json + .get("oneOf") + .and_then(|v| v.as_array()) + .expect("schemars should emit a oneOf for tagged enum"); + assert!( + one_of.len() >= 12, + "should have one oneOf entry per action (got {})", + one_of.len() + ); + + // Find the get_file branch and check its required array. + let get_file_branch = one_of + .iter() + .find(|entry| { + entry + .get("properties") + .and_then(|p| p.get("action")) + .and_then(|a| a.get("const")) + .and_then(|c| c.as_str()) + == Some("get_file") + }) + .expect("schema should contain a get_file branch"); + + let required: Vec<&str> = get_file_branch + .get("required") + .and_then(|r| r.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + assert!( + required.contains(&"file_id"), + "get_file branch should require file_id, got required={:?}", + required + ); + assert!( + required.contains(&"action"), + "get_file branch should require action, got required={:?}", + required + ); + } + + /// list_files takes only optional fields — `file_id` must NOT appear + /// in its required array, even though it's listed in other variants. + #[test] + fn schema_does_not_require_file_id_for_list_files() { + let schema = schemars::schema_for!(GoogleDriveAction); + let json = serde_json::to_value(&schema).unwrap(); + let one_of = json["oneOf"].as_array().unwrap(); + + let list_files_branch = one_of + .iter() + .find(|entry| { + entry + .get("properties") + .and_then(|p| p.get("action")) + .and_then(|a| a.get("const")) + .and_then(|c| c.as_str()) + == Some("list_files") + }) + .expect("schema should contain a list_files branch"); + + let required: Vec<&str> = list_files_branch + .get("required") + .and_then(|r| r.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + assert!( + !required.contains(&"file_id"), + "list_files must not require file_id (it has none)" + ); + assert_eq!( + required, ["action"], + "list_files should require only the discriminator" + ); + } +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-drive/wasm/google_drive_tool.wasm b/crates/ironclaw_first_party_extensions/assets/google-drive/wasm/google_drive_tool.wasm new file mode 100644 index 00000000000..84dc08e6a89 Binary files /dev/null and b/crates/ironclaw_first_party_extensions/assets/google-drive/wasm/google_drive_tool.wasm differ diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/manifest.toml b/crates/ironclaw_first_party_extensions/assets/google-sheets/manifest.toml new file mode 100644 index 00000000000..d3815c573b8 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/manifest.toml @@ -0,0 +1,164 @@ +schema_version = "reborn.extension_manifest.v2" +id = "google-sheets" +name = "Google Sheets" +version = "0.1.0" +description = "Google Sheets capabilities for creating, reading, writing, and formatting spreadsheets." +trust = "first_party_requested" + +[runtime] +kind = "wasm" +module = "wasm/google_sheets_tool.wasm" + +[[capabilities]] +id = "google-sheets.create_spreadsheet" +description = "Create a new spreadsheet." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/spreadsheets"], audience = { scheme = "https", host_pattern = "sheets.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-sheets/create_spreadsheet.input.v1.json" +output_schema_ref = "schemas/google-sheets/raw_output.v1.json" +prompt_doc_ref = "prompts/google-sheets/create_spreadsheet.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-sheets.get_spreadsheet" +description = "Get spreadsheet metadata." +effects = ["dispatch_capability", "network", "use_secret"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/spreadsheets.readonly"], audience = { scheme = "https", host_pattern = "sheets.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-sheets/get_spreadsheet.input.v1.json" +output_schema_ref = "schemas/google-sheets/raw_output.v1.json" +prompt_doc_ref = "prompts/google-sheets/get_spreadsheet.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-sheets.read_values" +description = "Read cell values from a range." +effects = ["dispatch_capability", "network", "use_secret"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/spreadsheets.readonly"], audience = { scheme = "https", host_pattern = "sheets.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-sheets/read_values.input.v1.json" +output_schema_ref = "schemas/google-sheets/raw_output.v1.json" +prompt_doc_ref = "prompts/google-sheets/read_values.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-sheets.batch_read_values" +description = "Read values from multiple ranges." +effects = ["dispatch_capability", "network", "use_secret"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/spreadsheets.readonly"], audience = { scheme = "https", host_pattern = "sheets.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-sheets/batch_read_values.input.v1.json" +output_schema_ref = "schemas/google-sheets/raw_output.v1.json" +prompt_doc_ref = "prompts/google-sheets/batch_read_values.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-sheets.write_values" +description = "Write values to a range." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/spreadsheets"], audience = { scheme = "https", host_pattern = "sheets.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-sheets/write_values.input.v1.json" +output_schema_ref = "schemas/google-sheets/raw_output.v1.json" +prompt_doc_ref = "prompts/google-sheets/write_values.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-sheets.append_values" +description = "Append rows after existing data." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/spreadsheets"], audience = { scheme = "https", host_pattern = "sheets.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-sheets/append_values.input.v1.json" +output_schema_ref = "schemas/google-sheets/raw_output.v1.json" +prompt_doc_ref = "prompts/google-sheets/append_values.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-sheets.clear_values" +description = "Clear values from a range." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/spreadsheets"], audience = { scheme = "https", host_pattern = "sheets.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-sheets/clear_values.input.v1.json" +output_schema_ref = "schemas/google-sheets/raw_output.v1.json" +prompt_doc_ref = "prompts/google-sheets/clear_values.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-sheets.add_sheet" +description = "Add a sheet tab." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/spreadsheets"], audience = { scheme = "https", host_pattern = "sheets.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-sheets/add_sheet.input.v1.json" +output_schema_ref = "schemas/google-sheets/raw_output.v1.json" +prompt_doc_ref = "prompts/google-sheets/add_sheet.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-sheets.delete_sheet" +description = "Delete a sheet tab." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/spreadsheets"], audience = { scheme = "https", host_pattern = "sheets.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-sheets/delete_sheet.input.v1.json" +output_schema_ref = "schemas/google-sheets/raw_output.v1.json" +prompt_doc_ref = "prompts/google-sheets/delete_sheet.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-sheets.rename_sheet" +description = "Rename a sheet tab." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/spreadsheets"], audience = { scheme = "https", host_pattern = "sheets.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-sheets/rename_sheet.input.v1.json" +output_schema_ref = "schemas/google-sheets/raw_output.v1.json" +prompt_doc_ref = "prompts/google-sheets/rename_sheet.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-sheets.format_cells" +description = "Format cells in a range." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/spreadsheets"], audience = { scheme = "https", host_pattern = "sheets.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-sheets/format_cells.input.v1.json" +output_schema_ref = "schemas/google-sheets/raw_output.v1.json" +prompt_doc_ref = "prompts/google-sheets/format_cells.md" +required_host_ports = ["host.runtime.http_egress"] diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/add_sheet.md b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/add_sheet.md new file mode 100644 index 00000000000..61a79f17021 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/add_sheet.md @@ -0,0 +1,3 @@ +Add a sheet tab. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/append_values.md b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/append_values.md new file mode 100644 index 00000000000..a89c2d7add5 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/append_values.md @@ -0,0 +1,3 @@ +Append rows after existing data. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/batch_read_values.md b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/batch_read_values.md new file mode 100644 index 00000000000..287db49ad37 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/batch_read_values.md @@ -0,0 +1,3 @@ +Read values from multiple ranges. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/clear_values.md b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/clear_values.md new file mode 100644 index 00000000000..b882f4b2531 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/clear_values.md @@ -0,0 +1,3 @@ +Clear values from a range. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/create_spreadsheet.md b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/create_spreadsheet.md new file mode 100644 index 00000000000..3dd08c87432 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/create_spreadsheet.md @@ -0,0 +1,3 @@ +Create a new spreadsheet. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/delete_sheet.md b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/delete_sheet.md new file mode 100644 index 00000000000..8c9f4a550ae --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/delete_sheet.md @@ -0,0 +1,3 @@ +Delete a sheet tab. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/format_cells.md b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/format_cells.md new file mode 100644 index 00000000000..03cff814991 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/format_cells.md @@ -0,0 +1,3 @@ +Format cells in a range. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/get_spreadsheet.md b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/get_spreadsheet.md new file mode 100644 index 00000000000..bfb9dcaf694 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/get_spreadsheet.md @@ -0,0 +1,3 @@ +Get spreadsheet metadata. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/read_values.md b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/read_values.md new file mode 100644 index 00000000000..80d360592cc --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/read_values.md @@ -0,0 +1,3 @@ +Read cell values from a range. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/rename_sheet.md b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/rename_sheet.md new file mode 100644 index 00000000000..ee440b42001 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/rename_sheet.md @@ -0,0 +1,3 @@ +Rename a sheet tab. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/write_values.md b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/write_values.md new file mode 100644 index 00000000000..eb0bc2b20ef --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/prompts/google-sheets/write_values.md @@ -0,0 +1,3 @@ +Write values to a range. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/add_sheet.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/add_sheet.input.v1.json new file mode 100644 index 00000000000..84bfd4cb75d --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/add_sheet.input.v1.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Sheets add_sheet", + "description": "Add a sheet tab.", + "type": "object", + "required": [ + "spreadsheet_id", + "title" + ], + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID." + }, + "title": { + "type": "string", + "description": "New sheet title." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/append_values.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/append_values.input.v1.json new file mode 100644 index 00000000000..5fb44925a81 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/append_values.input.v1.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Sheets append_values", + "description": "Append rows after existing data.", + "type": "object", + "required": [ + "spreadsheet_id", + "range", + "values" + ], + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID." + }, + "range": { + "type": "string", + "description": "A1 notation range." + }, + "values": { + "type": "array", + "items": { + "type": "array", + "items": { + "description": "Cell value." + }, + "description": "Row values." + }, + "description": "Rows of values." + }, + "value_input_option": { + "type": "string", + "description": "RAW or USER_ENTERED. Defaults to USER_ENTERED." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/batch_read_values.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/batch_read_values.input.v1.json new file mode 100644 index 00000000000..e4144a2afcb --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/batch_read_values.input.v1.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Sheets batch_read_values", + "description": "Read values from multiple ranges.", + "type": "object", + "required": [ + "spreadsheet_id", + "ranges" + ], + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID." + }, + "ranges": { + "type": "array", + "items": { + "type": "string", + "description": "A1 notation range." + }, + "description": "A1 notation ranges." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/clear_values.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/clear_values.input.v1.json new file mode 100644 index 00000000000..11cf398bd92 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/clear_values.input.v1.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Sheets clear_values", + "description": "Clear values from a range.", + "type": "object", + "required": [ + "spreadsheet_id", + "range" + ], + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID." + }, + "range": { + "type": "string", + "description": "A1 notation range to clear." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/create_spreadsheet.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/create_spreadsheet.input.v1.json new file mode 100644 index 00000000000..9c9916ba5e4 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/create_spreadsheet.input.v1.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Sheets create_spreadsheet", + "description": "Create a new spreadsheet.", + "type": "object", + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "description": "Spreadsheet title." + }, + "sheet_names": { + "type": "array", + "items": { + "type": "string", + "description": "Sheet name." + }, + "description": "Names of sheets to create." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/delete_sheet.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/delete_sheet.input.v1.json new file mode 100644 index 00000000000..e237fd2d50a --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/delete_sheet.input.v1.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Sheets delete_sheet", + "description": "Delete a sheet tab.", + "type": "object", + "required": [ + "spreadsheet_id", + "sheet_id" + ], + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID." + }, + "sheet_id": { + "type": "integer", + "description": "Numeric sheet ID." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/format_cells.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/format_cells.input.v1.json new file mode 100644 index 00000000000..4151288db74 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/format_cells.input.v1.json @@ -0,0 +1,97 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Sheets format_cells", + "description": "Format cells in a range.", + "type": "object", + "required": [ + "spreadsheet_id", + "sheet_id", + "start_row", + "end_row", + "start_column", + "end_column" + ], + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID." + }, + "sheet_id": { + "type": "integer", + "description": "Numeric sheet ID." + }, + "start_row": { + "type": "integer", + "description": "Start row, zero-indexed inclusive." + }, + "end_row": { + "type": "integer", + "description": "End row, zero-indexed exclusive." + }, + "start_column": { + "type": "integer", + "description": "Start column, zero-indexed inclusive." + }, + "end_column": { + "type": "integer", + "description": "End column, zero-indexed exclusive." + }, + "bold": { + "type": [ + "boolean", + "null" + ], + "description": "Bold text." + }, + "italic": { + "type": [ + "boolean", + "null" + ], + "description": "Italic text." + }, + "font_size": { + "type": [ + "integer", + "null" + ], + "description": "Font size." + }, + "text_color": { + "type": [ + "string", + "null" + ], + "description": "Text color hex." + }, + "background_color": { + "type": [ + "string", + "null" + ], + "description": "Background color hex." + }, + "horizontal_alignment": { + "type": [ + "string", + "null" + ], + "description": "Horizontal alignment." + }, + "number_format": { + "type": [ + "string", + "null" + ], + "description": "Number format pattern." + }, + "number_format_type": { + "type": [ + "string", + "null" + ], + "description": "Number format type." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/get_spreadsheet.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/get_spreadsheet.input.v1.json new file mode 100644 index 00000000000..cb8960c25f0 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/get_spreadsheet.input.v1.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Sheets get_spreadsheet", + "description": "Get spreadsheet metadata.", + "type": "object", + "required": [ + "spreadsheet_id" + ], + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/raw_output.v1.json b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/raw_output.v1.json new file mode 100644 index 00000000000..df8935f40c7 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/raw_output.v1.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Raw Google API Output", + "description": "Google API response serialized by the WASM tool.", + "type": "object", + "additionalProperties": true +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/read_values.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/read_values.input.v1.json new file mode 100644 index 00000000000..35db98a343c --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/read_values.input.v1.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Sheets read_values", + "description": "Read cell values from a range.", + "type": "object", + "required": [ + "spreadsheet_id", + "range" + ], + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID." + }, + "range": { + "type": "string", + "description": "A1 notation range." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/rename_sheet.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/rename_sheet.input.v1.json new file mode 100644 index 00000000000..19ac252ffd3 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/rename_sheet.input.v1.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Sheets rename_sheet", + "description": "Rename a sheet tab.", + "type": "object", + "required": [ + "spreadsheet_id", + "sheet_id", + "title" + ], + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID." + }, + "sheet_id": { + "type": "integer", + "description": "Numeric sheet ID." + }, + "title": { + "type": "string", + "description": "New sheet title." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/write_values.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/write_values.input.v1.json new file mode 100644 index 00000000000..708960a2b40 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/schemas/google-sheets/write_values.input.v1.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Sheets write_values", + "description": "Write values to a range.", + "type": "object", + "required": [ + "spreadsheet_id", + "range", + "values" + ], + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "The spreadsheet ID." + }, + "range": { + "type": "string", + "description": "A1 notation range." + }, + "values": { + "type": "array", + "items": { + "type": "array", + "items": { + "description": "Cell value." + }, + "description": "Row values." + }, + "description": "Rows of values." + }, + "value_input_option": { + "type": "string", + "description": "RAW or USER_ENTERED. Defaults to USER_ENTERED." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/wasm-src/Cargo.toml b/crates/ironclaw_first_party_extensions/assets/google-sheets/wasm-src/Cargo.toml new file mode 100644 index 00000000000..f312ddc17a0 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/wasm-src/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "google-sheets-tool" +version = "0.2.0" +edition = "2021" +description = "Google Sheets integration tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +urlencoding = "2" +# Auto-derives JSON Schema from the GoogleSheetsAction tagged enum so the +# advertised schema mirrors the serde-enforced contract. +schemars = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 + +[workspace] diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/wasm-src/src/api.rs b/crates/ironclaw_first_party_extensions/assets/google-sheets/wasm-src/src/api.rs new file mode 100644 index 00000000000..e5c53faa8c3 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/wasm-src/src/api.rs @@ -0,0 +1,514 @@ +//! Google Sheets API v4 implementation. +//! +//! All API calls go through the host's HTTP capability, which handles +//! credential injection and rate limiting. The WASM tool never sees +//! the actual OAuth token. + +use crate::near::agent::host; +use crate::types::*; + +const SHEETS_API_BASE: &str = "https://sheets.googleapis.com/v4/spreadsheets"; + +/// Make a Google Sheets API call. +fn api_call(method: &str, path: &str, body: Option<&str>) -> Result { + let url = if path.is_empty() { + SHEETS_API_BASE.to_string() + } else { + format!("{}/{}", SHEETS_API_BASE, path) + }; + + let headers = if body.is_some() { + r#"{"Content-Type": "application/json"}"# + } else { + "{}" + }; + + let body_bytes = body.map(|b| b.as_bytes().to_vec()); + + host::log( + host::LogLevel::Debug, + &format!("Google Sheets API: {} {}", method, url), + ); + + let response = host::http_request(method, &url, headers, body_bytes.as_deref(), None)?; + + if response.status < 200 || response.status >= 300 { + let body_text = String::from_utf8_lossy(&response.body); + return Err(format!( + "Google Sheets API returned status {}: {}", + response.status, body_text + )); + } + + if response.body.is_empty() { + return Ok(String::new()); + } + + String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 in response: {}", e)) +} + +/// Parse sheet info from the API's JSON. +fn parse_sheet_info(v: &serde_json::Value) -> SheetInfo { + let props = &v["properties"]; + let grid = &props["gridProperties"]; + SheetInfo { + sheet_id: props["sheetId"].as_i64().unwrap_or(0), + title: props["title"].as_str().unwrap_or("").to_string(), + index: props["index"].as_i64().unwrap_or(0), + row_count: grid["rowCount"].as_i64().unwrap_or(0), + column_count: grid["columnCount"].as_i64().unwrap_or(0), + } +} + +/// Parse a named range from the API's JSON. +fn parse_named_range(v: &serde_json::Value) -> NamedRange { + let range = &v["range"]; + let range_str = format_grid_range(range); + NamedRange { + named_range_id: v["namedRangeId"].as_str().unwrap_or("").to_string(), + name: v["name"].as_str().unwrap_or("").to_string(), + range: range_str, + } +} + +/// Format a GridRange into a human-readable string. +fn format_grid_range(v: &serde_json::Value) -> String { + let sheet_id = v["sheetId"].as_i64().unwrap_or(0); + let start_row = v["startRowIndex"].as_i64().unwrap_or(0); + let end_row = v["endRowIndex"].as_i64().unwrap_or(0); + let start_col = v["startColumnIndex"].as_i64().unwrap_or(0); + let end_col = v["endColumnIndex"].as_i64().unwrap_or(0); + format!( + "sheetId={}, rows {}:{}, cols {}:{}", + sheet_id, start_row, end_row, start_col, end_col + ) +} + +/// Create a new spreadsheet. +pub fn create_spreadsheet( + title: &str, + sheet_names: &[String], +) -> Result { + let sheets: Vec = if sheet_names.is_empty() { + vec![serde_json::json!({"properties": {"title": "Sheet1"}})] + } else { + sheet_names + .iter() + .map(|name| serde_json::json!({"properties": {"title": name}})) + .collect() + }; + + let body = serde_json::json!({ + "properties": {"title": title}, + "sheets": sheets, + }); + + let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; + let response = api_call("POST", "", Some(&body_str))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(CreateSpreadsheetResult { + spreadsheet_id: parsed["spreadsheetId"].as_str().unwrap_or("").to_string(), + title: parsed["properties"]["title"] + .as_str() + .unwrap_or("") + .to_string(), + url: parsed["spreadsheetUrl"].as_str().unwrap_or("").to_string(), + sheets: parsed["sheets"] + .as_array() + .map(|arr| arr.iter().map(parse_sheet_info).collect()) + .unwrap_or_default(), + }) +} + +/// Get spreadsheet metadata. +pub fn get_spreadsheet(spreadsheet_id: &str) -> Result { + let path = format!( + "{}?fields=spreadsheetId,properties.title,spreadsheetUrl,sheets.properties,namedRanges", + url_encode(spreadsheet_id) + ); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(SpreadsheetMetadata { + spreadsheet_id: parsed["spreadsheetId"].as_str().unwrap_or("").to_string(), + title: parsed["properties"]["title"] + .as_str() + .unwrap_or("") + .to_string(), + url: parsed["spreadsheetUrl"].as_str().unwrap_or("").to_string(), + sheets: parsed["sheets"] + .as_array() + .map(|arr| arr.iter().map(parse_sheet_info).collect()) + .unwrap_or_default(), + named_ranges: parsed["namedRanges"] + .as_array() + .map(|arr| arr.iter().map(parse_named_range).collect()) + .unwrap_or_default(), + }) +} + +/// Read values from a single range. +pub fn read_values(spreadsheet_id: &str, range: &str) -> Result { + let path = format!( + "{}/values/{}", + url_encode(spreadsheet_id), + url_encode(range) + ); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(ValuesResult { + range: parsed["range"].as_str().unwrap_or("").to_string(), + values: parsed["values"] + .as_array() + .map(|rows| { + rows.iter() + .map(|row| row.as_array().map(|cols| cols.to_vec()).unwrap_or_default()) + .collect() + }) + .unwrap_or_default(), + }) +} + +/// Read values from multiple ranges at once. +pub fn batch_read_values( + spreadsheet_id: &str, + ranges: &[String], +) -> Result { + let range_params: Vec = ranges + .iter() + .map(|r| format!("ranges={}", url_encode(r))) + .collect(); + + let path = format!( + "{}/values:batchGet?{}", + url_encode(spreadsheet_id), + range_params.join("&") + ); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let value_ranges = parsed["valueRanges"] + .as_array() + .map(|arr| { + arr.iter() + .map(|vr| ValuesResult { + range: vr["range"].as_str().unwrap_or("").to_string(), + values: vr["values"] + .as_array() + .map(|rows| { + rows.iter() + .map(|row| { + row.as_array().map(|cols| cols.to_vec()).unwrap_or_default() + }) + .collect() + }) + .unwrap_or_default(), + }) + .collect() + }) + .unwrap_or_default(); + + Ok(BatchValuesResult { value_ranges }) +} + +/// Write values to a range. +pub fn write_values( + spreadsheet_id: &str, + range: &str, + values: &[Vec], + value_input_option: &str, +) -> Result { + let path = format!( + "{}/values/{}?valueInputOption={}", + url_encode(spreadsheet_id), + url_encode(range), + url_encode(value_input_option) + ); + + let body = serde_json::json!({ + "range": range, + "majorDimension": "ROWS", + "values": values, + }); + + let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; + let response = api_call("PUT", &path, Some(&body_str))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(UpdateResult { + updated_range: parsed["updatedRange"].as_str().unwrap_or("").to_string(), + updated_rows: parsed["updatedRows"].as_i64().unwrap_or(0), + updated_columns: parsed["updatedColumns"].as_i64().unwrap_or(0), + updated_cells: parsed["updatedCells"].as_i64().unwrap_or(0), + }) +} + +/// Append rows after existing data. +pub fn append_values( + spreadsheet_id: &str, + range: &str, + values: &[Vec], + value_input_option: &str, +) -> Result { + let path = format!( + "{}/values/{}:append?valueInputOption={}&insertDataOption=INSERT_ROWS", + url_encode(spreadsheet_id), + url_encode(range), + url_encode(value_input_option) + ); + + let body = serde_json::json!({ + "range": range, + "majorDimension": "ROWS", + "values": values, + }); + + let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; + let response = api_call("POST", &path, Some(&body_str))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let updates = &parsed["updates"]; + Ok(UpdateResult { + updated_range: updates["updatedRange"].as_str().unwrap_or("").to_string(), + updated_rows: updates["updatedRows"].as_i64().unwrap_or(0), + updated_columns: updates["updatedColumns"].as_i64().unwrap_or(0), + updated_cells: updates["updatedCells"].as_i64().unwrap_or(0), + }) +} + +/// Clear values from a range. +pub fn clear_values(spreadsheet_id: &str, range: &str) -> Result { + let path = format!( + "{}/values/{}:clear", + url_encode(spreadsheet_id), + url_encode(range) + ); + + let response = api_call("POST", &path, Some("{}"))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(ClearResult { + cleared_range: parsed["clearedRange"].as_str().unwrap_or("").to_string(), + }) +} + +/// Send a batchUpdate request to the spreadsheet. +fn batch_update( + spreadsheet_id: &str, + requests: Vec, +) -> Result { + let path = format!("{}:batchUpdate", url_encode(spreadsheet_id)); + + let body = serde_json::json!({ "requests": requests }); + let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; + + let response = api_call("POST", &path, Some(&body_str))?; + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e)) +} + +/// Add a new sheet (tab) to the spreadsheet. +pub fn add_sheet(spreadsheet_id: &str, title: &str) -> Result { + let requests = vec![serde_json::json!({ + "addSheet": { + "properties": { + "title": title + } + } + })]; + + let parsed = batch_update(spreadsheet_id, requests)?; + + let reply = parsed["replies"] + .as_array() + .and_then(|arr| arr.first()) + .map(|r| &r["addSheet"]["properties"]); + + let reply = reply.ok_or_else(|| "No reply from batch update".to_string())?; + + Ok(AddSheetResult { + sheet: SheetInfo { + sheet_id: reply["sheetId"].as_i64().unwrap_or(0), + title: reply["title"].as_str().unwrap_or("").to_string(), + index: reply["index"].as_i64().unwrap_or(0), + row_count: reply["gridProperties"]["rowCount"].as_i64().unwrap_or(1000), + column_count: reply["gridProperties"]["columnCount"] + .as_i64() + .unwrap_or(26), + }, + }) +} + +/// Delete a sheet (tab) from the spreadsheet. +pub fn delete_sheet(spreadsheet_id: &str, sheet_id: i64) -> Result { + let requests = vec![serde_json::json!({ + "deleteSheet": { + "sheetId": sheet_id + } + })]; + + batch_update(spreadsheet_id, requests)?; + + Ok(SheetOperationResult { + spreadsheet_id: spreadsheet_id.to_string(), + success: true, + }) +} + +/// Rename a sheet (tab). +pub fn rename_sheet( + spreadsheet_id: &str, + sheet_id: i64, + title: &str, +) -> Result { + let requests = vec![serde_json::json!({ + "updateSheetProperties": { + "properties": { + "sheetId": sheet_id, + "title": title + }, + "fields": "title" + } + })]; + + batch_update(spreadsheet_id, requests)?; + + Ok(SheetOperationResult { + spreadsheet_id: spreadsheet_id.to_string(), + success: true, + }) +} + +/// Parse a hex color like "#FF0000" into Sheets API color (0.0-1.0 floats). +fn parse_hex_color(hex: &str) -> Option { + let hex = hex.strip_prefix('#').unwrap_or(hex); + if hex.len() != 6 { + return None; + } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(serde_json::json!({ + "red": r as f64 / 255.0, + "green": g as f64 / 255.0, + "blue": b as f64 / 255.0, + })) +} + +/// Parameters for cell formatting. +pub struct FormatOptions<'a> { + pub spreadsheet_id: &'a str, + pub sheet_id: i64, + pub start_row: i64, + pub end_row: i64, + pub start_column: i64, + pub end_column: i64, + pub bold: Option, + pub italic: Option, + pub font_size: Option, + pub text_color: Option<&'a str>, + pub background_color: Option<&'a str>, + pub horizontal_alignment: Option<&'a str>, + pub number_format: Option<&'a str>, + pub number_format_type: Option<&'a str>, +} + +/// Format cells in a range. +pub fn format_cells(opts: FormatOptions<'_>) -> Result { + let mut format = serde_json::json!({}); + let mut fields = Vec::new(); + + // Text format + let mut text_format = serde_json::json!({}); + let mut has_text_format = false; + + if let Some(b) = opts.bold { + text_format["bold"] = serde_json::Value::Bool(b); + has_text_format = true; + } + if let Some(i) = opts.italic { + text_format["italic"] = serde_json::Value::Bool(i); + has_text_format = true; + } + if let Some(size) = opts.font_size { + text_format["fontSize"] = serde_json::json!(size); + has_text_format = true; + } + if let Some(color) = opts.text_color { + if let Some(c) = parse_hex_color(color) { + text_format["foregroundColor"] = c; + has_text_format = true; + } + } + + if has_text_format { + format["textFormat"] = text_format; + fields.push("userEnteredFormat.textFormat"); + } + + // Background color + if let Some(color) = opts.background_color { + if let Some(c) = parse_hex_color(color) { + format["backgroundColor"] = c; + fields.push("userEnteredFormat.backgroundColor"); + } + } + + // Horizontal alignment + if let Some(align) = opts.horizontal_alignment { + format["horizontalAlignment"] = serde_json::Value::String(align.to_string()); + fields.push("userEnteredFormat.horizontalAlignment"); + } + + // Number format + if let Some(pattern) = opts.number_format { + let fmt_type = opts.number_format_type.unwrap_or("NUMBER"); + format["numberFormat"] = serde_json::json!({ + "type": fmt_type, + "pattern": pattern, + }); + fields.push("userEnteredFormat.numberFormat"); + } + + if fields.is_empty() { + return Err("No formatting options specified".to_string()); + } + + let requests = vec![serde_json::json!({ + "repeatCell": { + "range": { + "sheetId": opts.sheet_id, + "startRowIndex": opts.start_row, + "endRowIndex": opts.end_row, + "startColumnIndex": opts.start_column, + "endColumnIndex": opts.end_column, + }, + "cell": { + "userEnteredFormat": format, + }, + "fields": fields.join(","), + } + })]; + + batch_update(opts.spreadsheet_id, requests)?; + + Ok(FormatResult { + spreadsheet_id: opts.spreadsheet_id.to_string(), + success: true, + }) +} + +fn url_encode(s: &str) -> String { + urlencoding::encode(s).into_owned() +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/wasm-src/src/lib.rs b/crates/ironclaw_first_party_extensions/assets/google-sheets/wasm-src/src/lib.rs new file mode 100644 index 00000000000..9e0777b253e --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/wasm-src/src/lib.rs @@ -0,0 +1,258 @@ +//! Google Sheets WASM Tool for IronClaw. +//! +//! Provides Google Sheets integration for creating, reading, writing, +//! and formatting spreadsheets. Use Google Drive tool to search for +//! existing spreadsheets by name. +//! +//! # Capabilities Required +//! +//! - HTTP: `sheets.googleapis.com/v4/spreadsheets*` +//! - Credentials: staged Google product-auth account token injected by the host. +//! +//! # Supported Actions +//! +//! - `create_spreadsheet`: Create a new spreadsheet with optional sheet names +//! - `get_spreadsheet`: Get metadata (title, sheets, named ranges) +//! - `read_values`: Read cell values from a range (A1 notation) +//! - `batch_read_values`: Read from multiple ranges at once +//! - `write_values`: Write values to a range (overwrites) +//! - `append_values`: Append rows after existing data +//! - `clear_values`: Clear values from a range (keeps formatting) +//! - `add_sheet`: Add a new sheet (tab) +//! - `delete_sheet`: Delete a sheet (tab) +//! - `rename_sheet`: Rename a sheet (tab) +//! - `format_cells`: Format cells (bold, colors, alignment, number format) +//! +//! # Tips +//! +//! - Spreadsheet IDs are the same as Google Drive file IDs. Use google-drive +//! tool's list_files to find spreadsheets. +//! - Use A1 notation for ranges: "Sheet1!A1:D10", "A1:B5", "Sheet1!A:E" +//! - Sheet IDs (numeric) are different from sheet names. Get them via get_spreadsheet. +//! +//! # Example Usage +//! +//! ```json +//! {"action": "create_spreadsheet", "title": "Q1 Report", "sheet_names": ["Revenue", "Expenses"]} +//! {"action": "read_values", "spreadsheet_id": "abc123", "range": "Sheet1!A1:D10"} +//! {"action": "write_values", "spreadsheet_id": "abc123", "range": "Sheet1!A1", "values": [["Name", "Age"], ["Alice", 30]]} +//! {"action": "append_values", "spreadsheet_id": "abc123", "range": "Sheet1!A:B", "values": [["Bob", 25]]} +//! {"action": "format_cells", "spreadsheet_id": "abc123", "sheet_id": 0, "start_row": 0, "end_row": 1, "start_column": 0, "end_column": 4, "bold": true, "background_color": "#4285F4", "text_color": "#FFFFFF"} +//! ``` + +mod api; +mod types; + +use types::{GoogleSheetsAction, ToolContext}; + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../../../../wit/tool.wit", +}); + +struct GoogleSheetsTool; + +impl exports::near::agent::tool::Guest for GoogleSheetsTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params, req.context.as_deref()) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + // Derived from `GoogleSheetsAction` via `schemars::JsonSchema` so the + // advertised schema can never drift from the serde contract. + let schema = schemars::schema_for!(types::GoogleSheetsAction); + serde_json::to_string(&schema).unwrap_or_else(|_| "{}".to_string()) + } + + fn description() -> String { + "Google Sheets integration for creating, reading, writing, and formatting spreadsheets. \ + Supports cell value operations (read, write, append, clear) using A1 notation, sheet \ + (tab) management (add, delete, rename), and cell formatting (bold, colors, alignment, \ + number formats). Spreadsheet IDs are the same as Google Drive file IDs, so use the \ + google-drive tool to search for existing spreadsheets. The host injects a Google \ + product-auth credential with the spreadsheets scope. \ + To discover all available API operations, use http GET to fetch \ + (public, no auth needed)." + .to_string() + } +} + +fn execute_inner(params: &str, context: Option<&str>) -> Result { + let action_name = action_from_context(context)?; + let params = params_with_action(params, action_name)?; + let action: GoogleSheetsAction = + serde_json::from_value(params).map_err(|e| format!("Invalid parameters: {}", e))?; + + crate::near::agent::host::log( + crate::near::agent::host::LogLevel::Debug, + &format!("Executing Google Sheets action: {action_name}"), + ); + + let result = match action { + GoogleSheetsAction::CreateSpreadsheet { title, sheet_names } => { + let result = api::create_spreadsheet(&title, &sheet_names)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::GetSpreadsheet { spreadsheet_id } => { + let result = api::get_spreadsheet(&spreadsheet_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::ReadValues { + spreadsheet_id, + range, + } => { + let result = api::read_values(&spreadsheet_id, &range)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::BatchReadValues { + spreadsheet_id, + ranges, + } => { + let result = api::batch_read_values(&spreadsheet_id, &ranges)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::WriteValues { + spreadsheet_id, + range, + values, + value_input_option, + } => { + let result = api::write_values(&spreadsheet_id, &range, &values, &value_input_option)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::AppendValues { + spreadsheet_id, + range, + values, + value_input_option, + } => { + let result = api::append_values(&spreadsheet_id, &range, &values, &value_input_option)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::ClearValues { + spreadsheet_id, + range, + } => { + let result = api::clear_values(&spreadsheet_id, &range)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::AddSheet { + spreadsheet_id, + title, + } => { + let result = api::add_sheet(&spreadsheet_id, &title)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::DeleteSheet { + spreadsheet_id, + sheet_id, + } => { + let result = api::delete_sheet(&spreadsheet_id, sheet_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::RenameSheet { + spreadsheet_id, + sheet_id, + title, + } => { + let result = api::rename_sheet(&spreadsheet_id, sheet_id, &title)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSheetsAction::FormatCells { + spreadsheet_id, + sheet_id, + start_row, + end_row, + start_column, + end_column, + bold, + italic, + font_size, + text_color, + background_color, + horizontal_alignment, + number_format, + number_format_type, + } => { + let result = api::format_cells(api::FormatOptions { + spreadsheet_id: &spreadsheet_id, + sheet_id, + start_row, + end_row, + start_column, + end_column, + bold, + italic, + font_size, + text_color: text_color.as_deref(), + background_color: background_color.as_deref(), + horizontal_alignment: horizontal_alignment.as_deref(), + number_format: number_format.as_deref(), + number_format_type: number_format_type.as_deref(), + })?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + }; + + Ok(result) +} + +fn action_from_context(context: Option<&str>) -> Result<&'static str, String> { + let context = context.ok_or_else(|| "missing_invocation_context".to_string())?; + let context: ToolContext = + serde_json::from_str(context).map_err(|_| "invalid_invocation_context".to_string())?; + match context.capability_id.as_str() { + "google-sheets.create_spreadsheet" => Ok("create_spreadsheet"), + "google-sheets.get_spreadsheet" => Ok("get_spreadsheet"), + "google-sheets.read_values" => Ok("read_values"), + "google-sheets.batch_read_values" => Ok("batch_read_values"), + "google-sheets.write_values" => Ok("write_values"), + "google-sheets.append_values" => Ok("append_values"), + "google-sheets.clear_values" => Ok("clear_values"), + "google-sheets.add_sheet" => Ok("add_sheet"), + "google-sheets.delete_sheet" => Ok("delete_sheet"), + "google-sheets.rename_sheet" => Ok("rename_sheet"), + "google-sheets.format_cells" => Ok("format_cells"), + _ => Err("unsupported_google_sheets_capability".to_string()), + } +} + +fn params_with_action(params: &str, action: &str) -> Result { + let mut params: serde_json::Value = if params.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(params).map_err(|_| "invalid_parameters".to_string())? + }; + let obj = params + .as_object_mut() + .ok_or_else(|| "invalid_parameters".to_string())?; + if obj.contains_key("action") { + return Err("invalid_parameters".to_string()); + } + obj.insert( + "action".to_string(), + serde_json::Value::String(action.to_string()), + ); + Ok(params) +} + +export!(GoogleSheetsTool); diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/wasm-src/src/types.rs b/crates/ironclaw_first_party_extensions/assets/google-sheets/wasm-src/src/types.rs new file mode 100644 index 00000000000..4169a675f33 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-sheets/wasm-src/src/types.rs @@ -0,0 +1,239 @@ +//! Types for Google Sheets API requests and responses. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub(crate) struct ToolContext { + pub(crate) capability_id: String, +} + +/// Input parameters for the Google Sheets tool. +/// +/// `JsonSchema` is derived so the advertised tool schema mirrors the +/// serde-enforced contract: each variant becomes a `oneOf` entry with +/// its own `required` array. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum GoogleSheetsAction { + /// Create a new spreadsheet. + CreateSpreadsheet { + /// Spreadsheet title. + title: String, + /// Names of sheets (tabs) to create. Defaults to one sheet named "Sheet1". + #[serde(default)] + sheet_names: Vec, + }, + + /// Get spreadsheet metadata (title, sheets, named ranges). + GetSpreadsheet { + /// The spreadsheet ID (same as Google Drive file ID). + spreadsheet_id: String, + }, + + /// Read cell values from a range. + ReadValues { + /// The spreadsheet ID. + spreadsheet_id: String, + /// A1 notation range (e.g., "Sheet1!A1:D10", "A1:B5"). + range: String, + }, + + /// Read values from multiple ranges at once. + BatchReadValues { + /// The spreadsheet ID. + spreadsheet_id: String, + /// List of A1 notation ranges. + ranges: Vec, + }, + + /// Write values to a range (overwrites existing data). + WriteValues { + /// The spreadsheet ID. + spreadsheet_id: String, + /// A1 notation range (e.g., "Sheet1!A1:D10"). + range: String, + /// 2D array of values (rows of columns). + values: Vec>, + /// How to interpret input: "RAW" or "USER_ENTERED" (default). + #[serde(default = "default_value_input_option")] + value_input_option: String, + }, + + /// Append rows after existing data in a range. + AppendValues { + /// The spreadsheet ID. + spreadsheet_id: String, + /// A1 notation range to search for a table (e.g., "Sheet1!A:E"). + range: String, + /// Rows to append (2D array). + values: Vec>, + /// How to interpret input: "RAW" or "USER_ENTERED" (default). + #[serde(default = "default_value_input_option")] + value_input_option: String, + }, + + /// Clear values from a range (keeps formatting). + ClearValues { + /// The spreadsheet ID. + spreadsheet_id: String, + /// A1 notation range to clear. + range: String, + }, + + /// Add a new sheet (tab) to the spreadsheet. + AddSheet { + /// The spreadsheet ID. + spreadsheet_id: String, + /// Name for the new sheet. + title: String, + }, + + /// Delete a sheet (tab) from the spreadsheet. + DeleteSheet { + /// The spreadsheet ID. + spreadsheet_id: String, + /// Numeric sheet ID (from get_spreadsheet, NOT the sheet name). + sheet_id: i64, + }, + + /// Rename a sheet (tab). + RenameSheet { + /// The spreadsheet ID. + spreadsheet_id: String, + /// Numeric sheet ID. + sheet_id: i64, + /// New name for the sheet. + title: String, + }, + + /// Format cells in a range (bold, colors, number format, borders, alignment). + FormatCells { + /// The spreadsheet ID. + spreadsheet_id: String, + /// Numeric sheet ID. + sheet_id: i64, + /// Start row (0-indexed, inclusive). + start_row: i64, + /// End row (0-indexed, exclusive). + end_row: i64, + /// Start column (0-indexed, inclusive). + start_column: i64, + /// End column (0-indexed, exclusive). + end_column: i64, + /// Bold text. + #[serde(default)] + bold: Option, + /// Italic text. + #[serde(default)] + italic: Option, + /// Font size. + #[serde(default)] + font_size: Option, + /// Text color as hex (e.g., "#FF0000"). + #[serde(default)] + text_color: Option, + /// Background color as hex (e.g., "#FFFF00"). + #[serde(default)] + background_color: Option, + /// Horizontal alignment: "LEFT", "CENTER", "RIGHT". + #[serde(default)] + horizontal_alignment: Option, + /// Number format pattern (e.g., "#,##0.00", "yyyy-mm-dd"). + #[serde(default)] + number_format: Option, + /// Number format type: "NUMBER", "CURRENCY", "PERCENT", "DATE", "TIME", "TEXT". + #[serde(default)] + number_format_type: Option, + }, +} + +fn default_value_input_option() -> String { + "USER_ENTERED".to_string() +} + +/// Sheet (tab) info within a spreadsheet. +#[derive(Debug, Serialize)] +pub struct SheetInfo { + pub sheet_id: i64, + pub title: String, + pub index: i64, + pub row_count: i64, + pub column_count: i64, +} + +/// Named range within a spreadsheet. +#[derive(Debug, Serialize)] +pub struct NamedRange { + pub named_range_id: String, + pub name: String, + pub range: String, +} + +/// Result from create_spreadsheet. +#[derive(Debug, Serialize)] +pub struct CreateSpreadsheetResult { + pub spreadsheet_id: String, + pub title: String, + pub url: String, + pub sheets: Vec, +} + +/// Result from get_spreadsheet. +#[derive(Debug, Serialize)] +pub struct SpreadsheetMetadata { + pub spreadsheet_id: String, + pub title: String, + pub url: String, + pub sheets: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub named_ranges: Vec, +} + +/// Result from read_values. +#[derive(Debug, Serialize)] +pub struct ValuesResult { + pub range: String, + pub values: Vec>, +} + +/// Result from batch_read_values. +#[derive(Debug, Serialize)] +pub struct BatchValuesResult { + pub value_ranges: Vec, +} + +/// Result from write_values or append_values. +#[derive(Debug, Serialize)] +pub struct UpdateResult { + pub updated_range: String, + pub updated_rows: i64, + pub updated_columns: i64, + pub updated_cells: i64, +} + +/// Result from clear_values. +#[derive(Debug, Serialize)] +pub struct ClearResult { + pub cleared_range: String, +} + +/// Result from add_sheet. +#[derive(Debug, Serialize)] +pub struct AddSheetResult { + pub sheet: SheetInfo, +} + +/// Result from delete_sheet or rename_sheet. +#[derive(Debug, Serialize)] +pub struct SheetOperationResult { + pub spreadsheet_id: String, + pub success: bool, +} + +/// Result from format_cells. +#[derive(Debug, Serialize)] +pub struct FormatResult { + pub spreadsheet_id: String, + pub success: bool, +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-sheets/wasm/google_sheets_tool.wasm b/crates/ironclaw_first_party_extensions/assets/google-sheets/wasm/google_sheets_tool.wasm new file mode 100644 index 00000000000..35fd2b7d5df Binary files /dev/null and b/crates/ironclaw_first_party_extensions/assets/google-sheets/wasm/google_sheets_tool.wasm differ diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/manifest.toml b/crates/ironclaw_first_party_extensions/assets/google-slides/manifest.toml new file mode 100644 index 00000000000..9561ab0de65 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/manifest.toml @@ -0,0 +1,206 @@ +schema_version = "reborn.extension_manifest.v2" +id = "google-slides" +name = "Google Slides" +version = "0.1.0" +description = "Google Slides capabilities for creating, reading, editing, and formatting presentations." +trust = "first_party_requested" + +[runtime] +kind = "wasm" +module = "wasm/google_slides_tool.wasm" + +[[capabilities]] +id = "google-slides.create_presentation" +description = "Create a new presentation." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/presentations"], audience = { scheme = "https", host_pattern = "slides.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-slides/create_presentation.input.v1.json" +output_schema_ref = "schemas/google-slides/raw_output.v1.json" +prompt_doc_ref = "prompts/google-slides/create_presentation.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-slides.get_presentation" +description = "Get presentation metadata." +effects = ["dispatch_capability", "network", "use_secret"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/presentations.readonly"], audience = { scheme = "https", host_pattern = "slides.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-slides/get_presentation.input.v1.json" +output_schema_ref = "schemas/google-slides/raw_output.v1.json" +prompt_doc_ref = "prompts/google-slides/get_presentation.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-slides.get_thumbnail" +description = "Get a slide thumbnail URL." +effects = ["dispatch_capability", "network", "use_secret"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/presentations.readonly"], audience = { scheme = "https", host_pattern = "slides.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-slides/get_thumbnail.input.v1.json" +output_schema_ref = "schemas/google-slides/raw_output.v1.json" +prompt_doc_ref = "prompts/google-slides/get_thumbnail.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-slides.create_slide" +description = "Create a new slide." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/presentations"], audience = { scheme = "https", host_pattern = "slides.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-slides/create_slide.input.v1.json" +output_schema_ref = "schemas/google-slides/raw_output.v1.json" +prompt_doc_ref = "prompts/google-slides/create_slide.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-slides.delete_object" +description = "Delete a slide or page element." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/presentations"], audience = { scheme = "https", host_pattern = "slides.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-slides/delete_object.input.v1.json" +output_schema_ref = "schemas/google-slides/raw_output.v1.json" +prompt_doc_ref = "prompts/google-slides/delete_object.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-slides.insert_text" +description = "Insert text into a shape." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/presentations"], audience = { scheme = "https", host_pattern = "slides.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-slides/insert_text.input.v1.json" +output_schema_ref = "schemas/google-slides/raw_output.v1.json" +prompt_doc_ref = "prompts/google-slides/insert_text.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-slides.delete_text" +description = "Delete text from a shape." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/presentations"], audience = { scheme = "https", host_pattern = "slides.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-slides/delete_text.input.v1.json" +output_schema_ref = "schemas/google-slides/raw_output.v1.json" +prompt_doc_ref = "prompts/google-slides/delete_text.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-slides.replace_all_text" +description = "Find and replace text." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/presentations"], audience = { scheme = "https", host_pattern = "slides.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-slides/replace_all_text.input.v1.json" +output_schema_ref = "schemas/google-slides/raw_output.v1.json" +prompt_doc_ref = "prompts/google-slides/replace_all_text.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-slides.create_shape" +description = "Create a shape or text box." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/presentations"], audience = { scheme = "https", host_pattern = "slides.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-slides/create_shape.input.v1.json" +output_schema_ref = "schemas/google-slides/raw_output.v1.json" +prompt_doc_ref = "prompts/google-slides/create_shape.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-slides.insert_image" +description = "Insert an image on a slide." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/presentations"], audience = { scheme = "https", host_pattern = "slides.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-slides/insert_image.input.v1.json" +output_schema_ref = "schemas/google-slides/raw_output.v1.json" +prompt_doc_ref = "prompts/google-slides/insert_image.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-slides.format_text" +description = "Format text in a shape." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/presentations"], audience = { scheme = "https", host_pattern = "slides.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-slides/format_text.input.v1.json" +output_schema_ref = "schemas/google-slides/raw_output.v1.json" +prompt_doc_ref = "prompts/google-slides/format_text.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-slides.format_paragraph" +description = "Set paragraph alignment." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/presentations"], audience = { scheme = "https", host_pattern = "slides.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-slides/format_paragraph.input.v1.json" +output_schema_ref = "schemas/google-slides/raw_output.v1.json" +prompt_doc_ref = "prompts/google-slides/format_paragraph.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-slides.replace_shapes_with_image" +description = "Replace matching shapes with an image." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/presentations"], audience = { scheme = "https", host_pattern = "slides.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-slides/replace_shapes_with_image.input.v1.json" +output_schema_ref = "schemas/google-slides/raw_output.v1.json" +prompt_doc_ref = "prompts/google-slides/replace_shapes_with_image.md" +required_host_ports = ["host.runtime.http_egress"] + +[[capabilities]] +id = "google-slides.batch_update" +description = "Execute raw Slides API batchUpdate requests." +effects = ["dispatch_capability", "network", "use_secret", "external_write"] +runtime_credentials = [ + { handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/presentations"], audience = { scheme = "https", host_pattern = "slides.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } }, +] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/google-slides/batch_update.input.v1.json" +output_schema_ref = "schemas/google-slides/raw_output.v1.json" +prompt_doc_ref = "prompts/google-slides/batch_update.md" +required_host_ports = ["host.runtime.http_egress"] diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/batch_update.md b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/batch_update.md new file mode 100644 index 00000000000..12e8b573b26 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/batch_update.md @@ -0,0 +1,3 @@ +Execute raw Slides API batchUpdate requests. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/create_presentation.md b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/create_presentation.md new file mode 100644 index 00000000000..f70cce8317f --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/create_presentation.md @@ -0,0 +1,3 @@ +Create a new presentation. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/create_shape.md b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/create_shape.md new file mode 100644 index 00000000000..493e511cd27 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/create_shape.md @@ -0,0 +1,3 @@ +Create a shape or text box. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/create_slide.md b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/create_slide.md new file mode 100644 index 00000000000..c4bbd770c27 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/create_slide.md @@ -0,0 +1,3 @@ +Create a new slide. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/delete_object.md b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/delete_object.md new file mode 100644 index 00000000000..7563bb6e56d --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/delete_object.md @@ -0,0 +1,3 @@ +Delete a slide or page element. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/delete_text.md b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/delete_text.md new file mode 100644 index 00000000000..61d60ed89da --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/delete_text.md @@ -0,0 +1,3 @@ +Delete text from a shape. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/format_paragraph.md b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/format_paragraph.md new file mode 100644 index 00000000000..80a31fbd473 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/format_paragraph.md @@ -0,0 +1,3 @@ +Set paragraph alignment. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/format_text.md b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/format_text.md new file mode 100644 index 00000000000..a5107f960d1 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/format_text.md @@ -0,0 +1,3 @@ +Format text in a shape. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/get_presentation.md b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/get_presentation.md new file mode 100644 index 00000000000..34c60faeeaf --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/get_presentation.md @@ -0,0 +1,3 @@ +Get presentation metadata. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/get_thumbnail.md b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/get_thumbnail.md new file mode 100644 index 00000000000..ddf4759bf67 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/get_thumbnail.md @@ -0,0 +1,3 @@ +Get a slide thumbnail URL. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/insert_image.md b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/insert_image.md new file mode 100644 index 00000000000..f39a24119b9 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/insert_image.md @@ -0,0 +1,3 @@ +Insert an image on a slide. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/insert_text.md b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/insert_text.md new file mode 100644 index 00000000000..39686ec870c --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/insert_text.md @@ -0,0 +1,3 @@ +Insert text into a shape. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/replace_all_text.md b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/replace_all_text.md new file mode 100644 index 00000000000..280266951d6 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/replace_all_text.md @@ -0,0 +1,3 @@ +Find and replace text. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/replace_shapes_with_image.md b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/replace_shapes_with_image.md new file mode 100644 index 00000000000..b18cd4b3544 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/prompts/google-slides/replace_shapes_with_image.md @@ -0,0 +1,3 @@ +Replace matching shapes with an image. + +The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field. diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/batch_update.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/batch_update.input.v1.json new file mode 100644 index 00000000000..cb718e67a2c --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/batch_update.input.v1.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Slides batch_update", + "description": "Execute raw Slides API batchUpdate requests.", + "type": "object", + "required": [ + "presentation_id", + "requests" + ], + "properties": { + "presentation_id": { + "type": "string", + "description": "The presentation ID." + }, + "requests": { + "type": "array", + "items": { + "description": "Raw Slides API request object." + }, + "description": "Slides API request objects." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/create_presentation.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/create_presentation.input.v1.json new file mode 100644 index 00000000000..fd1ac1e8082 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/create_presentation.input.v1.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Slides create_presentation", + "description": "Create a new presentation.", + "type": "object", + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "description": "Presentation title." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/create_shape.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/create_shape.input.v1.json new file mode 100644 index 00000000000..3c13dfc1149 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/create_shape.input.v1.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Slides create_shape", + "description": "Create a shape or text box.", + "type": "object", + "required": [ + "presentation_id", + "slide_object_id", + "x", + "y", + "width", + "height" + ], + "properties": { + "presentation_id": { + "type": "string", + "description": "The presentation ID." + }, + "slide_object_id": { + "type": "string", + "description": "Slide object ID." + }, + "shape_type": { + "type": "string", + "description": "Shape type. Defaults to TEXT_BOX." + }, + "x": { + "type": "number", + "description": "X position in points." + }, + "y": { + "type": "number", + "description": "Y position in points." + }, + "width": { + "type": "number", + "description": "Width in points." + }, + "height": { + "type": "number", + "description": "Height in points." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/create_slide.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/create_slide.input.v1.json new file mode 100644 index 00000000000..3f25e643f45 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/create_slide.input.v1.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Slides create_slide", + "description": "Create a new slide.", + "type": "object", + "required": [ + "presentation_id" + ], + "properties": { + "presentation_id": { + "type": "string", + "description": "The presentation ID." + }, + "insertion_index": { + "type": [ + "integer", + "null" + ], + "description": "Insertion index." + }, + "layout": { + "type": "string", + "description": "Predefined layout. Defaults to BLANK." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/delete_object.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/delete_object.input.v1.json new file mode 100644 index 00000000000..3da5d3be81f --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/delete_object.input.v1.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Slides delete_object", + "description": "Delete a slide or page element.", + "type": "object", + "required": [ + "presentation_id", + "object_id" + ], + "properties": { + "presentation_id": { + "type": "string", + "description": "The presentation ID." + }, + "object_id": { + "type": "string", + "description": "Object ID to delete." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/delete_text.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/delete_text.input.v1.json new file mode 100644 index 00000000000..39d5abb8687 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/delete_text.input.v1.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Slides delete_text", + "description": "Delete text from a shape.", + "type": "object", + "required": [ + "presentation_id", + "object_id" + ], + "properties": { + "presentation_id": { + "type": "string", + "description": "The presentation ID." + }, + "object_id": { + "type": "string", + "description": "Shape object ID." + }, + "start_index": { + "type": "integer", + "description": "Start index. Defaults to 0." + }, + "end_index": { + "type": [ + "integer", + "null" + ], + "description": "End index exclusive." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/format_paragraph.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/format_paragraph.input.v1.json new file mode 100644 index 00000000000..b1eaa83210d --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/format_paragraph.input.v1.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Slides format_paragraph", + "description": "Set paragraph alignment.", + "type": "object", + "required": [ + "presentation_id", + "object_id", + "alignment" + ], + "properties": { + "presentation_id": { + "type": "string", + "description": "The presentation ID." + }, + "object_id": { + "type": "string", + "description": "Shape object ID." + }, + "alignment": { + "type": "string", + "description": "Paragraph alignment." + }, + "start_index": { + "type": [ + "integer", + "null" + ], + "description": "Start index." + }, + "end_index": { + "type": [ + "integer", + "null" + ], + "description": "End index." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/format_text.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/format_text.input.v1.json new file mode 100644 index 00000000000..f902612ed7d --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/format_text.input.v1.json @@ -0,0 +1,77 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Slides format_text", + "description": "Format text in a shape.", + "type": "object", + "required": [ + "presentation_id", + "object_id" + ], + "properties": { + "presentation_id": { + "type": "string", + "description": "The presentation ID." + }, + "object_id": { + "type": "string", + "description": "Shape object ID." + }, + "start_index": { + "type": [ + "integer", + "null" + ], + "description": "Start index." + }, + "end_index": { + "type": [ + "integer", + "null" + ], + "description": "End index." + }, + "bold": { + "type": [ + "boolean", + "null" + ], + "description": "Bold text." + }, + "italic": { + "type": [ + "boolean", + "null" + ], + "description": "Italic text." + }, + "underline": { + "type": [ + "boolean", + "null" + ], + "description": "Underline text." + }, + "font_size": { + "type": [ + "number", + "null" + ], + "description": "Font size." + }, + "font_family": { + "type": [ + "string", + "null" + ], + "description": "Font family." + }, + "foreground_color": { + "type": [ + "string", + "null" + ], + "description": "Text color hex." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/get_presentation.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/get_presentation.input.v1.json new file mode 100644 index 00000000000..089b8d08e3e --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/get_presentation.input.v1.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Slides get_presentation", + "description": "Get presentation metadata.", + "type": "object", + "required": [ + "presentation_id" + ], + "properties": { + "presentation_id": { + "type": "string", + "description": "The presentation ID." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/get_thumbnail.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/get_thumbnail.input.v1.json new file mode 100644 index 00000000000..b35a1dafb5d --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/get_thumbnail.input.v1.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Slides get_thumbnail", + "description": "Get a slide thumbnail URL.", + "type": "object", + "required": [ + "presentation_id", + "slide_object_id" + ], + "properties": { + "presentation_id": { + "type": "string", + "description": "The presentation ID." + }, + "slide_object_id": { + "type": "string", + "description": "Slide object ID." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/insert_image.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/insert_image.input.v1.json new file mode 100644 index 00000000000..2f505b4fe6d --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/insert_image.input.v1.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Slides insert_image", + "description": "Insert an image on a slide.", + "type": "object", + "required": [ + "presentation_id", + "slide_object_id", + "image_url", + "x", + "y", + "width", + "height" + ], + "properties": { + "presentation_id": { + "type": "string", + "description": "The presentation ID." + }, + "slide_object_id": { + "type": "string", + "description": "Slide object ID." + }, + "image_url": { + "type": "string", + "description": "Public image URL." + }, + "x": { + "type": "number", + "description": "X position in points." + }, + "y": { + "type": "number", + "description": "Y position in points." + }, + "width": { + "type": "number", + "description": "Width in points." + }, + "height": { + "type": "number", + "description": "Height in points." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/insert_text.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/insert_text.input.v1.json new file mode 100644 index 00000000000..31d32c153da --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/insert_text.input.v1.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Slides insert_text", + "description": "Insert text into a shape.", + "type": "object", + "required": [ + "presentation_id", + "object_id", + "text" + ], + "properties": { + "presentation_id": { + "type": "string", + "description": "The presentation ID." + }, + "object_id": { + "type": "string", + "description": "Shape/text box object ID." + }, + "text": { + "type": "string", + "description": "Text to insert." + }, + "insertion_index": { + "type": "integer", + "description": "Character index. Defaults to 0." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/raw_output.v1.json b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/raw_output.v1.json new file mode 100644 index 00000000000..df8935f40c7 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/raw_output.v1.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Raw Google API Output", + "description": "Google API response serialized by the WASM tool.", + "type": "object", + "additionalProperties": true +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/replace_all_text.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/replace_all_text.input.v1.json new file mode 100644 index 00000000000..df05dc9de69 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/replace_all_text.input.v1.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Slides replace_all_text", + "description": "Find and replace text.", + "type": "object", + "required": [ + "presentation_id", + "find", + "replace" + ], + "properties": { + "presentation_id": { + "type": "string", + "description": "The presentation ID." + }, + "find": { + "type": "string", + "description": "Text to find." + }, + "replace": { + "type": "string", + "description": "Replacement text." + }, + "match_case": { + "type": "boolean", + "description": "Case-sensitive match. Defaults to true." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/replace_shapes_with_image.input.v1.json b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/replace_shapes_with_image.input.v1.json new file mode 100644 index 00000000000..57a3f20147c --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/schemas/google-slides/replace_shapes_with_image.input.v1.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Google Slides replace_shapes_with_image", + "description": "Replace matching shapes with an image.", + "type": "object", + "required": [ + "presentation_id", + "find", + "image_url" + ], + "properties": { + "presentation_id": { + "type": "string", + "description": "The presentation ID." + }, + "find": { + "type": "string", + "description": "Text to match in shapes." + }, + "image_url": { + "type": "string", + "description": "Replacement image URL." + }, + "match_case": { + "type": "boolean", + "description": "Case-sensitive match. Defaults to true." + } + }, + "additionalProperties": false +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/wasm-src/Cargo.toml b/crates/ironclaw_first_party_extensions/assets/google-slides/wasm-src/Cargo.toml new file mode 100644 index 00000000000..15fe0d84e21 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/wasm-src/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "google-slides-tool" +version = "0.2.0" +edition = "2021" +description = "Google Slides integration tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +urlencoding = "2" +# Auto-derives JSON Schema from the GoogleSlidesAction tagged enum so the +# advertised schema mirrors the serde-enforced contract. +schemars = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 + +[workspace] diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/wasm-src/src/api.rs b/crates/ironclaw_first_party_extensions/assets/google-slides/wasm-src/src/api.rs new file mode 100644 index 00000000000..ad954f800d7 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/wasm-src/src/api.rs @@ -0,0 +1,603 @@ +//! Google Slides API v1 implementation. +//! +//! All API calls go through the host's HTTP capability, which handles +//! credential injection and rate limiting. The WASM tool never sees +//! the actual OAuth token. + +use crate::near::agent::host; +use crate::types::*; + +const SLIDES_API_BASE: &str = "https://slides.googleapis.com/v1/presentations"; + +/// Make a Google Slides API call. +fn api_call(method: &str, path: &str, body: Option<&str>) -> Result { + let url = if path.is_empty() { + SLIDES_API_BASE.to_string() + } else { + format!("{}/{}", SLIDES_API_BASE, path) + }; + + let headers = if body.is_some() { + r#"{"Content-Type": "application/json"}"# + } else { + "{}" + }; + + let body_bytes = body.map(|b| b.as_bytes().to_vec()); + + host::log( + host::LogLevel::Debug, + &format!("Google Slides API: {} {}", method, url), + ); + + let response = host::http_request(method, &url, headers, body_bytes.as_deref(), None)?; + + if response.status < 200 || response.status >= 300 { + let body_text = String::from_utf8_lossy(&response.body); + return Err(format!( + "Google Slides API returned status {}: {}", + response.status, body_text + )); + } + + if response.body.is_empty() { + return Ok(String::new()); + } + + String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 in response: {}", e)) +} + +/// Send a batchUpdate to the presentation. +fn batch_update_raw( + presentation_id: &str, + requests: Vec, +) -> Result { + let path = format!("{}:batchUpdate", url_encode(presentation_id)); + + let body = serde_json::json!({ "requests": requests }); + let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; + + let response = api_call("POST", &path, Some(&body_str))?; + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e)) +} + +/// Extract text content from a shape's textElements array. +fn extract_text_from_shape(shape: &serde_json::Value) -> Option { + let text_elements = shape["text"]["textElements"].as_array()?; + let mut text = String::new(); + for el in text_elements { + if let Some(content) = el["textRun"]["content"].as_str() { + text.push_str(content); + } + } + if text.is_empty() { + None + } else { + Some(text) + } +} + +/// Parse a page element into ElementInfo. +fn parse_element(el: &serde_json::Value) -> ElementInfo { + let object_id = el["objectId"].as_str().unwrap_or("").to_string(); + + let (element_type, text_content, placeholder_type) = if el.get("shape").is_some() { + let pt = el["shape"]["placeholder"]["type"] + .as_str() + .map(|s| s.to_string()); + let text = extract_text_from_shape(&el["shape"]); + ("shape".to_string(), text, pt) + } else if el.get("image").is_some() { + ("image".to_string(), None, None) + } else if el.get("table").is_some() { + ("table".to_string(), None, None) + } else if el.get("line").is_some() { + ("line".to_string(), None, None) + } else if el.get("video").is_some() { + ("video".to_string(), None, None) + } else if el.get("elementGroup").is_some() { + ("group".to_string(), None, None) + } else { + ("unknown".to_string(), None, None) + }; + + ElementInfo { + object_id, + element_type, + text_content, + placeholder_type, + } +} + +/// Create a new presentation. +pub fn create_presentation(title: &str) -> Result { + let body = serde_json::json!({ "title": title }); + let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; + + let response = api_call("POST", "", Some(&body_str))?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(CreatePresentationResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + title: parsed["title"].as_str().unwrap_or("").to_string(), + }) +} + +/// Get presentation metadata and slides. +pub fn get_presentation(presentation_id: &str) -> Result { + let path = url_encode(presentation_id); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let slides: Vec = parsed["slides"] + .as_array() + .map(|arr| { + arr.iter() + .map(|slide| { + let elements = slide["pageElements"] + .as_array() + .map(|els| els.iter().map(parse_element).collect()) + .unwrap_or_default(); + + SlideInfo { + object_id: slide["objectId"].as_str().unwrap_or("").to_string(), + layout_object_id: slide["slideProperties"]["layoutObjectId"] + .as_str() + .unwrap_or("") + .to_string(), + elements, + } + }) + .collect() + }) + .unwrap_or_default(); + + let slide_count = slides.len(); + + Ok(PresentationMetadata { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + title: parsed["title"].as_str().unwrap_or("").to_string(), + revision_id: parsed["revisionId"].as_str().unwrap_or("").to_string(), + slide_count, + slides, + }) +} + +/// Get a thumbnail URL for a slide. +pub fn get_thumbnail( + presentation_id: &str, + slide_object_id: &str, +) -> Result { + let path = format!( + "{}/pages/{}/thumbnail", + url_encode(presentation_id), + url_encode(slide_object_id) + ); + + let response = api_call("GET", &path, None)?; + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(ThumbnailResult { + content_url: parsed["contentUrl"].as_str().unwrap_or("").to_string(), + width: parsed["width"].as_i64().unwrap_or(0), + height: parsed["height"].as_i64().unwrap_or(0), + }) +} + +/// Create a new slide. +pub fn create_slide( + presentation_id: &str, + insertion_index: Option, + layout: &str, +) -> Result { + let mut request = serde_json::json!({ + "createSlide": { + "slideLayoutReference": { + "predefinedLayout": layout, + } + } + }); + + if let Some(idx) = insertion_index { + request["createSlide"]["insertionIndex"] = serde_json::json!(idx); + } + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + let created_id = parsed["replies"][0]["createSlide"]["objectId"] + .as_str() + .map(|s| s.to_string()); + + Ok(UpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + created_object_id: created_id, + }) +} + +/// Delete a slide or page element. +pub fn delete_object(presentation_id: &str, object_id: &str) -> Result { + let request = serde_json::json!({ + "deleteObject": { "objectId": object_id } + }); + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + Ok(UpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + created_object_id: None, + }) +} + +/// Insert text into a shape. +pub fn insert_text( + presentation_id: &str, + object_id: &str, + text: &str, + insertion_index: i64, +) -> Result { + let request = serde_json::json!({ + "insertText": { + "objectId": object_id, + "text": text, + "insertionIndex": insertion_index, + } + }); + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + Ok(UpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + created_object_id: None, + }) +} + +/// Delete text from a shape. +pub fn delete_text( + presentation_id: &str, + object_id: &str, + start_index: i64, + end_index: Option, +) -> Result { + let text_range = if let Some(end) = end_index { + serde_json::json!({ + "type": "FIXED_RANGE", + "startIndex": start_index, + "endIndex": end, + }) + } else { + serde_json::json!({ + "type": "FROM_START_INDEX", + "startIndex": start_index, + }) + }; + + let request = serde_json::json!({ + "deleteText": { + "objectId": object_id, + "textRange": text_range, + } + }); + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + Ok(UpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + created_object_id: None, + }) +} + +/// Find and replace text across the presentation. +pub fn replace_all_text( + presentation_id: &str, + find: &str, + replace: &str, + match_case: bool, +) -> Result { + let request = serde_json::json!({ + "replaceAllText": { + "containsText": { + "text": find, + "matchCase": match_case, + }, + "replaceText": replace, + } + }); + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + let occurrences = parsed["replies"][0]["replaceAllText"]["occurrencesChanged"] + .as_i64() + .unwrap_or(0); + + Ok(ReplaceResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + occurrences_changed: occurrences, + }) +} + +/// Points to EMU (English Metric Units). 1 point = 12700 EMU. +fn pt_to_emu(pt: f64) -> f64 { + pt * 12700.0 +} + +/// Create a shape on a slide. +pub fn create_shape( + presentation_id: &str, + slide_object_id: &str, + shape_type: &str, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result { + let request = serde_json::json!({ + "createShape": { + "shapeType": shape_type, + "elementProperties": { + "pageObjectId": slide_object_id, + "size": { + "width": { "magnitude": pt_to_emu(width), "unit": "EMU" }, + "height": { "magnitude": pt_to_emu(height), "unit": "EMU" }, + }, + "transform": { + "scaleX": 1.0, + "scaleY": 1.0, + "shearX": 0.0, + "shearY": 0.0, + "translateX": pt_to_emu(x), + "translateY": pt_to_emu(y), + "unit": "EMU", + }, + }, + } + }); + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + let created_id = parsed["replies"][0]["createShape"]["objectId"] + .as_str() + .map(|s| s.to_string()); + + Ok(UpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + created_object_id: created_id, + }) +} + +/// Insert an image on a slide. +pub fn insert_image( + presentation_id: &str, + slide_object_id: &str, + image_url: &str, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result { + let request = serde_json::json!({ + "createImage": { + "url": image_url, + "elementProperties": { + "pageObjectId": slide_object_id, + "size": { + "width": { "magnitude": pt_to_emu(width), "unit": "EMU" }, + "height": { "magnitude": pt_to_emu(height), "unit": "EMU" }, + }, + "transform": { + "scaleX": 1.0, + "scaleY": 1.0, + "shearX": 0.0, + "shearY": 0.0, + "translateX": pt_to_emu(x), + "translateY": pt_to_emu(y), + "unit": "EMU", + }, + }, + } + }); + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + let created_id = parsed["replies"][0]["createImage"]["objectId"] + .as_str() + .map(|s| s.to_string()); + + Ok(UpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + created_object_id: created_id, + }) +} + +/// Parse a hex color like "#FF0000" into Slides API color format. +fn parse_hex_color(hex: &str) -> Option { + let hex = hex.strip_prefix('#').unwrap_or(hex); + if hex.len() != 6 { + return None; + } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(serde_json::json!({ + "opaqueColor": { + "rgbColor": { + "red": r as f64 / 255.0, + "green": g as f64 / 255.0, + "blue": b as f64 / 255.0, + } + } + })) +} + +/// Parameters for text formatting. +pub struct FormatTextOptions<'a> { + pub presentation_id: &'a str, + pub object_id: &'a str, + pub start_index: Option, + pub end_index: Option, + pub bold: Option, + pub italic: Option, + pub underline: Option, + pub font_size: Option, + pub font_family: Option<&'a str>, + pub foreground_color: Option<&'a str>, +} + +/// Format text in a shape. +pub fn format_text(opts: FormatTextOptions<'_>) -> Result { + let mut style = serde_json::json!({}); + let mut fields = Vec::new(); + + if let Some(b) = opts.bold { + style["bold"] = serde_json::Value::Bool(b); + fields.push("bold"); + } + if let Some(i) = opts.italic { + style["italic"] = serde_json::Value::Bool(i); + fields.push("italic"); + } + if let Some(u) = opts.underline { + style["underline"] = serde_json::Value::Bool(u); + fields.push("underline"); + } + if let Some(size) = opts.font_size { + style["fontSize"] = serde_json::json!({ "magnitude": size, "unit": "PT" }); + fields.push("fontSize"); + } + if let Some(family) = opts.font_family { + style["fontFamily"] = serde_json::Value::String(family.to_string()); + fields.push("fontFamily"); + } + if let Some(color) = opts.foreground_color { + if let Some(c) = parse_hex_color(color) { + style["foregroundColor"] = c; + fields.push("foregroundColor"); + } + } + + if fields.is_empty() { + return Err("No formatting options specified".to_string()); + } + + let text_range = match (opts.start_index, opts.end_index) { + (Some(start), Some(end)) => serde_json::json!({ + "type": "FIXED_RANGE", + "startIndex": start, + "endIndex": end, + }), + (Some(start), None) => serde_json::json!({ + "type": "FROM_START_INDEX", + "startIndex": start, + }), + _ => serde_json::json!({ "type": "ALL" }), + }; + + let request = serde_json::json!({ + "updateTextStyle": { + "objectId": opts.object_id, + "textRange": text_range, + "style": style, + "fields": fields.join(","), + } + }); + + let parsed = batch_update_raw(opts.presentation_id, vec![request])?; + + Ok(UpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + created_object_id: None, + }) +} + +/// Format paragraph alignment in a shape. +pub fn format_paragraph( + presentation_id: &str, + object_id: &str, + alignment: &str, + start_index: Option, + end_index: Option, +) -> Result { + let text_range = match (start_index, end_index) { + (Some(start), Some(end)) => serde_json::json!({ + "type": "FIXED_RANGE", + "startIndex": start, + "endIndex": end, + }), + (Some(start), None) => serde_json::json!({ + "type": "FROM_START_INDEX", + "startIndex": start, + }), + _ => serde_json::json!({ "type": "ALL" }), + }; + + let request = serde_json::json!({ + "updateParagraphStyle": { + "objectId": object_id, + "textRange": text_range, + "style": { "alignment": alignment }, + "fields": "alignment", + } + }); + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + Ok(UpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + created_object_id: None, + }) +} + +/// Replace all shapes containing text with an image. +pub fn replace_shapes_with_image( + presentation_id: &str, + find: &str, + image_url: &str, + match_case: bool, +) -> Result { + let request = serde_json::json!({ + "replaceAllShapesWithImage": { + "containsText": { + "text": find, + "matchCase": match_case, + }, + "imageUrl": image_url, + "imageReplaceMethod": "CENTER_INSIDE", + } + }); + + let parsed = batch_update_raw(presentation_id, vec![request])?; + + let occurrences = parsed["replies"][0]["replaceAllShapesWithImage"]["occurrencesChanged"] + .as_i64() + .unwrap_or(0); + + Ok(ReplaceResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + occurrences_changed: occurrences, + }) +} + +/// Execute a raw batch update with arbitrary requests. +pub fn batch_update( + presentation_id: &str, + requests: Vec, +) -> Result { + let parsed = batch_update_raw(presentation_id, requests)?; + + let replies = parsed["replies"] + .as_array() + .map(|arr| arr.to_vec()) + .unwrap_or_default(); + + Ok(BatchUpdateResult { + presentation_id: parsed["presentationId"].as_str().unwrap_or("").to_string(), + replies, + }) +} + +fn url_encode(s: &str) -> String { + urlencoding::encode(s).into_owned() +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/wasm-src/src/lib.rs b/crates/ironclaw_first_party_extensions/assets/google-slides/wasm-src/src/lib.rs new file mode 100644 index 00000000000..e3af264e910 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/wasm-src/src/lib.rs @@ -0,0 +1,329 @@ +//! Google Slides WASM Tool for IronClaw. +//! +//! Provides Google Slides integration for creating, reading, editing, +//! and formatting presentations. Use Google Drive tool to search for +//! existing presentations by name. +//! +//! # Capabilities Required +//! +//! - HTTP: `slides.googleapis.com/v1/presentations*` +//! - Credentials: staged Google product-auth account token injected by the host. +//! +//! # Supported Actions +//! +//! - `create_presentation`: Create a new blank presentation +//! - `get_presentation`: Get presentation metadata (slides, elements, text) +//! - `get_thumbnail`: Get a thumbnail image URL for a slide +//! - `create_slide`: Add a new slide with a predefined layout +//! - `delete_object`: Delete a slide or page element +//! - `insert_text`: Insert text into a shape or text box +//! - `delete_text`: Delete text from a shape +//! - `replace_all_text`: Find and replace text across the presentation +//! - `create_shape`: Create a text box or shape on a slide +//! - `insert_image`: Insert an image on a slide +//! - `format_text`: Format text (bold, italic, font, color, size) +//! - `format_paragraph`: Set paragraph alignment +//! - `replace_shapes_with_image`: Replace placeholder shapes with an image +//! - `batch_update`: Execute multiple raw Slides API operations atomically +//! +//! # Tips +//! +//! - Presentation IDs are the same as Google Drive file IDs. Use +//! google-drive tool's list_files to find presentations. +//! - Positions and sizes are specified in points (1 inch = 72 points). +//! A standard slide is 720x405 points (10x5.625 inches). +//! - To add text to a slide: first create_shape (TEXT_BOX), then +//! insert_text into the returned object_id. +//! - Use get_presentation to discover object IDs for existing elements. +//! - For template workflows: create shapes with placeholder text, then +//! use replace_all_text or replace_shapes_with_image. +//! +//! # Example Usage +//! +//! ```json +//! {"action": "create_presentation", "title": "Q1 Report"} +//! {"action": "create_slide", "presentation_id": "abc123", "layout": "TITLE_AND_BODY"} +//! {"action": "get_presentation", "presentation_id": "abc123"} +//! {"action": "create_shape", "presentation_id": "abc123", "slide_object_id": "slide1", "shape_type": "TEXT_BOX", "x": 50, "y": 50, "width": 300, "height": 40} +//! {"action": "insert_text", "presentation_id": "abc123", "object_id": "shape1", "text": "Hello World"} +//! {"action": "format_text", "presentation_id": "abc123", "object_id": "shape1", "bold": true, "font_size": 24} +//! ``` + +mod api; +mod types; + +use types::{GoogleSlidesAction, ToolContext}; + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../../../../wit/tool.wit", +}); + +struct GoogleSlidesTool; + +impl exports::near::agent::tool::Guest for GoogleSlidesTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params, req.context.as_deref()) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + // Derived from `GoogleSlidesAction` via `schemars::JsonSchema` so the + // advertised schema can never drift from the serde contract. + let schema = schemars::schema_for!(types::GoogleSlidesAction); + serde_json::to_string(&schema).unwrap_or_else(|_| "{}".to_string()) + } + + fn description() -> String { + "Google Slides integration for creating, reading, editing, and formatting presentations. \ + Supports slide management (create, delete, reorder), text operations (insert, delete, \ + find-replace), shapes and text boxes, image insertion, text formatting (bold, italic, \ + font, color, size), paragraph alignment, thumbnails, and template-based image replacement. \ + Also provides a batch_update action for complex multi-step edits executed atomically. \ + Positions and sizes use points (standard slide is 720x405 pt). Presentation IDs are the \ + same as Google Drive file IDs, so use the google-drive tool to search for existing \ + presentations. The host injects a Google product-auth credential with the presentations \ + scope. \ + To discover all available API operations, use http GET to fetch \ + (public, no auth needed)." + .to_string() + } +} + +fn execute_inner(params: &str, context: Option<&str>) -> Result { + let action_name = action_from_context(context)?; + let params = params_with_action(params, action_name)?; + let action: GoogleSlidesAction = + serde_json::from_value(params).map_err(|e| format!("Invalid parameters: {}", e))?; + + crate::near::agent::host::log( + crate::near::agent::host::LogLevel::Debug, + &format!("Executing Google Slides action: {action_name}"), + ); + + let result = match action { + GoogleSlidesAction::CreatePresentation { title } => { + let result = api::create_presentation(&title)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::GetPresentation { presentation_id } => { + let result = api::get_presentation(&presentation_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::GetThumbnail { + presentation_id, + slide_object_id, + } => { + let result = api::get_thumbnail(&presentation_id, &slide_object_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::CreateSlide { + presentation_id, + insertion_index, + layout, + } => { + let result = api::create_slide(&presentation_id, insertion_index, &layout)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::DeleteObject { + presentation_id, + object_id, + } => { + let result = api::delete_object(&presentation_id, &object_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::InsertText { + presentation_id, + object_id, + text, + insertion_index, + } => { + let result = api::insert_text(&presentation_id, &object_id, &text, insertion_index)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::DeleteText { + presentation_id, + object_id, + start_index, + end_index, + } => { + let result = api::delete_text(&presentation_id, &object_id, start_index, end_index)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::ReplaceAllText { + presentation_id, + find, + replace, + match_case, + } => { + let result = api::replace_all_text(&presentation_id, &find, &replace, match_case)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::CreateShape { + presentation_id, + slide_object_id, + shape_type, + x, + y, + width, + height, + } => { + let result = api::create_shape( + &presentation_id, + &slide_object_id, + &shape_type, + x, + y, + width, + height, + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::InsertImage { + presentation_id, + slide_object_id, + image_url, + x, + y, + width, + height, + } => { + let result = api::insert_image( + &presentation_id, + &slide_object_id, + &image_url, + x, + y, + width, + height, + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::FormatText { + presentation_id, + object_id, + start_index, + end_index, + bold, + italic, + underline, + font_size, + font_family, + foreground_color, + } => { + let result = api::format_text(api::FormatTextOptions { + presentation_id: &presentation_id, + object_id: &object_id, + start_index, + end_index, + bold, + italic, + underline, + font_size, + font_family: font_family.as_deref(), + foreground_color: foreground_color.as_deref(), + })?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::FormatParagraph { + presentation_id, + object_id, + alignment, + start_index, + end_index, + } => { + let result = api::format_paragraph( + &presentation_id, + &object_id, + &alignment, + start_index, + end_index, + )?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::ReplaceShapesWithImage { + presentation_id, + find, + image_url, + match_case, + } => { + let result = + api::replace_shapes_with_image(&presentation_id, &find, &image_url, match_case)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + GoogleSlidesAction::BatchUpdate { + presentation_id, + requests, + } => { + let result = api::batch_update(&presentation_id, requests)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + }; + + Ok(result) +} + +fn action_from_context(context: Option<&str>) -> Result<&'static str, String> { + let context = context.ok_or_else(|| "missing_invocation_context".to_string())?; + let context: ToolContext = + serde_json::from_str(context).map_err(|_| "invalid_invocation_context".to_string())?; + match context.capability_id.as_str() { + "google-slides.create_presentation" => Ok("create_presentation"), + "google-slides.get_presentation" => Ok("get_presentation"), + "google-slides.get_thumbnail" => Ok("get_thumbnail"), + "google-slides.create_slide" => Ok("create_slide"), + "google-slides.delete_object" => Ok("delete_object"), + "google-slides.insert_text" => Ok("insert_text"), + "google-slides.delete_text" => Ok("delete_text"), + "google-slides.replace_all_text" => Ok("replace_all_text"), + "google-slides.create_shape" => Ok("create_shape"), + "google-slides.insert_image" => Ok("insert_image"), + "google-slides.format_text" => Ok("format_text"), + "google-slides.format_paragraph" => Ok("format_paragraph"), + "google-slides.replace_shapes_with_image" => Ok("replace_shapes_with_image"), + "google-slides.batch_update" => Ok("batch_update"), + _ => Err("unsupported_google_slides_capability".to_string()), + } +} + +fn params_with_action(params: &str, action: &str) -> Result { + let mut params: serde_json::Value = if params.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(params).map_err(|_| "invalid_parameters".to_string())? + }; + let obj = params + .as_object_mut() + .ok_or_else(|| "invalid_parameters".to_string())?; + if obj.contains_key("action") { + return Err("invalid_parameters".to_string()); + } + obj.insert( + "action".to_string(), + serde_json::Value::String(action.to_string()), + ); + Ok(params) +} + +export!(GoogleSlidesTool); diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/wasm-src/src/types.rs b/crates/ironclaw_first_party_extensions/assets/google-slides/wasm-src/src/types.rs new file mode 100644 index 00000000000..bce4cf44e65 --- /dev/null +++ b/crates/ironclaw_first_party_extensions/assets/google-slides/wasm-src/src/types.rs @@ -0,0 +1,285 @@ +//! Types for Google Slides API requests and responses. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub(crate) struct ToolContext { + pub(crate) capability_id: String, +} + +/// Input parameters for the Google Slides tool. +/// +/// `JsonSchema` is derived so the advertised tool schema mirrors the +/// serde-enforced contract: each variant becomes a `oneOf` entry with +/// its own `required` array. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum GoogleSlidesAction { + /// Create a new presentation. + CreatePresentation { + /// Presentation title. + title: String, + }, + + /// Get presentation metadata (slides, elements, text content). + GetPresentation { + /// The presentation ID (same as Google Drive file ID). + presentation_id: String, + }, + + /// Get a thumbnail image URL for a specific slide. + GetThumbnail { + /// The presentation ID. + presentation_id: String, + /// The slide's object ID. + slide_object_id: String, + }, + + /// Create a new slide. + CreateSlide { + /// The presentation ID. + presentation_id: String, + /// Position to insert (0-based). Omit to append at end. + #[serde(default)] + insertion_index: Option, + /// Predefined layout: "BLANK", "TITLE", "TITLE_AND_BODY", + /// "TITLE_AND_TWO_COLUMNS", "TITLE_ONLY", "SECTION_HEADER", + /// "CAPTION_ONLY", "BIG_NUMBER", "ONE_COLUMN_TEXT", "MAIN_POINT". + #[serde(default = "default_layout")] + layout: String, + }, + + /// Delete a slide or page element. + DeleteObject { + /// The presentation ID. + presentation_id: String, + /// Object ID of the slide or element to delete. + object_id: String, + }, + + /// Insert text into a shape or text box. + InsertText { + /// The presentation ID. + presentation_id: String, + /// Object ID of the shape/text box. + object_id: String, + /// Text to insert. + text: String, + /// Character index to insert at (0-based). Default: 0. + #[serde(default)] + insertion_index: i64, + }, + + /// Delete text from a shape. + DeleteText { + /// The presentation ID. + presentation_id: String, + /// Object ID of the shape. + object_id: String, + /// Start index (inclusive). Use 0 for start. + #[serde(default)] + start_index: i64, + /// End index (exclusive). Omit to delete to end. + #[serde(default)] + end_index: Option, + }, + + /// Find and replace text across the entire presentation. + ReplaceAllText { + /// The presentation ID. + presentation_id: String, + /// Text to find. + find: String, + /// Replacement text. + replace: String, + /// Case-sensitive match (default: true). + #[serde(default = "default_true")] + match_case: bool, + }, + + /// Create a text box or shape on a slide. + CreateShape { + /// The presentation ID. + presentation_id: String, + /// Slide object ID to place the shape on. + slide_object_id: String, + /// Shape type: "TEXT_BOX", "RECTANGLE", "ROUND_RECTANGLE", "ELLIPSE". + #[serde(default = "default_shape_type")] + shape_type: String, + /// X position in points from left edge. + x: f64, + /// Y position in points from top edge. + y: f64, + /// Width in points. + width: f64, + /// Height in points. + height: f64, + }, + + /// Insert an image on a slide. + InsertImage { + /// The presentation ID. + presentation_id: String, + /// Slide object ID to place the image on. + slide_object_id: String, + /// Publicly accessible image URL. + image_url: String, + /// X position in points. + x: f64, + /// Y position in points. + y: f64, + /// Width in points. + width: f64, + /// Height in points. + height: f64, + }, + + /// Format text in a shape (bold, italic, font, color, size). + FormatText { + /// The presentation ID. + presentation_id: String, + /// Object ID of the shape. + object_id: String, + /// Start index (inclusive). Use 0 for start. + #[serde(default)] + start_index: Option, + /// End index (exclusive). Omit to format all text. + #[serde(default)] + end_index: Option, + /// Make text bold. + #[serde(default)] + bold: Option, + /// Make text italic. + #[serde(default)] + italic: Option, + /// Underline text. + #[serde(default)] + underline: Option, + /// Font size in points. + #[serde(default)] + font_size: Option, + /// Font family name (e.g., "Arial"). + #[serde(default)] + font_family: Option, + /// Text color as hex (e.g., "#FF0000"). + #[serde(default)] + foreground_color: Option, + }, + + /// Set paragraph alignment for text in a shape. + FormatParagraph { + /// The presentation ID. + presentation_id: String, + /// Object ID of the shape. + object_id: String, + /// Alignment: "START", "CENTER", "END", "JUSTIFIED". + alignment: String, + /// Start index (inclusive). + #[serde(default)] + start_index: Option, + /// End index (exclusive). Omit to format all. + #[serde(default)] + end_index: Option, + }, + + /// Replace all shapes containing specific text with an image. + ReplaceShapesWithImage { + /// The presentation ID. + presentation_id: String, + /// Text to match in shapes. + find: String, + /// Image URL to replace shapes with. + image_url: String, + /// Case-sensitive match (default: true). + #[serde(default = "default_true")] + match_case: bool, + }, + + /// Execute multiple raw Slides API operations atomically. + BatchUpdate { + /// The presentation ID. + presentation_id: String, + /// Array of raw request objects as per Google Slides API. + requests: Vec, + }, +} + +fn default_layout() -> String { + "BLANK".to_string() +} + +fn default_true() -> bool { + true +} + +fn default_shape_type() -> String { + "TEXT_BOX".to_string() +} + +/// Slide info. +#[derive(Debug, Serialize)] +pub struct SlideInfo { + pub object_id: String, + pub layout_object_id: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub elements: Vec, +} + +/// Page element info. +#[derive(Debug, Serialize)] +pub struct ElementInfo { + pub object_id: String, + pub element_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub text_content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub placeholder_type: Option, +} + +/// Result from create_presentation. +#[derive(Debug, Serialize)] +pub struct CreatePresentationResult { + pub presentation_id: String, + pub title: String, +} + +/// Result from get_presentation. +#[derive(Debug, Serialize)] +pub struct PresentationMetadata { + pub presentation_id: String, + pub title: String, + pub revision_id: String, + pub slide_count: usize, + pub slides: Vec, +} + +/// Result from get_thumbnail. +#[derive(Debug, Serialize)] +pub struct ThumbnailResult { + pub content_url: String, + pub width: i64, + pub height: i64, +} + +/// Result from a batchUpdate operation. +#[derive(Debug, Serialize)] +pub struct UpdateResult { + pub presentation_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_object_id: Option, +} + +/// Result from replace_all_text. +#[derive(Debug, Serialize)] +pub struct ReplaceResult { + pub presentation_id: String, + pub occurrences_changed: i64, +} + +/// Result from batch_update. +#[derive(Debug, Serialize)] +pub struct BatchUpdateResult { + pub presentation_id: String, + pub replies: Vec, +} diff --git a/crates/ironclaw_first_party_extensions/assets/google-slides/wasm/google_slides_tool.wasm b/crates/ironclaw_first_party_extensions/assets/google-slides/wasm/google_slides_tool.wasm new file mode 100644 index 00000000000..660f0f311de Binary files /dev/null and b/crates/ironclaw_first_party_extensions/assets/google-slides/wasm/google_slides_tool.wasm differ diff --git a/crates/ironclaw_host_api/src/capability.rs b/crates/ironclaw_host_api/src/capability.rs index 0392d5fd02a..b80030ccfae 100644 --- a/crates/ironclaw_host_api/src/capability.rs +++ b/crates/ironclaw_host_api/src/capability.rs @@ -79,6 +79,8 @@ pub struct RuntimeCredentialRequirement { pub handle: SecretHandle, #[serde(default)] pub source: RuntimeCredentialRequirementSource, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub provider_scopes: Vec, pub audience: NetworkTargetPattern, pub target: RuntimeCredentialTarget, pub required: bool, diff --git a/crates/ironclaw_host_api/src/decision.rs b/crates/ironclaw_host_api/src/decision.rs index 4c7646e87e9..bf007a5849a 100644 --- a/crates/ironclaw_host_api/src/decision.rs +++ b/crates/ironclaw_host_api/src/decision.rs @@ -58,6 +58,8 @@ pub enum Obligation { InjectCredentialAccountOnce { handle: SecretHandle, provider: RuntimeCredentialAccountProviderId, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + provider_scopes: Vec, requester_extension: ExtensionId, }, FirstPartyCredentialStagedViaHostPort { @@ -78,6 +80,8 @@ pub enum Obligation { pub struct RuntimeCredentialAuthRequirement { pub provider: RuntimeCredentialAccountProviderId, pub requester_extension: ExtensionId, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub provider_scopes: Vec, } /// Canonical obligation evaluation classes. diff --git a/crates/ironclaw_host_runtime/src/obligations.rs b/crates/ironclaw_host_runtime/src/obligations.rs index 374091a9141..1aecec4595f 100644 --- a/crates/ironclaw_host_runtime/src/obligations.rs +++ b/crates/ironclaw_host_runtime/src/obligations.rs @@ -41,6 +41,7 @@ pub(crate) const DEFAULT_RUNTIME_SECRET_INJECTION_TTL: Duration = Duration::from pub struct RuntimeCredentialAccountRequest<'a> { pub scope: &'a ResourceScope, pub provider: &'a RuntimeCredentialAccountProviderId, + pub provider_scopes: &'a [String], pub requester_extension: &'a ExtensionId, } @@ -1274,6 +1275,7 @@ impl BuiltinObligationHandler { .resolve_access_secret(RuntimeCredentialAccountRequest { scope: &request.context.resource_scope, provider: obligation.provider, + provider_scopes: obligation.provider_scopes, requester_extension: obligation.requester_extension, }) .await @@ -1705,6 +1707,7 @@ fn secret_injection_handles(obligations: &[Obligation]) -> Vec { struct CredentialAccountInjectionObligation<'a> { handle: &'a SecretHandle, provider: &'a RuntimeCredentialAccountProviderId, + provider_scopes: &'a [String], requester_extension: &'a ExtensionId, } @@ -1717,10 +1720,12 @@ fn credential_account_injection_obligations( Obligation::InjectCredentialAccountOnce { handle, provider, + provider_scopes, requester_extension, } => Some(CredentialAccountInjectionObligation { handle, provider, + provider_scopes, requester_extension, }), _ => None, @@ -1756,6 +1761,7 @@ fn credential_stage_error_to_obligation_error( vec![RuntimeCredentialAuthRequirement { provider: obligation.provider.clone(), requester_extension: obligation.requester_extension.clone(), + provider_scopes: obligation.provider_scopes.to_vec(), }] }) .unwrap_or_default(), diff --git a/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs b/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs index b0a632cd968..c125e25d8c4 100644 --- a/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs +++ b/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs @@ -1294,6 +1294,7 @@ async fn inject_credential_account_once_fails_when_no_resolver_wired() { let obligations = vec![Obligation::InjectCredentialAccountOnce { handle: ironclaw_host_api::SecretHandle::new("github_runtime_token").unwrap(), provider: ironclaw_host_api::RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: Vec::new(), requester_extension: ironclaw_host_api::ExtensionId::new("github").unwrap(), }]; @@ -1329,9 +1330,11 @@ async fn inject_credential_account_once_fails_when_resolver_returns_auth_require let context = execution_context(CapabilitySet::default()); let capability_id = capability_id(); let estimate = ResourceEstimate::default(); + let provider_scopes = vec!["https://www.googleapis.com/auth/drive.readonly".to_string()]; let obligations = vec![Obligation::InjectCredentialAccountOnce { handle: ironclaw_host_api::SecretHandle::new("github_runtime_token").unwrap(), provider: ironclaw_host_api::RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: provider_scopes.clone(), requester_extension: ironclaw_host_api::ExtensionId::new("github").unwrap(), }]; @@ -1358,6 +1361,7 @@ async fn inject_credential_account_once_fails_when_resolver_returns_auth_require credential_requirements[0].requester_extension.as_str(), "github" ); + assert_eq!(credential_requirements[0].provider_scopes, provider_scopes); } #[tokio::test] @@ -1392,6 +1396,7 @@ async fn inject_credential_account_once_resolves_and_stages_secret() { let obligations = vec![Obligation::InjectCredentialAccountOnce { handle: injection_slot.clone(), provider: ironclaw_host_api::RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: Vec::new(), requester_extension: ironclaw_host_api::ExtensionId::new("github").unwrap(), }]; @@ -1432,6 +1437,7 @@ async fn inject_credential_account_once_maps_unknown_resolved_secret_to_auth_req let obligations = vec![Obligation::InjectCredentialAccountOnce { handle: ironclaw_host_api::SecretHandle::new("github_runtime_token").unwrap(), provider: ironclaw_host_api::RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: Vec::new(), requester_extension: ironclaw_host_api::ExtensionId::new("github").unwrap(), }]; diff --git a/crates/ironclaw_host_runtime/tests/github_wasm_runtime_contract.rs b/crates/ironclaw_host_runtime/tests/github_wasm_runtime_contract.rs index b8e14b81bc8..deac4684277 100644 --- a/crates/ironclaw_host_runtime/tests/github_wasm_runtime_contract.rs +++ b/crates/ironclaw_host_runtime/tests/github_wasm_runtime_contract.rs @@ -36,6 +36,52 @@ use ironclaw_wasm::{ }; use serde_json::json; +macro_rules! google_wasm_services_for_test { + ( + $package_id:expr, + $policy:expr, + $network:expr, + $secret_store:expr, + $account_access_secret:expr, + $required_scopes:expr $(,)? + ) => {{ + let package_id = $package_id; + let policy = $policy; + let required_scopes = $required_scopes; + HostRuntimeServices::new( + Arc::new(registry_with_google_package(package_id)), + Arc::new(filesystem_with_google_package(package_id)), + Arc::new(governor_with_default_limit(sample_account())), + Arc::new(ObligatingAuthorizer::new(vec![ + Obligation::ApplyNetworkPolicy { + policy: policy.clone(), + }, + Obligation::InjectCredentialAccountOnce { + handle: SecretHandle::new("google_runtime_token").unwrap(), + provider: RuntimeCredentialAccountProviderId::new("google").unwrap(), + provider_scopes: required_scopes.clone(), + requester_extension: ExtensionId::new(package_id).unwrap(), + }, + ])), + ProcessServices::in_memory(), + CapabilitySurfaceVersion::new("surface-v1").unwrap(), + ) + .with_secret_store($secret_store) + .with_runtime_credential_account_resolver(Arc::new( + FixedGoogleRuntimeCredentialAccountResolver { + expected_requester_extension: ExtensionId::new(package_id).unwrap(), + expected_scopes: required_scopes, + result: Ok($account_access_secret), + }, + )) + .with_trust_policy(Arc::new(google_first_party_trust_policy(package_id))) + .try_with_host_http_egress($network) + .unwrap() + .try_with_wasm_runtime(WitToolRuntimeConfig::default(), WitToolHost::deny_all()) + .unwrap() + }}; +} + #[tokio::test] async fn host_runtime_services_routes_structured_github_wasm_search_through_runtime_http_egress() { let capability_id = CapabilityId::new("github.search_issues").unwrap(); @@ -60,6 +106,7 @@ async fn host_runtime_services_routes_structured_github_wasm_search_through_runt Obligation::InjectCredentialAccountOnce { handle: slot_handle, provider: RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: Vec::new(), requester_extension: ExtensionId::new("github").unwrap(), }, ])), @@ -122,6 +169,272 @@ async fn host_runtime_services_routes_structured_github_wasm_search_through_runt ); } +#[tokio::test] +async fn host_runtime_services_routes_google_drive_wasm_list_files_with_scoped_google_credential() { + let capability_id = CapabilityId::new("google-drive.list_files").unwrap(); + let scope = sample_scope(InvocationId::new()); + let policy = google_drive_policy(); + let network = RecordingNetworkHttpEgress::with_body(br#"{"files":[]}"#.to_vec()); + let secret_store = Arc::new(InMemorySecretStore::new()); + let slot_handle = SecretHandle::new("google_runtime_token").unwrap(); + let account_access_secret = SecretHandle::new("google_manual_access").unwrap(); + let required_scopes = vec!["https://www.googleapis.com/auth/drive.readonly".to_string()]; + let services = HostRuntimeServices::new( + Arc::new(registry_with_google_drive_package()), + Arc::new(filesystem_with_google_drive_package()), + Arc::new(governor_with_default_limit(sample_account())), + Arc::new(ObligatingAuthorizer::new(vec![ + Obligation::ApplyNetworkPolicy { + policy: policy.clone(), + }, + Obligation::InjectCredentialAccountOnce { + handle: slot_handle, + provider: RuntimeCredentialAccountProviderId::new("google").unwrap(), + provider_scopes: required_scopes.clone(), + requester_extension: ExtensionId::new("google-drive").unwrap(), + }, + ])), + ProcessServices::in_memory(), + CapabilitySurfaceVersion::new("surface-v1").unwrap(), + ) + .with_secret_store(Arc::clone(&secret_store)) + .with_runtime_credential_account_resolver(Arc::new( + FixedGoogleRuntimeCredentialAccountResolver { + expected_requester_extension: ExtensionId::new("google-drive").unwrap(), + expected_scopes: required_scopes, + result: Ok(account_access_secret.clone()), + }, + )) + .with_trust_policy(Arc::new(google_drive_first_party_trust_policy())) + .try_with_host_http_egress(network.clone()) + .unwrap() + .try_with_wasm_runtime(WitToolRuntimeConfig::default(), WitToolHost::deny_all()) + .unwrap(); + secret_store + .put( + scope.clone(), + account_access_secret, + SecretMaterial::from("ya29.fake_fixture_token"), + ) + .await + .unwrap(); + + let outcome = services + .host_runtime_for_local_testing() + .invoke_capability(wasm_runtime_request_for_scope( + capability_id.clone(), + scope, + json!({"query": "name contains 'report'"}), + )) + .await + .unwrap(); + + match outcome { + RuntimeCapabilityOutcome::Completed(completed) => { + assert_eq!(completed.capability_id, capability_id); + assert_eq!(completed.output, json!({"files":[]})); + } + other => panic!("expected completed outcome, got {other:?}"), + } + let requests = network.requests(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, NetworkMethod::Get); + assert!( + requests[0] + .url + .starts_with("https://www.googleapis.com/drive/v3/files?") + ); + assert!(requests[0].url.contains("pageSize=25")); + assert!(requests[0].url.contains("q=name%20contains%20%27report%27")); + assert_eq!(requests[0].body, Vec::::new()); + assert_eq!(requests[0].policy, policy); + assert_eq!( + requests[0] + .headers + .iter() + .find(|(name, _)| name == "authorization"), + Some(&( + "authorization".to_string(), + "Bearer ya29.fake_fixture_token".to_string(), + )) + ); +} + +#[tokio::test] +async fn host_runtime_services_routes_google_docs_wasm_get_document_with_scoped_google_credential() +{ + let capability_id = CapabilityId::new("google-docs.get_document").unwrap(); + let scope = sample_scope(InvocationId::new()); + let policy = google_policy("docs.googleapis.com"); + let network = RecordingNetworkHttpEgress::with_body( + br#"{"documentId":"doc-1","title":"Doc","revisionId":"r1","body":{"content":[{"endIndex":5}]}}"#.to_vec(), + ); + let secret_store = Arc::new(InMemorySecretStore::new()); + let account_access_secret = SecretHandle::new("google_docs_access").unwrap(); + let required_scopes = vec!["https://www.googleapis.com/auth/documents.readonly".to_string()]; + let services = google_wasm_services_for_test!( + "google-docs", + policy.clone(), + network.clone(), + Arc::clone(&secret_store), + account_access_secret.clone(), + required_scopes, + ); + secret_store + .put( + scope.clone(), + account_access_secret, + SecretMaterial::from("ya29.fake_fixture_token"), + ) + .await + .unwrap(); + + let outcome = services + .host_runtime_for_local_testing() + .invoke_capability(wasm_runtime_request_for_scope( + capability_id.clone(), + scope, + json!({"document_id": "doc-1"}), + )) + .await + .unwrap(); + + match outcome { + RuntimeCapabilityOutcome::Completed(completed) => { + assert_eq!(completed.capability_id, capability_id); + assert_eq!(completed.output["document_id"], json!("doc-1")); + } + other => panic!("expected completed outcome, got {other:?}"), + } + let requests = network.requests(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, NetworkMethod::Get); + assert_eq!( + requests[0].url, + "https://docs.googleapis.com/v1/documents/doc-1" + ); + assert_eq!(requests[0].body, Vec::::new()); + assert_eq!(requests[0].policy, policy); + assert_google_bearer_header(&requests[0], "ya29.fake_fixture_token"); +} + +#[tokio::test] +async fn host_runtime_services_routes_google_sheets_wasm_get_spreadsheet_with_scoped_google_credential() + { + let capability_id = CapabilityId::new("google-sheets.get_spreadsheet").unwrap(); + let scope = sample_scope(InvocationId::new()); + let policy = google_policy("sheets.googleapis.com"); + let network = RecordingNetworkHttpEgress::with_body( + br#"{"spreadsheetId":"sheet-1","properties":{"title":"Sheet"},"spreadsheetUrl":"https://docs.google.com/spreadsheets/d/sheet-1","sheets":[],"namedRanges":[]}"#.to_vec(), + ); + let secret_store = Arc::new(InMemorySecretStore::new()); + let account_access_secret = SecretHandle::new("google_sheets_access").unwrap(); + let required_scopes = vec!["https://www.googleapis.com/auth/spreadsheets.readonly".to_string()]; + let services = google_wasm_services_for_test!( + "google-sheets", + policy.clone(), + network.clone(), + Arc::clone(&secret_store), + account_access_secret.clone(), + required_scopes, + ); + secret_store + .put( + scope.clone(), + account_access_secret, + SecretMaterial::from("ya29.fake_fixture_token"), + ) + .await + .unwrap(); + + let outcome = services + .host_runtime_for_local_testing() + .invoke_capability(wasm_runtime_request_for_scope( + capability_id.clone(), + scope, + json!({"spreadsheet_id": "sheet-1"}), + )) + .await + .unwrap(); + + match outcome { + RuntimeCapabilityOutcome::Completed(completed) => { + assert_eq!(completed.capability_id, capability_id); + assert_eq!(completed.output["spreadsheet_id"], json!("sheet-1")); + } + other => panic!("expected completed outcome, got {other:?}"), + } + let requests = network.requests(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, NetworkMethod::Get); + assert_eq!( + requests[0].url, + "https://sheets.googleapis.com/v4/spreadsheets/sheet-1?fields=spreadsheetId,properties.title,spreadsheetUrl,sheets.properties,namedRanges" + ); + assert_eq!(requests[0].body, Vec::::new()); + assert_eq!(requests[0].policy, policy); + assert_google_bearer_header(&requests[0], "ya29.fake_fixture_token"); +} + +#[tokio::test] +async fn host_runtime_services_routes_google_slides_wasm_get_presentation_with_scoped_google_credential() + { + let capability_id = CapabilityId::new("google-slides.get_presentation").unwrap(); + let scope = sample_scope(InvocationId::new()); + let policy = google_policy("slides.googleapis.com"); + let network = RecordingNetworkHttpEgress::with_body( + br#"{"presentationId":"slides-1","title":"Slides","revisionId":"r1","slides":[]}"#.to_vec(), + ); + let secret_store = Arc::new(InMemorySecretStore::new()); + let account_access_secret = SecretHandle::new("google_slides_access").unwrap(); + let required_scopes = + vec!["https://www.googleapis.com/auth/presentations.readonly".to_string()]; + let services = google_wasm_services_for_test!( + "google-slides", + policy.clone(), + network.clone(), + Arc::clone(&secret_store), + account_access_secret.clone(), + required_scopes, + ); + secret_store + .put( + scope.clone(), + account_access_secret, + SecretMaterial::from("ya29.fake_fixture_token"), + ) + .await + .unwrap(); + + let outcome = services + .host_runtime_for_local_testing() + .invoke_capability(wasm_runtime_request_for_scope( + capability_id.clone(), + scope, + json!({"presentation_id": "slides-1"}), + )) + .await + .unwrap(); + + match outcome { + RuntimeCapabilityOutcome::Completed(completed) => { + assert_eq!(completed.capability_id, capability_id); + assert_eq!(completed.output["presentation_id"], json!("slides-1")); + } + other => panic!("expected completed outcome, got {other:?}"), + } + let requests = network.requests(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, NetworkMethod::Get); + assert_eq!( + requests[0].url, + "https://slides.googleapis.com/v1/presentations/slides-1" + ); + assert_eq!(requests[0].body, Vec::::new()); + assert_eq!(requests[0].policy, policy); + assert_google_bearer_header(&requests[0], "ya29.fake_fixture_token"); +} + #[tokio::test] async fn host_runtime_services_maps_github_wasm_input_errors_to_invalid_input() { let capability_id = CapabilityId::new("github.search_issues").unwrap(); @@ -143,6 +456,7 @@ async fn host_runtime_services_maps_github_wasm_input_errors_to_invalid_input() Obligation::InjectCredentialAccountOnce { handle: slot_handle, provider: RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: Vec::new(), requester_extension: ExtensionId::new("github").unwrap(), }, ])), @@ -204,6 +518,7 @@ async fn host_runtime_services_missing_github_runtime_secret_blocks_on_auth() { Obligation::InjectCredentialAccountOnce { handle: slot_handle, provider: RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: Vec::new(), requester_extension: ExtensionId::new("github").unwrap(), }, ])), @@ -768,6 +1083,53 @@ async fn bundled_github_wasm_leaves_success_json_for_host_output_decode() { assert_eq!(execution.error, None); } +#[test] +fn bundled_google_drive_wasm_rejects_invalid_context_derived_dispatch_inputs() { + let http = Arc::new(RecordingWasmHostHttp::ok(WasmHttpResponse { + status: 200, + headers_json: "{}".to_string(), + body: br#"{"files":[]}"#.to_vec(), + })); + + let missing_context = execute_bundled_google_drive_wasm(json!({}), None, Arc::clone(&http)); + assert_eq!( + wasm_error_code_or_text(&missing_context).as_deref(), + Some("missing_invocation_context") + ); + + let malformed_context = + execute_bundled_google_drive_wasm(json!({}), Some("not-json"), Arc::clone(&http)); + assert_eq!( + wasm_error_code_or_text(&malformed_context).as_deref(), + Some("invalid_invocation_context") + ); + + let unsupported_capability = execute_bundled_google_drive_wasm( + json!({}), + Some(r#"{"capability_id":"google-drive.nope"}"#), + Arc::clone(&http), + ); + assert_eq!( + wasm_error_code_or_text(&unsupported_capability).as_deref(), + Some("unsupported_google_drive_capability") + ); + + let action_collision = execute_bundled_google_drive_wasm( + json!({"action": "list_files"}), + Some(r#"{"capability_id":"google-drive.list_files"}"#), + Arc::clone(&http), + ); + assert_eq!( + wasm_error_code_or_text(&action_collision).as_deref(), + Some("invalid_parameters") + ); + + assert!( + http.requests().unwrap().is_empty(), + "dispatch-wrapper validation failures must block before HTTP egress" + ); +} + fn assert_failed_outcome(outcome: RuntimeCapabilityOutcome, expected_kind: RuntimeFailureKind) { match outcome { RuntimeCapabilityOutcome::Failed(failure) => assert_eq!(failure.kind, expected_kind), @@ -786,6 +1148,16 @@ fn structured_wasm_error_code(execution: &WitToolExecution) -> Option { parsed["code"].as_str().map(str::to_string) } +fn wasm_error_code_or_text(execution: &WitToolExecution) -> Option { + let error = execution.error.as_deref()?; + if let Ok(parsed) = serde_json::from_str::(error) + && let Some(code) = parsed["code"].as_str() + { + return Some(code.to_string()); + } + Some(error.to_string()) +} + #[derive(Debug, Clone)] struct RecordingNetworkHttpEgress { requests: Arc>>, @@ -880,6 +1252,29 @@ impl RuntimeCredentialAccountResolver for FixedRuntimeCredentialAccountResolver } } +#[derive(Debug)] +struct FixedGoogleRuntimeCredentialAccountResolver { + expected_requester_extension: ExtensionId, + expected_scopes: Vec, + result: Result, +} + +#[async_trait] +impl RuntimeCredentialAccountResolver for FixedGoogleRuntimeCredentialAccountResolver { + async fn resolve_access_secret( + &self, + request: RuntimeCredentialAccountRequest<'_>, + ) -> Result { + assert_eq!(request.provider.as_str(), "google"); + assert_eq!( + request.requester_extension, + &self.expected_requester_extension + ); + assert_eq!(request.provider_scopes, self.expected_scopes.as_slice()); + self.result.clone() + } +} + fn registry_with_github_package() -> ExtensionRegistry { let manifest = ExtensionManifest::parse_with_host_api_contracts( &std::fs::read_to_string(github_asset_root().join("manifest.toml")).unwrap(), @@ -898,6 +1293,10 @@ fn registry_with_github_package() -> ExtensionRegistry { registry } +fn registry_with_google_drive_package() -> ExtensionRegistry { + registry_with_google_package("google-drive") +} + fn filesystem_with_github_package() -> LocalFilesystem { let mut filesystem = LocalFilesystem::new(); filesystem @@ -909,16 +1308,85 @@ fn filesystem_with_github_package() -> LocalFilesystem { filesystem } +fn filesystem_with_google_drive_package() -> LocalFilesystem { + filesystem_with_google_package("google-drive") +} + +fn registry_with_google_package(package_id: &str) -> ExtensionRegistry { + let manifest = ExtensionManifest::parse_with_host_api_contracts( + &std::fs::read_to_string(google_asset_root(package_id).join("manifest.toml")).unwrap(), + ManifestSource::HostBundled, + &default_host_port_catalog().unwrap(), + &default_host_api_contract_registry().unwrap(), + ) + .unwrap(); + let package = ExtensionPackage::from_manifest( + manifest, + VirtualPath::new(format!("/system/extensions/{package_id}")).unwrap(), + ) + .unwrap(); + let mut registry = ExtensionRegistry::new(); + registry.insert(package).unwrap(); + registry +} + +fn filesystem_with_google_package(package_id: &str) -> LocalFilesystem { + let mut filesystem = LocalFilesystem::new(); + filesystem + .mount_local( + VirtualPath::new("/system/extensions").unwrap(), + HostPath::from_path_buf( + google_asset_root(package_id) + .parent() + .unwrap() + .to_path_buf(), + ), + ) + .unwrap(); + filesystem +} + fn github_asset_root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") .join("crates/ironclaw_first_party_extensions/assets/github") } +fn google_drive_asset_root() -> std::path::PathBuf { + google_asset_root("google-drive") +} + +fn google_asset_root(package_id: &str) -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("crates/ironclaw_first_party_extensions/assets") + .join(package_id) +} + fn github_wasm_path() -> std::path::PathBuf { github_asset_root().join("wasm/github_tool.wasm") } +fn google_drive_wasm_path() -> std::path::PathBuf { + google_drive_asset_root().join("wasm/google_drive_tool.wasm") +} + +fn google_drive_policy() -> NetworkPolicy { + google_policy("www.googleapis.com") +} + +fn google_policy(host_pattern: &str) -> NetworkPolicy { + NetworkPolicy { + allowed_targets: vec![NetworkTargetPattern { + scheme: Some(NetworkScheme::Https), + host_pattern: host_pattern.to_string(), + port: None, + }], + deny_private_ip_ranges: true, + max_egress_bytes: Some(10_000), + } +} + fn github_first_party_trust_policy() -> HostTrustPolicy { HostTrustPolicy::new(vec![Box::new(AdminConfig::with_entries(vec![ AdminEntry::for_local_manifest( @@ -938,6 +1406,42 @@ fn github_first_party_trust_policy() -> HostTrustPolicy { .unwrap() } +fn google_drive_first_party_trust_policy() -> HostTrustPolicy { + google_first_party_trust_policy("google-drive") +} + +fn google_first_party_trust_policy(package_id: &str) -> HostTrustPolicy { + HostTrustPolicy::new(vec![Box::new(AdminConfig::with_entries(vec![ + AdminEntry::for_local_manifest( + PackageId::new(package_id).unwrap(), + format!("/system/extensions/{package_id}/manifest.toml"), + None, + HostTrustAssignment::first_party(), + vec![ + EffectKind::DispatchCapability, + EffectKind::Network, + EffectKind::UseSecret, + EffectKind::ExternalWrite, + ], + None, + ), + ]))]) + .unwrap() +} + +fn assert_google_bearer_header(request: &NetworkHttpRequest, expected_token: &str) { + assert_eq!( + request + .headers + .iter() + .find(|(name, _)| name == "authorization"), + Some(&( + "authorization".to_string(), + format!("Bearer {expected_token}"), + )) + ); +} + fn wasm_runtime_request_for_scope( capability_id: CapabilityId, scope: ResourceScope, @@ -1044,6 +1548,24 @@ fn execute_bundled_github_wasm( .unwrap() } +fn execute_bundled_google_drive_wasm( + input: serde_json::Value, + context: Option<&str>, + http: Arc, +) -> WitToolExecution { + let runtime = WitToolRuntime::new(WitToolRuntimeConfig::default()).unwrap(); + let wasm_bytes = std::fs::read(google_drive_wasm_path()) + .expect("first-party Google Drive WASM must be built"); + let prepared = runtime.prepare("google-drive", &wasm_bytes).unwrap(); + let request = match context { + Some(context) => WitToolRequest::new(input.to_string()).with_context(context.to_string()), + None => WitToolRequest::new(input.to_string()), + }; + runtime + .execute(&prepared, WitToolHost::deny_all().with_http(http), request) + .unwrap() +} + fn assert_single_wasm_request( http: &RecordingWasmHostHttp, expected_method: &str, diff --git a/crates/ironclaw_loop_support/src/capability_port/tests/runtime_lifecycle_tests.rs b/crates/ironclaw_loop_support/src/capability_port/tests/runtime_lifecycle_tests.rs index 0c4238edb78..80116fe6935 100644 --- a/crates/ironclaw_loop_support/src/capability_port/tests/runtime_lifecycle_tests.rs +++ b/crates/ironclaw_loop_support/src/capability_port/tests/runtime_lifecycle_tests.rs @@ -408,6 +408,7 @@ async fn runtime_auth_gate_forwards_credential_requirements() { let requirement = RuntimeCredentialAuthRequirement { provider: RuntimeCredentialAccountProviderId::new("github").unwrap(), requester_extension: provider_id.clone(), + provider_scopes: Vec::new(), }; let port = runtime_capability_port( &capability_id, diff --git a/crates/ironclaw_reborn_composition/src/available_extensions.rs b/crates/ironclaw_reborn_composition/src/available_extensions.rs index 32bb7f1ec0a..bd116751d86 100644 --- a/crates/ironclaw_reborn_composition/src/available_extensions.rs +++ b/crates/ironclaw_reborn_composition/src/available_extensions.rs @@ -18,6 +18,26 @@ const GITHUB_WASM_MODULE: &[u8] = include_bytes!("../../ironclaw_first_party_extensions/assets/github/wasm/github_tool.wasm"); const GOOGLE_CALENDAR_MANIFEST: &str = include_str!("../../ironclaw_first_party_extensions/assets/google-calendar/manifest.toml"); +const GOOGLE_DOCS_MANIFEST: &str = + include_str!("../../ironclaw_first_party_extensions/assets/google-docs/manifest.toml"); +const GOOGLE_DOCS_WASM_MODULE: &[u8] = include_bytes!( + "../../ironclaw_first_party_extensions/assets/google-docs/wasm/google_docs_tool.wasm" +); +const GOOGLE_DRIVE_MANIFEST: &str = + include_str!("../../ironclaw_first_party_extensions/assets/google-drive/manifest.toml"); +const GOOGLE_DRIVE_WASM_MODULE: &[u8] = include_bytes!( + "../../ironclaw_first_party_extensions/assets/google-drive/wasm/google_drive_tool.wasm" +); +const GOOGLE_SHEETS_MANIFEST: &str = + include_str!("../../ironclaw_first_party_extensions/assets/google-sheets/manifest.toml"); +const GOOGLE_SHEETS_WASM_MODULE: &[u8] = include_bytes!( + "../../ironclaw_first_party_extensions/assets/google-sheets/wasm/google_sheets_tool.wasm" +); +const GOOGLE_SLIDES_MANIFEST: &str = + include_str!("../../ironclaw_first_party_extensions/assets/google-slides/manifest.toml"); +const GOOGLE_SLIDES_WASM_MODULE: &[u8] = include_bytes!( + "../../ironclaw_first_party_extensions/assets/google-slides/wasm/google_slides_tool.wasm" +); const GMAIL_MANIFEST: &str = include_str!("../../ironclaw_first_party_extensions/assets/gmail/manifest.toml"); const NOTION_MCP_MANIFEST: &str = @@ -91,6 +111,10 @@ impl AvailableExtensionCatalog { web_access_package()?, nearai_mcp_package()?, google_calendar_package()?, + google_docs_package()?, + google_drive_package()?, + google_sheets_package()?, + google_slides_package()?, gmail_package()?, ])) } @@ -199,6 +223,42 @@ fn google_calendar_package() -> Result Result { + bundled_extension_package( + "google-docs", + "Google Docs", + GOOGLE_DOCS_MANIFEST, + google_docs_assets(), + ) +} + +fn google_drive_package() -> Result { + bundled_extension_package( + "google-drive", + "Google Drive", + GOOGLE_DRIVE_MANIFEST, + google_drive_assets(), + ) +} + +fn google_sheets_package() -> Result { + bundled_extension_package( + "google-sheets", + "Google Sheets", + GOOGLE_SHEETS_MANIFEST, + google_sheets_assets(), + ) +} + +fn google_slides_package() -> Result { + bundled_extension_package( + "google-slides", + "Google Slides", + GOOGLE_SLIDES_MANIFEST, + google_slides_assets(), + ) +} + fn gmail_package() -> Result { bundled_extension_package("gmail", "Gmail", GMAIL_MANIFEST, gmail_assets()) } @@ -207,6 +267,22 @@ pub(crate) fn google_calendar_manifest_digest() -> String { sha256_digest_token(GOOGLE_CALENDAR_MANIFEST.as_bytes()) } +pub(crate) fn google_docs_manifest_digest() -> String { + sha256_digest_token(GOOGLE_DOCS_MANIFEST.as_bytes()) +} + +pub(crate) fn google_drive_manifest_digest() -> String { + sha256_digest_token(GOOGLE_DRIVE_MANIFEST.as_bytes()) +} + +pub(crate) fn google_sheets_manifest_digest() -> String { + sha256_digest_token(GOOGLE_SHEETS_MANIFEST.as_bytes()) +} + +pub(crate) fn google_slides_manifest_digest() -> String { + sha256_digest_token(GOOGLE_SLIDES_MANIFEST.as_bytes()) +} + pub(crate) fn gmail_manifest_digest() -> String { sha256_digest_token(GMAIL_MANIFEST.as_bytes()) } @@ -736,6 +812,143 @@ fn google_calendar_assets() -> Vec { ] } +macro_rules! google_wasm_assets { + ($id:literal, $manifest:expr, $wasm_file:literal, $wasm_module:expr, [$($operation:literal),+ $(,)?]) => {{ + vec![ + bytes_asset("manifest.toml", $manifest.as_bytes()), + bytes_asset( + concat!("schemas/", $id, "/raw_output.v1.json"), + include_bytes!(concat!( + "../../ironclaw_first_party_extensions/assets/", + $id, + "/schemas/", + $id, + "/raw_output.v1.json" + )), + ), + $( + bytes_asset( + concat!("schemas/", $id, "/", $operation, ".input.v1.json"), + include_bytes!(concat!( + "../../ironclaw_first_party_extensions/assets/", + $id, + "/schemas/", + $id, + "/", + $operation, + ".input.v1.json" + )), + ), + bytes_asset( + concat!("prompts/", $id, "/", $operation, ".md"), + include_bytes!(concat!( + "../../ironclaw_first_party_extensions/assets/", + $id, + "/prompts/", + $id, + "/", + $operation, + ".md" + )), + ), + )+ + bytes_asset(concat!("wasm/", $wasm_file), $wasm_module), + ] + }}; +} + +fn google_docs_assets() -> Vec { + google_wasm_assets!( + "google-docs", + GOOGLE_DOCS_MANIFEST, + "google_docs_tool.wasm", + GOOGLE_DOCS_WASM_MODULE, + [ + "create_document", + "get_document", + "read_content", + "insert_text", + "delete_content", + "replace_text", + "format_text", + "format_paragraph", + "insert_table", + "create_list", + "batch_update" + ] + ) +} + +fn google_drive_assets() -> Vec { + google_wasm_assets!( + "google-drive", + GOOGLE_DRIVE_MANIFEST, + "google_drive_tool.wasm", + GOOGLE_DRIVE_WASM_MODULE, + [ + "list_files", + "get_file", + "download_file", + "upload_file", + "update_file", + "create_folder", + "delete_file", + "trash_file", + "share_file", + "list_permissions", + "remove_permission", + "list_shared_drives" + ] + ) +} + +fn google_sheets_assets() -> Vec { + google_wasm_assets!( + "google-sheets", + GOOGLE_SHEETS_MANIFEST, + "google_sheets_tool.wasm", + GOOGLE_SHEETS_WASM_MODULE, + [ + "create_spreadsheet", + "get_spreadsheet", + "read_values", + "batch_read_values", + "write_values", + "append_values", + "clear_values", + "add_sheet", + "delete_sheet", + "rename_sheet", + "format_cells" + ] + ) +} + +fn google_slides_assets() -> Vec { + google_wasm_assets!( + "google-slides", + GOOGLE_SLIDES_MANIFEST, + "google_slides_tool.wasm", + GOOGLE_SLIDES_WASM_MODULE, + [ + "create_presentation", + "get_presentation", + "get_thumbnail", + "create_slide", + "delete_object", + "insert_text", + "delete_text", + "replace_all_text", + "create_shape", + "insert_image", + "format_text", + "format_paragraph", + "replace_shapes_with_image", + "batch_update" + ] + ) +} + fn gmail_assets() -> Vec { vec![ bytes_asset("manifest.toml", GMAIL_MANIFEST.as_bytes()), @@ -1085,6 +1298,10 @@ mod tests { "web-access", "nearai", "google-calendar", + "google-docs", + "google-drive", + "google-sheets", + "google-slides", "gmail", ] { let package_ref = @@ -1121,6 +1338,47 @@ mod tests { } } + #[test] + fn bundled_gsuite_wasm_capabilities_are_operation_scoped() { + let catalog = AvailableExtensionCatalog::from_first_party_assets().unwrap(); + let package_ref = + LifecyclePackageRef::new(LifecyclePackageKind::Extension, "google-drive").unwrap(); + let package = catalog.resolve(&package_ref).unwrap(); + let capabilities = package + .package + .manifest + .capabilities + .iter() + .map(|capability| (capability.id.as_str(), capability)) + .collect::>(); + + assert!(!capabilities.contains_key("google-drive.execute")); + assert!(capabilities.contains_key("google-drive.list_files")); + assert!(capabilities.contains_key("google-drive.upload_file")); + + let summary = package.summary(); + assert!( + summary + .visible_read_only_capability_ids + .contains(&"google-drive.list_files".to_string()) + ); + assert!( + !summary + .visible_read_only_capability_ids + .contains(&"google-drive.upload_file".to_string()) + ); + + let list_files = capabilities["google-drive.list_files"]; + assert_eq!( + list_files.runtime_credentials[0].provider_scopes, + vec!["https://www.googleapis.com/auth/drive.readonly".to_string()] + ); + assert!(!list_files.effects.contains(&EffectKind::ExternalWrite)); + + let upload_file = capabilities["google-drive.upload_file"]; + assert!(upload_file.effects.contains(&EffectKind::ExternalWrite)); + } + #[tokio::test] async fn materialize_bundled_github_writes_manifest_schema_refs() { let fs = InMemoryBackend::default(); @@ -1153,8 +1411,14 @@ mod tests { } #[test] - fn bundled_mcp_manifest_digests_are_sha256_tokens() { + fn bundled_manifest_digests_are_sha256_tokens() { assert!(notion_mcp_manifest_digest().starts_with("sha256:")); + assert!(google_calendar_manifest_digest().starts_with("sha256:")); + assert!(google_docs_manifest_digest().starts_with("sha256:")); + assert!(google_drive_manifest_digest().starts_with("sha256:")); + assert!(google_sheets_manifest_digest().starts_with("sha256:")); + assert!(google_slides_manifest_digest().starts_with("sha256:")); + assert!(gmail_manifest_digest().starts_with("sha256:")); } #[test] diff --git a/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs b/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs index 109e64c3c4b..4c4c513dbe8 100644 --- a/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs +++ b/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs @@ -1619,10 +1619,26 @@ mod tests { else { panic!("expected extension search payload"); }; - assert_eq!(extensions.len(), 1); - assert_eq!(extensions[0].package_ref.id.as_str(), "google-calendar"); + let extension_ids = extensions + .iter() + .map(|extension| extension.package_ref.id.as_str()) + .collect::>(); assert_eq!( - extensions[0].visible_read_only_capability_ids, + extension_ids, + BTreeSet::from([ + "google-calendar", + "google-docs", + "google-drive", + "google-sheets", + "google-slides", + ]) + ); + let calendar = extensions + .iter() + .find(|extension| extension.package_ref.id.as_str() == "google-calendar") + .expect("google-calendar search result"); + assert_eq!( + calendar.visible_read_only_capability_ids, vec![ "google-calendar.list_calendars", "google-calendar.list_events", diff --git a/crates/ironclaw_reborn_composition/src/factory.rs b/crates/ironclaw_reborn_composition/src/factory.rs index 983df0602ff..97b2705deed 100644 --- a/crates/ironclaw_reborn_composition/src/factory.rs +++ b/crates/ironclaw_reborn_composition/src/factory.rs @@ -91,7 +91,8 @@ use crate::{ use crate::{ available_extensions::{ AvailableExtensionCatalog, gmail_manifest_digest, google_calendar_manifest_digest, - notion_mcp_manifest_digest, web_access_manifest_digest, + google_docs_manifest_digest, google_drive_manifest_digest, google_sheets_manifest_digest, + google_slides_manifest_digest, notion_mcp_manifest_digest, web_access_manifest_digest, }, extension_installation_store::FilesystemExtensionInstallationStore, extension_lifecycle::{ @@ -1459,6 +1460,46 @@ fn local_dev_first_party_trust_policy() -> Result, } impl RuntimeCredentialAccountSelectionRequest { pub(crate) fn new( lookup: CredentialAccountSelectionRequest, runtime_scope: AuthProductScope, + provider_scopes: Vec, ) -> Self { Self { lookup, runtime_scope, + provider_scopes, } } } @@ -78,6 +81,7 @@ impl RuntimeCredentialAccountSelectionService for ProductAuthRuntimeCredentialAc .filter(|account| { account.provider == request.lookup.provider && account.status == CredentialAccountStatus::Configured + && account_has_provider_scopes(account, &request.provider_scopes) && account_visible_from_runtime_scope(account, &request.runtime_scope) }) .collect::>(); @@ -124,6 +128,20 @@ impl RuntimeCredentialAccountResolver for ProductAuthRuntimeCredentialResolver { ); CredentialStageError::Backend })?; + let provider_scopes = request + .provider_scopes + .iter() + .map(|scope| { + ProviderScope::new(scope.clone()).map_err(|e| { + tracing::debug!( + scope = %scope, + err = %e, + "runtime credential provider scope is invalid" + ); + CredentialStageError::Backend + }) + }) + .collect::, _>>()?; let account = self .accounts .select_unique_configured_runtime_account( @@ -131,6 +149,7 @@ impl RuntimeCredentialAccountResolver for ProductAuthRuntimeCredentialResolver { CredentialAccountSelectionRequest::new(auth_scope, provider) .for_extension(request.requester_extension.clone()), AuthProductScope::new(request.scope.clone(), AuthSurface::Api), + provider_scopes, ), ) .await @@ -148,6 +167,15 @@ impl RuntimeCredentialAccountResolver for ProductAuthRuntimeCredentialResolver { } } +fn account_has_provider_scopes( + account: &CredentialAccount, + required_scopes: &[ProviderScope], +) -> bool { + required_scopes + .iter() + .all(|required| account.scopes.iter().any(|scope| scope == required)) +} + fn account_visible_from_runtime_scope( account: &CredentialAccount, runtime_scope: &AuthProductScope, @@ -248,6 +276,7 @@ mod tests { .resolve_access_secret(RuntimeCredentialAccountRequest { scope: &scope, provider: &RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: &[], requester_extension: &ExtensionId::new("github").unwrap(), }) .await @@ -289,6 +318,7 @@ mod tests { .resolve_access_secret(RuntimeCredentialAccountRequest { scope: &runtime_scope, provider: &RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: &[], requester_extension: &ExtensionId::new("github").unwrap(), }) .await @@ -331,6 +361,7 @@ mod tests { .resolve_access_secret(RuntimeCredentialAccountRequest { scope: &runtime_scope, provider: &RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: &[], requester_extension: &ExtensionId::new("github").unwrap(), }) .await @@ -373,6 +404,7 @@ mod tests { .resolve_access_secret(RuntimeCredentialAccountRequest { scope: &runtime_scope, provider: &RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: &[], requester_extension: &ExtensionId::new("github").unwrap(), }) .await @@ -414,6 +446,7 @@ mod tests { .resolve_access_secret(RuntimeCredentialAccountRequest { scope: &runtime_scope, provider: &RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: &[], requester_extension: &ExtensionId::new("github").unwrap(), }) .await @@ -436,6 +469,7 @@ mod tests { .resolve_access_secret(RuntimeCredentialAccountRequest { scope: &scope, provider: &RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: &[], requester_extension: &ExtensionId::new("github").unwrap(), }) .await @@ -444,6 +478,48 @@ mod tests { assert_eq!(error, CredentialStageError::AuthRequired); } + #[tokio::test] + async fn resolver_requires_requested_provider_scopes() { + let accounts = Arc::new(InMemoryAuthProductServices::new()); + let scope = + ResourceScope::local_default(UserId::new("alice").unwrap(), InvocationId::new()) + .unwrap(); + let auth_scope = AuthProductScope::new(scope.clone(), AuthSurface::Api); + accounts + .create_account(NewCredentialAccount { + scope: auth_scope, + provider: AuthProviderId::new("google").unwrap(), + label: CredentialAccountLabel::new("work google").unwrap(), + status: CredentialAccountStatus::Configured, + ownership: CredentialOwnership::UserReusable, + owner_extension: None, + granted_extensions: Vec::new(), + access_secret: Some(SecretHandle::new("google_manual_access").unwrap()), + refresh_secret: None, + scopes: vec![ + ProviderScope::new("https://www.googleapis.com/auth/gmail.send").unwrap(), + ], + }) + .await + .unwrap(); + let resolver = ProductAuthRuntimeCredentialResolver::new(Arc::new( + ProductAuthRuntimeCredentialAccountSelector::new(accounts), + )); + let required_scopes = vec!["https://www.googleapis.com/auth/drive".to_string()]; + + let error = resolver + .resolve_access_secret(RuntimeCredentialAccountRequest { + scope: &scope, + provider: &RuntimeCredentialAccountProviderId::new("google").unwrap(), + provider_scopes: &required_scopes, + requester_extension: &ExtensionId::new("google-drive").unwrap(), + }) + .await + .unwrap_err(); + + assert_eq!(error, CredentialStageError::AuthRequired); + } + #[tokio::test] async fn resolver_maps_unconfigured_account_status_to_auth_required() { let accounts = Arc::new(InMemoryAuthProductServices::new()); @@ -474,6 +550,7 @@ mod tests { .resolve_access_secret(RuntimeCredentialAccountRequest { scope: &scope, provider: &RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: &[], requester_extension: &ExtensionId::new("github").unwrap(), }) .await @@ -512,6 +589,7 @@ mod tests { .resolve_access_secret(RuntimeCredentialAccountRequest { scope: &scope, provider: &RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: &[], requester_extension: &ExtensionId::new("github").unwrap(), }) .await @@ -557,6 +635,7 @@ mod tests { .resolve_access_secret(RuntimeCredentialAccountRequest { scope: &scope, provider: &RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: &[], requester_extension: &ExtensionId::new("github").unwrap(), }) .await @@ -613,6 +692,7 @@ mod tests { .resolve_access_secret(RuntimeCredentialAccountRequest { scope: &scope, provider: &RuntimeCredentialAccountProviderId::new("github").unwrap(), + provider_scopes: &[], requester_extension: &ExtensionId::new("github").unwrap(), }) .await diff --git a/crates/ironclaw_reborn_composition/src/projection/tests/turn_stream.rs b/crates/ironclaw_reborn_composition/src/projection/tests/turn_stream.rs index 6046884b81a..8ba1435d525 100644 --- a/crates/ironclaw_reborn_composition/src/projection/tests/turn_stream.rs +++ b/crates/ironclaw_reborn_composition/src/projection/tests/turn_stream.rs @@ -235,6 +235,7 @@ async fn webui_event_stream_uses_credential_requirement_for_manual_token_auth_pr let credential_requirements = vec![RuntimeCredentialAuthRequirement { provider: RuntimeCredentialAccountProviderId::new("github").unwrap(), requester_extension: ExtensionId::new("github").unwrap(), + provider_scopes: Vec::new(), }]; let event_log_dyn: Arc = Arc::new(InMemoryDurableEventLog::new()); let services = build_reborn_projection_services( diff --git a/crates/ironclaw_reborn_composition/src/runtime/local_dev/extension_surface.rs b/crates/ironclaw_reborn_composition/src/runtime/local_dev/extension_surface.rs index c14515b6aad..a00d654b067 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/local_dev/extension_surface.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/local_dev/extension_surface.rs @@ -187,6 +187,7 @@ mod tests { runtime_credentials: vec![ironclaw_host_api::RuntimeCredentialRequirement { handle: ironclaw_host_api::SecretHandle::new("exa_mcp_token").unwrap(), source: ironclaw_host_api::RuntimeCredentialRequirementSource::SecretHandle, + provider_scopes: Vec::new(), audience: NetworkTargetPattern { scheme: Some(NetworkScheme::Https), host_pattern: EXA_MCP_HOST.to_string(), diff --git a/docs/plans/2026-05-24-gsuite-reborn-port-map.md b/docs/plans/2026-05-24-gsuite-reborn-port-map.md index b45609d917e..9a730625482 100644 --- a/docs/plans/2026-05-24-gsuite-reborn-port-map.md +++ b/docs/plans/2026-05-24-gsuite-reborn-port-map.md @@ -63,3 +63,24 @@ extension boundaries. - Phase 3/4: `cargo test -p ironclaw_first_party_extensions` - Port contract changes: `cargo test -p ironclaw_first_party_extension_ports` - Dependency changes: `cargo test -p ironclaw_architecture reborn_crate_dependency_boundaries_hold` + +## 2026-06-02 Reborn WASM Parity Slice + +- Drive, Docs, Sheets, and Slides now port as host-bundled WASM packages under + `crates/ironclaw_first_party_extensions/assets/`. +- Each product exposes operation-level Reborn capability IDs instead of a mixed + `*.execute` tool. The WASM wrappers derive the hidden v1 action discriminator + from the host invocation context, so model input cannot select a different + operation. +- Read capabilities omit `external_write`; mutating capabilities include it. + The lifecycle summary therefore exposes read-only GSuite operations without + granting writes. +- Each operation has a schema/prompt asset and declares the required Google + provider scope in `runtime_credentials.provider_scopes`: + Drive `drive`, Docs `documents`, Sheets `spreadsheets`, Slides + `presentations`. +- Host-runtime credential obligations now preserve provider scopes into product + auth account selection and auth-required recovery. Caller-level coverage + exercises `google-drive.list_files` through `HostRuntimeServices`, staged + product-auth credential resolution, host-mediated HTTP egress, and bearer + injection. diff --git a/tests/support/reborn/harness.rs b/tests/support/reborn/harness.rs index 5658aab9fef..f65891d897e 100644 --- a/tests/support/reborn/harness.rs +++ b/tests/support/reborn/harness.rs @@ -2285,6 +2285,7 @@ impl GithubHarnessAuthorizer { Obligation::InjectCredentialAccountOnce { handle: SecretHandle::new("github_runtime_token")?, provider: RuntimeCredentialAccountProviderId::new("github")?, + provider_scopes: Vec::new(), requester_extension: ExtensionId::new("github")?, }, ])?,