diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93f42475..2b8ff06e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,10 @@ jobs: fi - name: Check Rust formatting run: cargo fmt --all -- --check + - name: Validate connector extension contracts + run: | + cargo test -p locality-connector --test manifest_contract --test conformance_testkit + cargo test -p localityd --test connector_manifest - name: Run Rust tests run: cargo test --workspace --all-targets - name: Run real Linux FUSE smoke test diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a15f4a7c..1455bea4 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,13 +6,19 @@ on: - main paths: - ".github/workflows/docs.yml" + - "connectors/**" + - "docs/connector-development.md" - "Makefile" + - "README.md" - "docs-site/**" - "scripts/mintlify-docs.sh" pull_request: paths: - ".github/workflows/docs.yml" + - "connectors/**" + - "docs/connector-development.md" - "Makefile" + - "README.md" - "docs-site/**" - "scripts/mintlify-docs.sh" workflow_dispatch: diff --git a/Cargo.lock b/Cargo.lock index 55377970..d5a2effe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,20 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -218,6 +232,12 @@ dependencies = [ "objc2", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "brotli" version = "8.0.3" @@ -264,6 +284,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -899,6 +925,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "embed-resource" version = "3.0.9" @@ -969,6 +1004,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1020,6 +1066,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1074,6 +1131,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs-set-times" version = "0.20.3" @@ -2045,6 +2112,32 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonschema" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46662859bc5f60a145b75f4632fbadc84e829e45df6c5de74cfc8e05acb96b5" +dependencies = [ + "ahash", + "base64 0.22.1", + "bytecount", + "email_address", + "fancy-regex", + "fraction", + "idna", + "itoa", + "num-cmp", + "num-traits", + "once_cell", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "uuid-simd", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2076,6 +2169,12 @@ dependencies = [ "libc", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2219,6 +2318,7 @@ dependencies = [ name = "locality-connector" version = "0.3.6" dependencies = [ + "jsonschema", "locality-core", "serde", "serde_json", @@ -2432,6 +2532,7 @@ dependencies = [ "chrono", "getrandom 0.3.4", "hmac", + "jsonschema", "libc", "locality-connector", "locality-core", @@ -2650,12 +2751,81 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2933,6 +3103,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "pango" version = "0.18.3" @@ -3272,6 +3448,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "referencing" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9c261f7ce75418b3beadfb3f0eb1299fe8eb9640deba45ffa2cb783098697d" +dependencies = [ + "ahash", + "fluent-uri", + "once_cell", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.4" @@ -5021,6 +5211,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "uuid", + "vsimd", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -5039,6 +5240,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vswhom" version = "0.1.0" diff --git a/README.md b/README.md index 54ac6a6a..eb1e5dfa 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,9 @@ Locality currently includes: restoring, inspecting, and debugging mounts; - a per-user daemon process, `localityd`, that owns hydration, background freshness, virtual filesystem requests, local write tracking, and Live Mode; -- a Notion connector that renders pages/databases to canonical Markdown, - supports conservative block/property writes, handles media under `.loc/media`, - and reconciles changed pages after pushes; +- seven direct connectors: Notion and Google Docs with conservative document + writes, Google Calendar and Gmail with reviewed draft creation, Linear with + issue edits, and read-only Granola and Slack projections; - virtual filesystem projections through macOS File Provider, Linux FUSE, and Windows Cloud Files. - generated `AGENTS.md` and `CLAUDE.md` guidance inside mounts so coding agents @@ -225,7 +225,7 @@ locality-core + locality-store three-tree planner, validation, journals, SQLite state | v -connector SDK +connector SDK + descriptive connector registry | v locality-notion -> Notion API @@ -240,7 +240,13 @@ Core crates and directories: | `crates/localityd` | Per-user daemon for mounts, hydration, freshness, virtual filesystem IPC, and Live Mode. | | `crates/locality-core` | Connector-neutral sync model, canonical Markdown, diff planning, validation, guardrails, conflicts, and journals. | | `crates/locality-connector` | Connector trait and data types for enumerate, fetch, render, parse, apply, and reverse apply. | +| `connectors/` | Versioned language-neutral connector registry and JSON schema. | | `crates/locality-notion` | Notion API client, DTOs, renderer, parser/apply support, database schema handling, media, and OAuth integration. | +| `crates/locality-google-docs` | Google Docs/Drive projection, rendering, OAuth, and conservative document writes. | +| `crates/locality-google-calendar` | Primary-calendar event projection and reviewed event-draft creation. | +| `crates/locality-gmail` | Read-only mail projection and reviewed Gmail draft creation. | +| `crates/locality-granola` | Read-only Granola meeting summary and transcript projection. | +| `crates/locality-linear` | Linear issue projection, issue edits, context sidecars, and API-key auth. | | `crates/locality-slack` | Slack Web API client, OAuth credential handling, read-only conversation projection, and Markdown rendering. | | `crates/locality-store` | SQLite state store, migrations, mounts, entities, shadows, journals, credentials metadata, and freshness state. | | `platform/linux/locality-fuse` | Linux FUSE helper for online-only virtual mounts. | diff --git a/connectors/registry.json b/connectors/registry.json new file mode 100644 index 00000000..a64eb05d --- /dev/null +++ b/connectors/registry.json @@ -0,0 +1,500 @@ +{ + "$schema": "./registry.schema.json", + "schema_version": 1, + "connectors": [ + { + "id": "notion", + "version": "notion.v1", + "display_name": "Notion", + "crate": "crates/locality-notion", + "default_profile_id": "notion-oauth-default", + "default_connection_id": "notion-default", + "profiles": [ + { + "id": "notion-oauth-default", + "display_name": "Notion OAuth", + "auth_kind": "oauth", + "scopes": [], + "actions": ["read", "write"] + }, + { + "id": "notion-token-default", + "display_name": "Notion token auth", + "auth_kind": "token", + "scopes": [], + "actions": ["read", "write"] + } + ], + "mount": { + "default_id": "notion-main", + "read_only": false, + "default_projection_mode": "plain_files", + "default_settings": {}, + "settings_schema": { + "type": "object", + "additionalProperties": false, + "maxProperties": 0 + } + }, + "capabilities": { + "supports_block_updates": true, + "supports_entity_body_updates": false, + "supports_databases": true, + "supports_oauth": true, + "supports_remote_observation": true, + "supports_lazy_child_enumeration": true, + "supports_media_download": true, + "supports_undo": true, + "supports_batch_observation": false + }, + "push_operations": [ + "update_block", + "replace_block", + "append_block", + "move_block", + "update_media", + "archive_block", + "archive_entity", + "update_properties", + "move_entity", + "create_entity", + "create_database" + ], + "membership_operations": [], + "projection": { + "source_root_create_parent_kind": null, + "create_entity_parent_kinds": ["page", "database"], + "move_entity_parent_kinds": ["page", "database"], + "body_diff_mode": "block", + "virtual_rename_policy": "filename_derived", + "periodic_discovery_seconds": null, + "max_background_discovery_workers": 3 + }, + "ui": { + "icon": "notion.svg", + "docs_slug": "notion" + } + }, + { + "id": "google-docs", + "version": "google-docs.v1", + "display_name": "Google Docs", + "crate": "crates/locality-google-docs", + "default_profile_id": "google-docs-oauth-default", + "default_connection_id": "google-docs-default", + "profiles": [ + { + "id": "google-docs-oauth-default", + "display_name": "Google Docs OAuth", + "auth_kind": "oauth", + "scopes": [ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/drive.metadata" + ], + "actions": ["read", "write"] + } + ], + "mount": { + "default_id": "google-docs-main", + "read_only": false, + "default_projection_mode": "plain_files", + "default_settings": {}, + "settings_schema": { + "type": "object", + "additionalProperties": false, + "maxProperties": 0 + } + }, + "capabilities": { + "supports_block_updates": true, + "supports_entity_body_updates": false, + "supports_databases": false, + "supports_oauth": true, + "supports_remote_observation": true, + "supports_lazy_child_enumeration": true, + "supports_media_download": false, + "supports_undo": false, + "supports_batch_observation": false + }, + "push_operations": [ + "update_block", + "replace_block", + "append_block", + "archive_block", + "archive_entity", + "update_properties", + "move_entity", + "create_entity" + ], + "membership_operations": [], + "projection": { + "source_root_create_parent_kind": "directory", + "create_entity_parent_kinds": ["directory"], + "move_entity_parent_kinds": ["directory"], + "body_diff_mode": "block", + "virtual_rename_policy": "filename_derived", + "periodic_discovery_seconds": null, + "max_background_discovery_workers": 4 + }, + "ui": { + "icon": "google-docs.svg", + "docs_slug": "google-docs" + } + }, + { + "id": "google-calendar", + "version": "google-calendar.v1", + "display_name": "Google Calendar", + "crate": "crates/locality-google-calendar", + "default_profile_id": "google-calendar-oauth-default", + "default_connection_id": "google-calendar-default", + "profiles": [ + { + "id": "google-calendar-oauth-default", + "display_name": "Google Calendar OAuth", + "auth_kind": "oauth", + "scopes": [ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/calendar.events" + ], + "actions": ["read", "create"] + } + ], + "mount": { + "default_id": "google-calendar-main", + "read_only": false, + "default_projection_mode": "plain_files", + "default_settings": {}, + "settings_schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "google_calendar": { + "type": "object", + "additionalProperties": false, + "properties": { + "date_window": { + "type": ["object", "null"], + "additionalProperties": false, + "required": ["after", "before"], + "properties": { + "after": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"}, + "before": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"} + } + } + } + } + } + } + }, + "capabilities": { + "supports_block_updates": false, + "supports_entity_body_updates": false, + "supports_databases": false, + "supports_oauth": true, + "supports_remote_observation": true, + "supports_lazy_child_enumeration": true, + "supports_media_download": false, + "supports_undo": false, + "supports_batch_observation": false + }, + "push_operations": ["create_entity"], + "membership_operations": [], + "projection": { + "source_root_create_parent_kind": null, + "create_entity_parent_kinds": ["directory"], + "move_entity_parent_kinds": [], + "body_diff_mode": "block", + "virtual_rename_policy": "filename_derived", + "periodic_discovery_seconds": null, + "max_background_discovery_workers": 4 + }, + "ui": { + "icon": "google-calendar.svg", + "docs_slug": "google-calendar" + } + }, + { + "id": "gmail", + "version": "gmail.v1", + "display_name": "Gmail", + "crate": "crates/locality-gmail", + "default_profile_id": "gmail-oauth-default", + "default_connection_id": "gmail-default", + "profiles": [ + { + "id": "gmail-oauth-default", + "display_name": "Gmail OAuth", + "auth_kind": "oauth", + "scopes": [ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose" + ], + "actions": ["read", "send"] + } + ], + "mount": { + "default_id": "gmail-main", + "read_only": false, + "default_projection_mode": "plain_files", + "default_settings": {}, + "settings_schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "gmail": { + "type": "object", + "additionalProperties": false, + "properties": { + "date_window": { + "type": ["object", "null"], + "additionalProperties": false, + "required": ["after", "before"], + "properties": { + "after": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"}, + "before": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"} + } + }, + "view": {"type": "string", "enum": ["messages", "threads"]} + } + } + } + } + }, + "capabilities": { + "supports_block_updates": false, + "supports_entity_body_updates": false, + "supports_databases": false, + "supports_oauth": true, + "supports_remote_observation": true, + "supports_lazy_child_enumeration": true, + "supports_media_download": false, + "supports_undo": false, + "supports_batch_observation": false + }, + "push_operations": ["create_entity"], + "membership_operations": [], + "projection": { + "source_root_create_parent_kind": null, + "create_entity_parent_kinds": ["directory"], + "move_entity_parent_kinds": ["directory"], + "body_diff_mode": "block", + "virtual_rename_policy": "filename_derived", + "periodic_discovery_seconds": null, + "max_background_discovery_workers": 4 + }, + "ui": { + "icon": "gmail.svg", + "docs_slug": "gmail" + } + }, + { + "id": "granola", + "version": "granola.v1", + "display_name": "Granola", + "crate": "crates/locality-granola", + "default_profile_id": "granola-api-key-default", + "default_connection_id": "granola-default", + "profiles": [ + { + "id": "granola-api-key-default", + "display_name": "Granola API key", + "auth_kind": "api_key", + "scopes": ["read"], + "actions": ["read"] + } + ], + "mount": { + "default_id": "granola-main", + "read_only": true, + "default_projection_mode": "plain_files", + "default_settings": {}, + "settings_schema": { + "type": "object", + "additionalProperties": false, + "maxProperties": 0 + } + }, + "capabilities": { + "supports_block_updates": false, + "supports_entity_body_updates": false, + "supports_databases": false, + "supports_oauth": false, + "supports_remote_observation": true, + "supports_lazy_child_enumeration": true, + "supports_media_download": false, + "supports_undo": false, + "supports_batch_observation": false + }, + "push_operations": [], + "membership_operations": [], + "projection": { + "source_root_create_parent_kind": null, + "create_entity_parent_kinds": [], + "move_entity_parent_kinds": [], + "body_diff_mode": "block", + "virtual_rename_policy": "filename_derived", + "periodic_discovery_seconds": 300, + "max_background_discovery_workers": 3 + }, + "ui": { + "icon": "granola.svg", + "docs_slug": "granola" + } + }, + { + "id": "linear", + "version": "linear.v1", + "display_name": "Linear", + "crate": "crates/locality-linear", + "default_profile_id": "linear-api-key-default", + "default_connection_id": "linear-default", + "profiles": [ + { + "id": "linear-api-key-default", + "display_name": "Linear API key", + "auth_kind": "api_key", + "scopes": ["issues:read", "issues:write"], + "actions": ["read", "write"] + } + ], + "mount": { + "default_id": "linear-main", + "read_only": false, + "default_projection_mode": "plain_files", + "default_settings": {}, + "settings_schema": { + "type": "object", + "additionalProperties": false, + "maxProperties": 0 + } + }, + "capabilities": { + "supports_block_updates": false, + "supports_entity_body_updates": true, + "supports_databases": false, + "supports_oauth": false, + "supports_remote_observation": true, + "supports_lazy_child_enumeration": true, + "supports_media_download": true, + "supports_undo": false, + "supports_batch_observation": true + }, + "push_operations": ["update_entity_body", "update_properties", "move_entity"], + "membership_operations": [], + "projection": { + "source_root_create_parent_kind": null, + "create_entity_parent_kinds": [], + "move_entity_parent_kinds": ["directory"], + "body_diff_mode": "whole_entity", + "virtual_rename_policy": "preserve_canonical", + "periodic_discovery_seconds": 300, + "max_background_discovery_workers": 3 + }, + "ui": { + "icon": "linear.svg", + "docs_slug": "linear" + } + }, + { + "id": "slack", + "version": "slack.v1", + "display_name": "Slack", + "crate": "crates/locality-slack", + "default_profile_id": "slack-oauth-default", + "default_connection_id": "slack-default", + "profiles": [ + { + "id": "slack-oauth-default", + "display_name": "Slack OAuth", + "auth_kind": "oauth", + "scopes": [ + "channels:read", + "channels:history", + "groups:read", + "groups:history", + "im:read", + "im:history", + "mpim:read", + "mpim:history", + "users:read", + "team:read", + "files:read", + "channels:join" + ], + "actions": [] + } + ], + "mount": { + "default_id": "slack-main", + "read_only": true, + "default_projection_mode": "plain_files", + "default_settings": { + "slack": { + "history_limit": 15, + "types": ["public_channel", "private_channel", "im", "mpim"], + "auto_join_public_channels": true + } + }, + "settings_schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "slack": { + "type": "object", + "additionalProperties": false, + "properties": { + "history_limit": {"type": "integer", "minimum": 1, "maximum": 15}, + "types": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": ["public_channel", "private_channel", "im", "mpim"] + } + }, + "auto_join_public_channels": {"type": "boolean"} + } + } + } + } + }, + "capabilities": { + "supports_block_updates": false, + "supports_entity_body_updates": false, + "supports_databases": false, + "supports_oauth": true, + "supports_remote_observation": true, + "supports_lazy_child_enumeration": true, + "supports_media_download": false, + "supports_undo": false, + "supports_batch_observation": false + }, + "push_operations": [], + "membership_operations": ["join_public_channels"], + "projection": { + "source_root_create_parent_kind": null, + "create_entity_parent_kinds": [], + "move_entity_parent_kinds": [], + "body_diff_mode": "block", + "virtual_rename_policy": "filename_derived", + "periodic_discovery_seconds": null, + "max_background_discovery_workers": 1 + }, + "ui": { + "icon": "slack.svg", + "docs_slug": "slack" + } + } + ] +} diff --git a/connectors/registry.schema.json b/connectors/registry.schema.json new file mode 100644 index 00000000..dfbbb802 --- /dev/null +++ b/connectors/registry.schema.json @@ -0,0 +1,226 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://locality.dev/schemas/connector-registry-v1.json", + "title": "Locality connector registry v1", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "schema_version", "connectors"], + "properties": { + "$schema": {"const": "./registry.schema.json"}, + "schema_version": {"const": 1}, + "connectors": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": {"$ref": "#/$defs/connector"} + } + }, + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "connector": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "version", + "display_name", + "crate", + "default_profile_id", + "default_connection_id", + "profiles", + "mount", + "capabilities", + "push_operations", + "membership_operations", + "projection", + "ui" + ], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "version": { + "type": "string", + "minLength": 4, + "maxLength": 80, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*\\.v[1-9][0-9]*$" + }, + "display_name": {"type": "string", "minLength": 1, "maxLength": 80}, + "crate": { + "type": "string", + "minLength": 17, + "maxLength": 96, + "pattern": "^crates/locality-[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "default_profile_id": {"$ref": "#/$defs/identifier"}, + "default_connection_id": {"$ref": "#/$defs/identifier"}, + "profiles": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": {"$ref": "#/$defs/profile"} + }, + "mount": {"$ref": "#/$defs/mount"}, + "capabilities": {"$ref": "#/$defs/capabilities"}, + "push_operations": { + "type": "array", + "maxItems": 12, + "uniqueItems": true, + "items": {"$ref": "#/$defs/push_operation"} + }, + "membership_operations": { + "type": "array", + "maxItems": 4, + "uniqueItems": true, + "items": {"enum": ["join_public_channels"]} + }, + "projection": {"$ref": "#/$defs/projection"}, + "ui": {"$ref": "#/$defs/ui"} + } + }, + "profile": { + "type": "object", + "additionalProperties": false, + "required": ["id", "display_name", "auth_kind", "scopes", "actions"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "display_name": {"type": "string", "minLength": 1, "maxLength": 80}, + "auth_kind": {"enum": ["oauth", "token", "api_key"]}, + "scopes": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": {"type": "string", "minLength": 1, "maxLength": 256} + }, + "actions": { + "type": "array", + "maxItems": 16, + "uniqueItems": true, + "items": {"enum": ["read", "write", "create", "send"]} + } + } + }, + "mount": { + "type": "object", + "additionalProperties": false, + "required": [ + "default_id", + "read_only", + "default_projection_mode", + "default_settings", + "settings_schema" + ], + "properties": { + "default_id": {"$ref": "#/$defs/identifier"}, + "read_only": {"type": "boolean"}, + "default_projection_mode": { + "enum": ["plain_files", "macos_file_provider", "linux_fuse", "windows_cloud_files"] + }, + "default_settings": {"type": "object", "maxProperties": 64}, + "settings_schema": {"type": "object", "minProperties": 1, "maxProperties": 64} + } + }, + "capabilities": { + "type": "object", + "additionalProperties": false, + "required": [ + "supports_block_updates", + "supports_entity_body_updates", + "supports_databases", + "supports_oauth", + "supports_remote_observation", + "supports_lazy_child_enumeration", + "supports_media_download", + "supports_undo", + "supports_batch_observation" + ], + "properties": { + "supports_block_updates": {"type": "boolean"}, + "supports_entity_body_updates": {"type": "boolean"}, + "supports_databases": {"type": "boolean"}, + "supports_oauth": {"type": "boolean"}, + "supports_remote_observation": {"type": "boolean"}, + "supports_lazy_child_enumeration": {"type": "boolean"}, + "supports_media_download": {"type": "boolean"}, + "supports_undo": {"type": "boolean"}, + "supports_batch_observation": {"type": "boolean"} + } + }, + "push_operation": { + "enum": [ + "update_block", + "replace_block", + "append_block", + "move_block", + "update_media", + "archive_block", + "archive_entity", + "update_entity_body", + "update_properties", + "move_entity", + "create_entity", + "create_database" + ] + }, + "projection": { + "type": "object", + "additionalProperties": false, + "required": [ + "source_root_create_parent_kind", + "create_entity_parent_kinds", + "move_entity_parent_kinds", + "body_diff_mode", + "virtual_rename_policy", + "periodic_discovery_seconds", + "max_background_discovery_workers" + ], + "properties": { + "source_root_create_parent_kind": { + "oneOf": [{"$ref": "#/$defs/entity_kind"}, {"type": "null"}] + }, + "create_entity_parent_kinds": { + "type": "array", + "maxItems": 3, + "uniqueItems": true, + "items": {"$ref": "#/$defs/entity_kind"} + }, + "move_entity_parent_kinds": { + "type": "array", + "maxItems": 3, + "uniqueItems": true, + "items": {"$ref": "#/$defs/entity_kind"} + }, + "body_diff_mode": {"enum": ["block", "whole_entity"]}, + "virtual_rename_policy": {"enum": ["filename_derived", "preserve_canonical"]}, + "periodic_discovery_seconds": { + "type": ["integer", "null"], + "minimum": 30, + "maximum": 86400 + }, + "max_background_discovery_workers": { + "type": "integer", + "minimum": 1, + "maximum": 32 + } + } + }, + "entity_kind": {"enum": ["page", "database", "directory"]}, + "ui": { + "type": "object", + "additionalProperties": false, + "required": ["icon", "docs_slug"], + "properties": { + "icon": { + "type": "string", + "minLength": 5, + "maxLength": 80, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*\\.svg$" + }, + "docs_slug": {"$ref": "#/$defs/identifier"} + } + } + } +} diff --git a/crates/loc-cli/tests/mount.rs b/crates/loc-cli/tests/mount.rs index 8c8a612b..8a7b871c 100644 --- a/crates/loc-cli/tests/mount.rs +++ b/crates/loc-cli/tests/mount.rs @@ -739,7 +739,7 @@ fn cli_mount_slack_persists_auto_join_public_channels_setting() { } #[test] -fn cli_mount_slack_omits_auto_join_when_public_channel_type_is_excluded() { +fn cli_mount_slack_persists_disabled_auto_join_when_public_channel_type_is_excluded() { let fixture = MountFixture::new("loc-cli-slack-auto-join-public-excluded"); fs::create_dir_all(&fixture.root).expect("create fixture root"); let state_root = fixture.root.join("state"); @@ -765,7 +765,7 @@ fn cli_mount_slack_omits_auto_join_when_public_channel_type_is_excluded() { assert_eq!( report["settings_json"], - r#"{"slack":{"history_limit":15,"types":["im","mpim"]}}"# + r#"{"slack":{"history_limit":15,"types":["im","mpim"],"auto_join_public_channels":false}}"# ); } diff --git a/crates/locality-connector/Cargo.toml b/crates/locality-connector/Cargo.toml index 82ea314f..be4b702a 100644 --- a/crates/locality-connector/Cargo.toml +++ b/crates/locality-connector/Cargo.toml @@ -13,6 +13,7 @@ path = "src/lib.rs" [dependencies] locality-core.workspace = true serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" [dev-dependencies] -serde_json = "1.0" +jsonschema = { version = "0.33", default-features = false } diff --git a/crates/locality-connector/src/conformance.rs b/crates/locality-connector/src/conformance.rs new file mode 100644 index 00000000..56eccd94 --- /dev/null +++ b/crates/locality-connector/src/conformance.rs @@ -0,0 +1,319 @@ +//! Reusable, credential-free connector conformance checks. + +use std::collections::BTreeSet; +use std::fmt::{self, Debug}; +use std::fs; +use std::path::{Component, Path}; + +use locality_core::planner::PushOperationKind; + +use crate::manifest::{ConnectorManifest, is_safe_relative_identifier}; +use crate::{Connector, ConnectorCapabilities}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConformanceError(pub String); + +impl fmt::Display for ConformanceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for ConformanceError {} + +pub fn check_manifest_identity( + manifest: &ConnectorManifest, + connector: &C, +) -> Result<(), ConformanceError> { + let runtime_id = connector.kind().0; + if manifest.id == runtime_id { + Ok(()) + } else { + Err(ConformanceError(format!( + "manifest id `{}` does not match Connector::kind() `{runtime_id}`", + manifest.id + ))) + } +} + +pub fn check_capability_operation_agreement( + manifest: &ConnectorManifest, + connector: &C, +) -> Result<(), ConformanceError> { + check_capabilities(manifest, &connector.capabilities())?; + let runtime_operations = connector.supported_push_operations(); + let manifest_operations = manifest.runtime_push_operations(); + if manifest_operations != runtime_operations { + let only_manifest = manifest_operations + .difference(&runtime_operations) + .map(PushOperationKind::as_str) + .collect::>(); + let only_runtime = runtime_operations + .difference(&manifest_operations) + .map(PushOperationKind::as_str) + .collect::>(); + return Err(ConformanceError(format!( + "connector `{}` push operations drifted; manifest only: {only_manifest:?}; runtime only: {only_runtime:?}", + manifest.id + ))); + } + Ok(()) +} + +pub fn check_capabilities( + manifest: &ConnectorManifest, + runtime: &ConnectorCapabilities, +) -> Result<(), ConformanceError> { + let described = manifest.capabilities.as_runtime_capabilities(); + if described == *runtime { + Ok(()) + } else { + Err(ConformanceError(format!( + "connector `{}` capabilities drifted; manifest: {described:?}; runtime: {runtime:?}", + manifest.id + ))) + } +} + +pub fn check_manifest_asset_paths(manifest: &ConnectorManifest) -> Result<(), ConformanceError> { + let icon_stem = manifest + .ui + .icon + .strip_suffix(".svg") + .ok_or_else(|| ConformanceError("icon must end in .svg".to_string()))?; + if !is_safe_relative_identifier(icon_stem) + || !is_safe_relative_identifier(&manifest.ui.docs_slug) + { + return Err(ConformanceError(format!( + "connector `{}` has unsafe docs or icon identifiers", + manifest.id + ))); + } + Ok(()) +} + +pub fn check_read_only_rejection( + manifest: &ConnectorManifest, + decisions_are_writable: impl IntoIterator, +) -> Result<(), ConformanceError> { + if !manifest.mount.read_only { + return Ok(()); + } + if decisions_are_writable.into_iter().any(|writable| writable) { + Err(ConformanceError(format!( + "read-only connector `{}` accepted a write decision", + manifest.id + ))) + } else { + Ok(()) + } +} + +pub fn check_debug_redaction( + value: &impl Debug, + secret_values: &[&str], +) -> Result<(), ConformanceError> { + let rendered = format!("{value:?}"); + for secret in secret_values { + if !secret.is_empty() && rendered.contains(secret) { + return Err(ConformanceError( + "Debug output contains credential or secret material".to_string(), + )); + } + } + Ok(()) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FixtureLayout<'a> { + pub version_directory: &'a str, + pub required_files: &'a [&'a str], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DirectFixtureAuth { + Oauth, + Token, + ApiKey, +} + +pub fn check_direct_fixture_layout( + connector_crate_root: &Path, + version_directory: &str, + auth: DirectFixtureAuth, +) -> Result<(), ConformanceError> { + if !is_versioned_direct_fixture_directory(version_directory) { + return Err(ConformanceError(format!( + "direct fixture directory `{version_directory}` must use direct-v" + ))); + } + let auth_file = match auth { + DirectFixtureAuth::Oauth => "auth-scopes.json", + DirectFixtureAuth::Token | DirectFixtureAuth::ApiKey => "auth-kind.txt", + }; + check_fixture_layout( + connector_crate_root, + &FixtureLayout { + version_directory, + required_files: &[ + ".gitattributes", + "tree-paths.txt", + "settings-default.json", + auth_file, + ], + }, + )?; + + let root = connector_crate_root + .join("fixtures") + .join(version_directory); + for obsolete_or_conflicting in match auth { + DirectFixtureAuth::Oauth => ["oauth-scopes.json", "auth-kind.txt"], + DirectFixtureAuth::Token | DirectFixtureAuth::ApiKey => { + ["oauth-scopes.json", "auth-scopes.json"] + } + } { + if root.join(obsolete_or_conflicting).exists() { + return Err(ConformanceError(format!( + "fixture `{}` conflicts with standardized auth file `{auth_file}`", + root.join(obsolete_or_conflicting).display() + ))); + } + } + + match auth { + DirectFixtureAuth::Oauth => validate_oauth_scope_fixture(&root.join(auth_file))?, + DirectFixtureAuth::Token | DirectFixtureAuth::ApiKey => { + let expected = match auth { + DirectFixtureAuth::Token => "token", + DirectFixtureAuth::ApiKey => "api_key", + DirectFixtureAuth::Oauth => unreachable!(), + }; + let actual = fs::read_to_string(root.join(auth_file)).map_err(|error| { + ConformanceError(format!("failed to read `{auth_file}`: {error}")) + })?; + if actual.trim() != expected { + return Err(ConformanceError(format!( + "`{auth_file}` must contain exactly `{expected}`" + ))); + } + } + } + + let mut native_cases = BTreeSet::new(); + for entry in fs::read_dir(&root).map_err(|error| { + ConformanceError(format!("failed to read `{}`: {error}", root.display())) + })? { + let entry = entry.map_err(|error| ConformanceError(error.to_string()))?; + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + let Some(case) = name + .strip_prefix("native-") + .and_then(|name| name.strip_suffix(".json")) + else { + continue; + }; + if !is_safe_relative_identifier(case) { + return Err(ConformanceError(format!( + "native fixture case `{case}` must be a safe identifier" + ))); + } + native_cases.insert(case.to_string()); + } + if native_cases.is_empty() { + return Err(ConformanceError(format!( + "fixture directory `{}` must contain at least one native-.json", + root.display() + ))); + } + for case in native_cases { + let rendered = root.join(format!("{case}.md")); + if !rendered.is_file() { + return Err(ConformanceError(format!( + "native fixture case `{case}` is missing `{}`", + rendered.display() + ))); + } + } + Ok(()) +} + +fn is_versioned_direct_fixture_directory(value: &str) -> bool { + value + .strip_prefix("direct-v") + .and_then(|version| version.parse::().ok().map(|parsed| (version, parsed))) + .is_some_and(|(version, parsed)| parsed > 0 && version == parsed.to_string()) +} + +fn validate_oauth_scope_fixture(path: &Path) -> Result<(), ConformanceError> { + let bytes = fs::read(path).map_err(|error| { + ConformanceError(format!("failed to read `{}`: {error}", path.display())) + })?; + let scopes = serde_json::from_slice::>(&bytes).map_err(|error| { + ConformanceError(format!( + "OAuth scope fixture `{}` is invalid: {error}", + path.display() + )) + })?; + if scopes.is_empty() || scopes.iter().any(String::is_empty) { + return Err(ConformanceError( + "OAuth scope fixture must contain non-empty scope names".to_string(), + )); + } + if scopes.iter().collect::>().len() != scopes.len() { + return Err(ConformanceError( + "OAuth scope fixture must not contain duplicate scopes".to_string(), + )); + } + Ok(()) +} + +pub fn check_fixture_layout( + connector_crate_root: &Path, + layout: &FixtureLayout<'_>, +) -> Result<(), ConformanceError> { + if !is_safe_relative_identifier(layout.version_directory) { + return Err(ConformanceError(format!( + "fixture version directory `{}` is unsafe", + layout.version_directory + ))); + } + let root = connector_crate_root + .join("fixtures") + .join(layout.version_directory); + if !root.is_dir() { + return Err(ConformanceError(format!( + "fixture directory `{}` is missing", + root.display() + ))); + } + for relative in layout.required_files { + let relative = Path::new(relative); + if !is_safe_relative_path(relative) { + return Err(ConformanceError(format!( + "fixture path `{}` is unsafe", + relative.display() + ))); + } + let fixture = root.join(relative); + if !fixture.is_file() { + return Err(ConformanceError(format!( + "required fixture `{}` is missing", + fixture.display() + ))); + } + } + Ok(()) +} + +pub fn is_safe_relative_path(path: &Path) -> bool { + !path.as_os_str().is_empty() + && !path.is_absolute() + && path.components().all(|component| match component { + Component::Normal(value) => value + .to_str() + .is_some_and(|value| !value.is_empty() && !value.chars().any(char::is_control)), + _ => false, + }) +} diff --git a/crates/locality-connector/src/lib.rs b/crates/locality-connector/src/lib.rs index 7ae935a0..787d8bbe 100644 --- a/crates/locality-connector/src/lib.rs +++ b/crates/locality-connector/src/lib.rs @@ -17,6 +17,8 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; use std::path::Path; +pub mod conformance; +pub mod manifest; pub mod network; pub mod oauth_broker; diff --git a/crates/locality-connector/src/manifest.rs b/crates/locality-connector/src/manifest.rs new file mode 100644 index 00000000..44ec46a1 --- /dev/null +++ b/crates/locality-connector/src/manifest.rs @@ -0,0 +1,902 @@ +//! Versioned descriptive connector manifest contract. +//! +//! The manifest is discovery and conformance metadata. It never grants network, +//! credential, filesystem, or push authority; hosts must continue to enforce +//! those policies in trusted code. + +use std::collections::BTreeSet; +use std::fmt; +use std::sync::OnceLock; + +use locality_core::planner::PushOperationKind; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::ConnectorCapabilities; + +pub const CONNECTOR_REGISTRY_SCHEMA_VERSION: u16 = 1; +pub const CONNECTOR_REGISTRY_JSON: &str = include_str!("../../../connectors/registry.json"); +pub const CONNECTOR_REGISTRY_SCHEMA_JSON: &str = + include_str!("../../../connectors/registry.schema.json"); + +const MAX_CONNECTORS: usize = 64; +const MAX_PROFILES: usize = 8; +const MAX_SCOPES: usize = 64; +const MAX_ACTIONS: usize = 16; +const MAX_ID_LEN: usize = 64; +const MAX_DISPLAY_NAME_LEN: usize = 80; +const MAX_SCOPE_LEN: usize = 256; +const MIN_DISCOVERY_SECONDS: u64 = 30; +const MAX_DISCOVERY_SECONDS: u64 = 86_400; +const MAX_BACKGROUND_WORKERS: usize = 32; + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConnectorRegistry { + #[serde(rename = "$schema")] + pub schema: String, + pub schema_version: u16, + pub connectors: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConnectorManifest { + pub id: String, + pub version: String, + pub display_name: String, + #[serde(rename = "crate")] + pub crate_path: String, + pub default_profile_id: String, + pub default_connection_id: String, + pub profiles: Vec, + pub mount: ConnectorMountManifest, + pub capabilities: ManifestCapabilities, + pub push_operations: Vec, + pub membership_operations: Vec, + pub projection: ProjectionPolicyManifest, + pub ui: ConnectorUiManifest, +} + +#[derive(Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConnectorProfileManifest { + pub id: String, + pub display_name: String, + pub auth_kind: AuthKind, + pub scopes: Vec, + pub actions: Vec, +} + +impl fmt::Debug for ConnectorProfileManifest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ConnectorProfileManifest") + .field("id", &self.id) + .field("display_name", &self.display_name) + .field("auth_kind", &self.auth_kind) + .field( + "scopes", + &format_args!("<{} descriptive scopes>", self.scopes.len()), + ) + .field("actions", &self.actions) + .finish() + } +} + +#[derive(Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConnectorMountManifest { + pub default_id: String, + pub read_only: bool, + pub default_projection_mode: ProjectionMode, + pub default_settings: Value, + pub settings_schema: Value, +} + +impl fmt::Debug for ConnectorMountManifest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ConnectorMountManifest") + .field("default_id", &self.default_id) + .field("read_only", &self.read_only) + .field("default_projection_mode", &self.default_projection_mode) + .field("default_settings", &"") + .field("settings_schema", &"") + .finish() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManifestCapabilities { + pub supports_block_updates: bool, + pub supports_entity_body_updates: bool, + pub supports_databases: bool, + pub supports_oauth: bool, + pub supports_remote_observation: bool, + pub supports_lazy_child_enumeration: bool, + pub supports_media_download: bool, + pub supports_undo: bool, + pub supports_batch_observation: bool, +} + +impl ManifestCapabilities { + pub fn as_runtime_capabilities(&self) -> ConnectorCapabilities { + ConnectorCapabilities { + supports_block_updates: self.supports_block_updates, + supports_entity_body_updates: self.supports_entity_body_updates, + supports_databases: self.supports_databases, + supports_oauth: self.supports_oauth, + supports_remote_observation: self.supports_remote_observation, + supports_lazy_child_enumeration: self.supports_lazy_child_enumeration, + supports_media_download: self.supports_media_download, + supports_undo: self.supports_undo, + supports_batch_observation: self.supports_batch_observation, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectionPolicyManifest { + pub source_root_create_parent_kind: Option, + pub create_entity_parent_kinds: Vec, + pub move_entity_parent_kinds: Vec, + pub body_diff_mode: BodyDiffMode, + pub virtual_rename_policy: VirtualRenamePolicy, + pub periodic_discovery_seconds: Option, + pub max_background_discovery_workers: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConnectorUiManifest { + pub icon: String, + pub docs_slug: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthKind { + Oauth, + Token, + ApiKey, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConnectorAction { + Read, + Write, + Create, + Send, +} + +impl ConnectorAction { + fn mutates_remote(self) -> bool { + !matches!(self, Self::Read) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProjectionMode { + PlainFiles, + MacosFileProvider, + LinuxFuse, + WindowsCloudFiles, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManifestPushOperation { + UpdateBlock, + ReplaceBlock, + AppendBlock, + MoveBlock, + UpdateMedia, + ArchiveBlock, + ArchiveEntity, + UpdateEntityBody, + UpdateProperties, + MoveEntity, + CreateEntity, + CreateDatabase, +} + +/// Remote membership changes that are not content writes or push authority. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MembershipOperation { + JoinPublicChannels, +} + +impl ManifestPushOperation { + pub fn as_runtime_kind(self) -> PushOperationKind { + match self { + Self::UpdateBlock => PushOperationKind::UpdateBlock, + Self::ReplaceBlock => PushOperationKind::ReplaceBlock, + Self::AppendBlock => PushOperationKind::AppendBlock, + Self::MoveBlock => PushOperationKind::MoveBlock, + Self::UpdateMedia => PushOperationKind::UpdateMedia, + Self::ArchiveBlock => PushOperationKind::ArchiveBlock, + Self::ArchiveEntity => PushOperationKind::ArchiveEntity, + Self::UpdateEntityBody => PushOperationKind::UpdateEntityBody, + Self::UpdateProperties => PushOperationKind::UpdateProperties, + Self::MoveEntity => PushOperationKind::MoveEntity, + Self::CreateEntity => PushOperationKind::CreateEntity, + Self::CreateDatabase => PushOperationKind::CreateDatabase, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManifestEntityKind { + Page, + Database, + Directory, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BodyDiffMode { + Block, + WholeEntity, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VirtualRenamePolicy { + FilenameDerived, + PreserveCanonical, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ManifestError { + Json(String), + Validation(Vec), +} + +impl fmt::Display for ManifestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Json(message) => { + write!(formatter, "connector registry JSON is invalid: {message}") + } + Self::Validation(violations) => { + write!(formatter, "connector registry validation failed")?; + for violation in violations { + write!(formatter, "; {}: {}", violation.path, violation.message)?; + } + Ok(()) + } + } + } +} + +impl std::error::Error for ManifestError {} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ManifestViolation { + pub path: String, + pub message: String, +} + +impl ManifestViolation { + fn new(path: impl Into, message: impl Into) -> Self { + Self { + path: path.into(), + message: message.into(), + } + } +} + +impl ConnectorRegistry { + pub fn parse(json: &str) -> Result { + let registry = serde_json::from_str::(json) + .map_err(|error| ManifestError::Json(error.to_string()))?; + registry.validate()?; + Ok(registry) + } + + pub fn validate(&self) -> Result<(), ManifestError> { + let mut violations = Vec::new(); + if self.schema != "./registry.schema.json" { + violations.push(ManifestViolation::new( + "$.$schema", + "must be ./registry.schema.json", + )); + } + if self.schema_version != CONNECTOR_REGISTRY_SCHEMA_VERSION { + violations.push(ManifestViolation::new( + "$.schema_version", + format!( + "unsupported version {}; expected {CONNECTOR_REGISTRY_SCHEMA_VERSION}", + self.schema_version + ), + )); + } + if self.connectors.is_empty() || self.connectors.len() > MAX_CONNECTORS { + violations.push(ManifestViolation::new( + "$.connectors", + format!("must contain between 1 and {MAX_CONNECTORS} connectors"), + )); + } + + let mut connector_ids = BTreeSet::new(); + let mut profile_ids = BTreeSet::new(); + let mut connection_ids = BTreeSet::new(); + let mut mount_ids = BTreeSet::new(); + for (index, connector) in self.connectors.iter().enumerate() { + let path = format!("$.connectors[{index}]"); + validate_connector(connector, &path, &mut violations); + require_unique( + &mut connector_ids, + &connector.id, + format!("{path}.id"), + "connector id", + &mut violations, + ); + require_unique( + &mut connection_ids, + &connector.default_connection_id, + format!("{path}.default_connection_id"), + "default connection id", + &mut violations, + ); + require_unique( + &mut mount_ids, + &connector.mount.default_id, + format!("{path}.mount.default_id"), + "default mount id", + &mut violations, + ); + for (profile_index, profile) in connector.profiles.iter().enumerate() { + require_unique( + &mut profile_ids, + &profile.id, + format!("{path}.profiles[{profile_index}].id"), + "profile id", + &mut violations, + ); + } + } + + if violations.is_empty() { + Ok(()) + } else { + Err(ManifestError::Validation(violations)) + } + } + + pub fn connector(&self, id: &str) -> Option<&ConnectorManifest> { + self.connectors.iter().find(|connector| connector.id == id) + } +} + +impl ConnectorManifest { + pub fn runtime_push_operations(&self) -> BTreeSet { + self.push_operations + .iter() + .map(|operation| operation.as_runtime_kind()) + .collect() + } + + pub fn has_oauth_profile(&self) -> bool { + self.profiles + .iter() + .any(|profile| profile.auth_kind == AuthKind::Oauth) + } +} + +static BUNDLED_REGISTRY: OnceLock> = OnceLock::new(); + +pub fn bundled_connector_registry() -> Result<&'static ConnectorRegistry, ManifestError> { + match BUNDLED_REGISTRY.get_or_init(|| ConnectorRegistry::parse(CONNECTOR_REGISTRY_JSON)) { + Ok(registry) => Ok(registry), + Err(error) => Err(error.clone()), + } +} + +pub fn is_safe_relative_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_ID_LEN + && value.split('-').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + }) +} + +fn validate_connector( + connector: &ConnectorManifest, + path: &str, + violations: &mut Vec, +) { + validate_identifier(&connector.id, format!("{path}.id"), violations); + validate_identifier( + &connector.default_profile_id, + format!("{path}.default_profile_id"), + violations, + ); + validate_identifier( + &connector.default_connection_id, + format!("{path}.default_connection_id"), + violations, + ); + validate_display_name( + &connector.display_name, + format!("{path}.display_name"), + violations, + ); + let crate_suffix = connector.crate_path.strip_prefix("crates/locality-"); + if crate_suffix.is_none_or(|suffix| !is_safe_relative_identifier(suffix)) { + violations.push(ManifestViolation::new( + format!("{path}.crate"), + "must be a safe crates/locality- path", + )); + } + let expected_version_prefix = format!("{}.v", connector.id); + if connector.version.len() > MAX_DISPLAY_NAME_LEN + || !connector.version.starts_with(&expected_version_prefix) + || connector.version[expected_version_prefix.len()..] + .parse::() + .ok() + .is_none_or(|version| version == 0) + { + violations.push(ManifestViolation::new( + format!("{path}.version"), + format!("must use {}", expected_version_prefix), + )); + } + + if connector.profiles.is_empty() || connector.profiles.len() > MAX_PROFILES { + violations.push(ManifestViolation::new( + format!("{path}.profiles"), + format!("must contain between 1 and {MAX_PROFILES} profiles"), + )); + } + let mut local_profile_ids = BTreeSet::new(); + for (index, profile) in connector.profiles.iter().enumerate() { + let profile_path = format!("{path}.profiles[{index}]"); + validate_profile(profile, &profile_path, violations); + require_unique( + &mut local_profile_ids, + &profile.id, + format!("{profile_path}.id"), + "connector profile id", + violations, + ); + } + if !local_profile_ids.contains(connector.default_profile_id.as_str()) { + violations.push(ManifestViolation::new( + format!("{path}.default_profile_id"), + "must name exactly one profile in this connector", + )); + } + + let has_oauth = connector.has_oauth_profile(); + if connector.capabilities.supports_oauth != has_oauth { + violations.push(ManifestViolation::new( + format!("{path}.capabilities.supports_oauth"), + "must agree with the presence of an OAuth profile", + )); + } + + validate_mount(&connector.mount, &format!("{path}.mount"), violations); + validate_projection( + &connector.projection, + &format!("{path}.projection"), + violations, + ); + validate_operations(connector, path, violations); + + let icon_stem = connector.ui.icon.strip_suffix(".svg"); + if icon_stem.is_none_or(|stem| !is_safe_relative_identifier(stem)) { + violations.push(ManifestViolation::new( + format!("{path}.ui.icon"), + "must be a safe relative kebab-case .svg filename", + )); + } + validate_identifier( + &connector.ui.docs_slug, + format!("{path}.ui.docs_slug"), + violations, + ); +} + +fn validate_profile( + profile: &ConnectorProfileManifest, + path: &str, + violations: &mut Vec, +) { + validate_identifier(&profile.id, format!("{path}.id"), violations); + validate_display_name( + &profile.display_name, + format!("{path}.display_name"), + violations, + ); + validate_unique_strings( + &profile.scopes, + MAX_SCOPES, + MAX_SCOPE_LEN, + &format!("{path}.scopes"), + violations, + ); + validate_unique_values( + &profile.actions, + MAX_ACTIONS, + &format!("{path}.actions"), + violations, + ); +} + +fn validate_mount( + mount: &ConnectorMountManifest, + path: &str, + violations: &mut Vec, +) { + validate_identifier(&mount.default_id, format!("{path}.default_id"), violations); + if !mount.default_settings.is_object() { + violations.push(ManifestViolation::new( + format!("{path}.default_settings"), + "must be a JSON object", + )); + } + if !mount.settings_schema.is_object() { + violations.push(ManifestViolation::new( + format!("{path}.settings_schema"), + "must be a JSON Schema object", + )); + } + reject_sensitive_keys( + &mount.default_settings, + &format!("{path}.default_settings"), + violations, + ); + reject_sensitive_keys( + &mount.settings_schema, + &format!("{path}.settings_schema"), + violations, + ); +} + +fn validate_projection( + projection: &ProjectionPolicyManifest, + path: &str, + violations: &mut Vec, +) { + validate_unique_values( + &projection.create_entity_parent_kinds, + 3, + &format!("{path}.create_entity_parent_kinds"), + violations, + ); + validate_unique_values( + &projection.move_entity_parent_kinds, + 3, + &format!("{path}.move_entity_parent_kinds"), + violations, + ); + if projection + .periodic_discovery_seconds + .is_some_and(|seconds| !(MIN_DISCOVERY_SECONDS..=MAX_DISCOVERY_SECONDS).contains(&seconds)) + { + violations.push(ManifestViolation::new( + format!("{path}.periodic_discovery_seconds"), + format!("must be null or between {MIN_DISCOVERY_SECONDS} and {MAX_DISCOVERY_SECONDS}"), + )); + } + if !(1..=MAX_BACKGROUND_WORKERS).contains(&projection.max_background_discovery_workers) { + violations.push(ManifestViolation::new( + format!("{path}.max_background_discovery_workers"), + format!("must be between 1 and {MAX_BACKGROUND_WORKERS}"), + )); + } + if projection.body_diff_mode == BodyDiffMode::WholeEntity + && projection.virtual_rename_policy != VirtualRenamePolicy::PreserveCanonical + { + violations.push(ManifestViolation::new( + format!("{path}.virtual_rename_policy"), + "whole-entity projections must preserve their canonical title", + )); + } +} + +fn validate_operations( + connector: &ConnectorManifest, + path: &str, + violations: &mut Vec, +) { + validate_unique_values( + &connector.push_operations, + 12, + &format!("{path}.push_operations"), + violations, + ); + validate_unique_values( + &connector.membership_operations, + 4, + &format!("{path}.membership_operations"), + violations, + ); + let operations = connector + .push_operations + .iter() + .copied() + .collect::>(); + let block_operations = [ + ManifestPushOperation::UpdateBlock, + ManifestPushOperation::ReplaceBlock, + ManifestPushOperation::AppendBlock, + ManifestPushOperation::MoveBlock, + ManifestPushOperation::ArchiveBlock, + ]; + let has_block_operation = block_operations + .iter() + .any(|operation| operations.contains(operation)); + if connector.capabilities.supports_block_updates != has_block_operation { + violations.push(ManifestViolation::new( + format!("{path}.capabilities.supports_block_updates"), + "must agree with declared block push operations", + )); + } + if connector.capabilities.supports_entity_body_updates + != operations.contains(&ManifestPushOperation::UpdateEntityBody) + { + violations.push(ManifestViolation::new( + format!("{path}.capabilities.supports_entity_body_updates"), + "must agree with update_entity_body", + )); + } + if operations.contains(&ManifestPushOperation::CreateDatabase) + && !connector.capabilities.supports_databases + { + violations.push(ManifestViolation::new( + format!("{path}.push_operations"), + "create_database requires supports_databases", + )); + } + if operations.contains(&ManifestPushOperation::UpdateMedia) + && !connector.capabilities.supports_media_download + { + violations.push(ManifestViolation::new( + format!("{path}.push_operations"), + "update_media requires media support", + )); + } + if connector.capabilities.supports_undo && operations.is_empty() { + violations.push(ManifestViolation::new( + format!("{path}.capabilities.supports_undo"), + "undo support requires at least one push operation", + )); + } + if connector.mount.read_only { + if !operations.is_empty() { + violations.push(ManifestViolation::new( + format!("{path}.push_operations"), + "read-only connectors cannot describe push operations", + )); + } + if connector + .profiles + .iter() + .flat_map(|profile| profile.actions.iter()) + .copied() + .any(ConnectorAction::mutates_remote) + { + violations.push(ManifestViolation::new( + format!("{path}.profiles"), + "read-only connectors cannot describe mutating actions", + )); + } + } + if connector + .membership_operations + .contains(&MembershipOperation::JoinPublicChannels) + && !connector + .profiles + .iter() + .any(|profile| profile.scopes.iter().any(|scope| scope == "channels:join")) + { + violations.push(ManifestViolation::new( + format!("{path}.membership_operations"), + "join_public_channels requires a profile with channels:join scope", + )); + } +} + +fn validate_identifier(value: &str, path: String, violations: &mut Vec) { + if !is_safe_relative_identifier(value) { + violations.push(ManifestViolation::new( + path, + "must be a safe kebab-case identifier", + )); + } +} + +fn validate_display_name(value: &str, path: String, violations: &mut Vec) { + if value.is_empty() || value.len() > MAX_DISPLAY_NAME_LEN || value.chars().any(char::is_control) + { + violations.push(ManifestViolation::new( + path, + format!("must be 1 to {MAX_DISPLAY_NAME_LEN} non-control characters"), + )); + } +} + +fn validate_unique_strings( + values: &[String], + max_items: usize, + max_len: usize, + path: &str, + violations: &mut Vec, +) { + if values.len() > max_items { + violations.push(ManifestViolation::new( + path, + format!("must contain at most {max_items} values"), + )); + } + let mut unique = BTreeSet::new(); + for (index, value) in values.iter().enumerate() { + if value.is_empty() || value.len() > max_len || value.chars().any(char::is_control) { + violations.push(ManifestViolation::new( + format!("{path}[{index}]"), + format!("must be 1 to {max_len} non-control characters"), + )); + } + if !unique.insert(value) { + violations.push(ManifestViolation::new( + format!("{path}[{index}]"), + "must be unique", + )); + } + } +} + +fn validate_unique_values( + values: &[T], + max_items: usize, + path: &str, + violations: &mut Vec, +) { + if values.len() > max_items { + violations.push(ManifestViolation::new( + path, + format!("must contain at most {max_items} values"), + )); + } + let mut unique = BTreeSet::new(); + for (index, value) in values.iter().enumerate() { + if !unique.insert(value) { + violations.push(ManifestViolation::new( + format!("{path}[{index}]"), + "must be unique", + )); + } + } +} + +fn require_unique<'a>( + values: &mut BTreeSet<&'a str>, + value: &'a str, + path: String, + label: &str, + violations: &mut Vec, +) { + if !values.insert(value) { + violations.push(ManifestViolation::new( + path, + format!("duplicate {label} `{value}`"), + )); + } +} + +fn reject_sensitive_keys(value: &Value, path: &str, violations: &mut Vec) { + match value { + Value::Object(object) => { + for (key, nested) in object { + let nested_path = format!("{path}.{key}"); + if is_sensitive_setting_key(key) { + violations.push(ManifestViolation::new( + &nested_path, + "connector manifests cannot contain credential-bearing settings", + )); + } + reject_sensitive_keys(nested, &nested_path, violations); + } + } + Value::Array(values) => { + for (index, nested) in values.iter().enumerate() { + reject_sensitive_keys(nested, &format!("{path}[{index}]"), violations); + } + } + _ => {} + } +} + +fn is_sensitive_setting_key(key: &str) -> bool { + let chars = key.chars().collect::>(); + let mut normalized = String::with_capacity(key.len()); + for (index, character) in chars.iter().copied().enumerate() { + if !character.is_ascii_alphanumeric() { + if !normalized.is_empty() && !normalized.ends_with('_') { + normalized.push('_'); + } + continue; + } + let previous = index.checked_sub(1).and_then(|index| chars.get(index)); + let next = chars.get(index + 1); + let camel_boundary = character.is_ascii_uppercase() + && previous.is_some_and(|previous| { + previous.is_ascii_lowercase() + || previous.is_ascii_digit() + || (previous.is_ascii_uppercase() && next.is_some_and(char::is_ascii_lowercase)) + }); + if camel_boundary && !normalized.is_empty() && !normalized.ends_with('_') { + normalized.push('_'); + } + normalized.push(character.to_ascii_lowercase()); + } + + let segments = normalized + .split('_') + .filter(|segment| !segment.is_empty()) + .collect::>(); + if segments.iter().any(|segment| { + matches!( + *segment, + "token" + | "secret" + | "password" + | "credential" + | "credentials" + | "authorization" + | "bearer" + ) + }) { + return true; + } + if segments.windows(2).any(|pair| { + matches!( + pair, + ["api", "key"] + | ["private", "key"] + | ["access", "token"] + | ["client", "secret"] + | ["bearer", "token"] + ) + }) { + return true; + } + + let [compact] = segments.as_slice() else { + return false; + }; + const SENSITIVE_COMPOUNDS: &[&str] = &[ + "apikey", + "privatekey", + "accesstoken", + "clientsecret", + "bearertoken", + ]; + const METADATA_SUFFIXES: &[&str] = &[ + "value", + "id", + "ref", + "reference", + "handle", + "path", + "file", + "name", + ]; + SENSITIVE_COMPOUNDS.iter().any(|compound| { + compact + .strip_prefix(compound) + .is_some_and(|suffix| suffix.is_empty() || METADATA_SUFFIXES.contains(&suffix)) + }) +} diff --git a/crates/locality-connector/tests/conformance_testkit.rs b/crates/locality-connector/tests/conformance_testkit.rs new file mode 100644 index 00000000..c5d9c835 --- /dev/null +++ b/crates/locality-connector/tests/conformance_testkit.rs @@ -0,0 +1,59 @@ +use std::fmt; +use std::path::Path; + +use locality_connector::conformance::{ + DirectFixtureAuth, FixtureLayout, check_debug_redaction, check_direct_fixture_layout, + check_fixture_layout, is_safe_relative_path, +}; + +struct RedactedConfig { + secret: String, +} + +#[test] +fn direct_fixture_versions_must_be_canonical_positive_integers() { + for invalid in ["direct", "direct-v0", "direct-v01", "v1", "../direct-v1"] { + let error = check_direct_fixture_layout( + Path::new(env!("CARGO_MANIFEST_DIR")), + invalid, + DirectFixtureAuth::Oauth, + ) + .expect_err("invalid version directory rejected"); + assert!(error.to_string().contains("direct-v")); + } +} + +impl fmt::Debug for RedactedConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RedactedConfig") + .field("secret", &"") + .finish() + } +} + +#[test] +fn redaction_check_rejects_secret_bearing_debug_output() { + let safe = RedactedConfig { + secret: "connector-secret-sentinel".to_string(), + }; + check_debug_redaction(&safe, &[&safe.secret]).expect("redacted debug"); + assert!(check_debug_redaction(&safe.secret, &[&safe.secret]).is_err()); +} + +#[test] +fn fixture_paths_are_portable_and_traversal_free() { + assert!(is_safe_relative_path(Path::new("native/page.json"))); + assert!(!is_safe_relative_path(Path::new("../page.json"))); + assert!(!is_safe_relative_path(Path::new("/tmp/page.json"))); + + let missing = check_fixture_layout( + Path::new(env!("CARGO_MANIFEST_DIR")), + &FixtureLayout { + version_directory: "direct-v1", + required_files: &["native-page.json"], + }, + ) + .expect_err("missing layout must fail"); + assert!(missing.to_string().contains("fixture directory")); +} diff --git a/crates/locality-connector/tests/manifest_contract.rs b/crates/locality-connector/tests/manifest_contract.rs new file mode 100644 index 00000000..1df53024 --- /dev/null +++ b/crates/locality-connector/tests/manifest_contract.rs @@ -0,0 +1,235 @@ +use locality_connector::manifest::{ + CONNECTOR_REGISTRY_JSON, CONNECTOR_REGISTRY_SCHEMA_JSON, ConnectorRegistry, ManifestError, + MembershipOperation, bundled_connector_registry, +}; +use serde_json::{Value, json}; + +fn registry_value() -> Value { + serde_json::from_str(CONNECTOR_REGISTRY_JSON).expect("registry JSON") +} + +fn validation_messages(error: ManifestError) -> String { + match error { + ManifestError::Json(message) => message, + ManifestError::Validation(violations) => violations + .into_iter() + .map(|violation| format!("{}: {}", violation.path, violation.message)) + .collect::>() + .join("\n"), + } +} + +#[test] +fn bundled_registry_is_strict_valid_v1() { + let registry = bundled_connector_registry().expect("bundled registry"); + assert_eq!(registry.schema_version, 1); + assert_eq!( + registry + .connectors + .iter() + .map(|connector| connector.id.as_str()) + .collect::>(), + [ + "notion", + "google-docs", + "google-calendar", + "gmail", + "granola", + "linear", + "slack", + ] + ); +} + +#[test] +fn registry_matches_the_published_json_schema() { + let schema = serde_json::from_str::(CONNECTOR_REGISTRY_SCHEMA_JSON) + .expect("registry schema JSON"); + let instance = registry_value(); + let validator = jsonschema::validator_for(&schema).expect("compile registry schema"); + let errors = validator + .iter_errors(&instance) + .map(|error| error.to_string()) + .collect::>(); + assert!(errors.is_empty(), "schema errors: {errors:#?}"); +} + +#[test] +fn every_default_mount_setting_matches_its_connector_schema() { + for connector in &bundled_connector_registry().expect("registry").connectors { + let validator = jsonschema::validator_for(&connector.mount.settings_schema) + .unwrap_or_else(|error| panic!("{} settings schema: {error}", connector.id)); + let errors = validator + .iter_errors(&connector.mount.default_settings) + .map(|error| error.to_string()) + .collect::>(); + assert!( + errors.is_empty(), + "{} default settings do not match schema: {errors:#?}", + connector.id + ); + } +} + +#[test] +fn slack_membership_mutation_is_separate_from_content_push_operations() { + let registry = bundled_connector_registry().expect("registry"); + let slack = registry.connector("slack").expect("Slack manifest"); + + assert_eq!( + slack.membership_operations, + [MembershipOperation::JoinPublicChannels] + ); + assert!(slack.mount.read_only); + assert!(slack.push_operations.is_empty()); + assert!(!slack.capabilities.supports_block_updates); + assert!(!slack.capabilities.supports_entity_body_updates); +} + +#[test] +fn strict_parser_rejects_unknown_fields_and_enums() { + let mut unknown_field = registry_value(); + unknown_field["connectors"][0]["executable"] = json!("/tmp/plugin"); + assert!( + ConnectorRegistry::parse(&unknown_field.to_string()) + .expect_err("unknown field") + .to_string() + .contains("unknown field") + ); + + let mut unknown_enum = registry_value(); + unknown_enum["connectors"][0]["profiles"][0]["auth_kind"] = json!("shell"); + assert!( + ConnectorRegistry::parse(&unknown_enum.to_string()) + .expect_err("unknown enum") + .to_string() + .contains("unknown variant") + ); +} + +#[test] +fn validation_rejects_duplicate_defaults_and_missing_default_profile() { + let mut duplicate = registry_value(); + duplicate["connectors"][1]["default_connection_id"] = json!("notion-default"); + duplicate["connectors"][1]["mount"]["default_id"] = json!("notion-main"); + duplicate["connectors"][1]["default_profile_id"] = json!("missing-profile"); + + let messages = validation_messages( + ConnectorRegistry::parse(&duplicate.to_string()).expect_err("duplicates must fail"), + ); + assert!(messages.contains("duplicate default connection id")); + assert!(messages.contains("duplicate default mount id")); + assert!(messages.contains("must name exactly one profile")); +} + +#[test] +fn validation_rejects_unsafe_assets_credentials_and_inconsistent_capabilities() { + let mut invalid = registry_value(); + invalid["connectors"][0]["ui"]["icon"] = json!("../notion.svg"); + invalid["connectors"][0]["ui"]["docs_slug"] = json!("https://example.test"); + invalid["connectors"][0]["mount"]["default_settings"] = + json!({"access_token": "must-never-live-here"}); + invalid["connectors"][0]["capabilities"]["supports_block_updates"] = json!(false); + + let messages = validation_messages( + ConnectorRegistry::parse(&invalid.to_string()).expect_err("invalid contract must fail"), + ); + assert!(messages.contains("safe relative kebab-case .svg filename")); + assert!(messages.contains("safe kebab-case identifier")); + assert!(messages.contains("cannot contain credential-bearing settings")); + assert!(messages.contains("must agree with declared block push operations")); +} + +#[test] +fn public_channel_membership_mutation_requires_its_oauth_scope() { + let mut invalid = registry_value(); + let scopes = invalid["connectors"][6]["profiles"][0]["scopes"] + .as_array_mut() + .expect("Slack scopes"); + scopes.retain(|scope| scope != "channels:join"); + + let messages = validation_messages( + ConnectorRegistry::parse(&invalid.to_string()).expect_err("missing scope must fail"), + ); + assert!(messages.contains("join_public_channels requires a profile with channels:join scope")); +} + +#[test] +fn debug_omits_settings_and_scope_values() { + let registry = bundled_connector_registry().expect("registry"); + let gmail = registry.connector("gmail").expect("gmail manifest"); + let debug = format!("{gmail:?}"); + + assert!(debug.contains("")); + assert!(debug.contains("<5 descriptive scopes>")); + assert!(!debug.contains("gmail.compose")); +} + +#[test] +fn sensitive_setting_keys_are_rejected_across_common_naming_styles() { + for key in [ + "accessToken", + "refresh-token", + "client_secret", + "private_key", + "privateKeyPem", + "apiKey", + "APIKey", + "bearer", + "bearerToken", + "authorizationHeader", + "credentials", + "apikey", + "PRIVATEKEY", + "accesstoken", + "clientsecret", + "bearertoken", + "accesstokenvalue", + "ACCESSTOKENVALUE", + "apikeyid", + "privatekeypath", + "clientsecrethandle", + "bearertokenreference", + "access_token_value", + "AccessTokenValue", + ] { + let mut invalid = registry_value(); + invalid["connectors"][0]["mount"]["default_settings"] = json!({key: "sentinel"}); + let error = ConnectorRegistry::parse(&invalid.to_string()) + .expect_err(&format!("sensitive key `{key}` was accepted")); + let messages = validation_messages(error); + assert!( + messages.contains("cannot contain credential-bearing settings"), + "sensitive key `{key}` was not classified: {messages}" + ); + } + + for key in [ + "monkey", + "hockey", + "keynote", + "keyboard_layout", + "tokenizer", + "secretary", + "secretariat", + "bearberry", + "accessibility", + "private_mode", + "api_latency", + "api_keyboard_layout", + "private_keynote", + "privatekeynote", + "PrivateKeynote", + "apikeyboard", + "accesstokenizer", + "clientsecretary", + "serviceapikey", + "sessiontoken", + "databasepassword", + ] { + let mut valid = registry_value(); + valid["connectors"][0]["mount"]["default_settings"] = json!({key: true}); + ConnectorRegistry::parse(&valid.to_string()) + .unwrap_or_else(|error| panic!("safe key `{key}` was rejected: {error}")); + } +} diff --git a/crates/locality-gmail/src/settings.rs b/crates/locality-gmail/src/settings.rs index 341ab2d5..026a3e17 100644 --- a/crates/locality-gmail/src/settings.rs +++ b/crates/locality-gmail/src/settings.rs @@ -4,7 +4,7 @@ use locality_core::{LocalityError, LocalityResult}; use serde::{Deserialize, Deserializer, Serialize, de}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct GmailMountSettings { pub gmail: GmailSettings, } @@ -54,7 +54,7 @@ impl GmailMountSettings { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct GmailSettings { pub date_window: Option, pub view: GmailProjectionView, @@ -115,6 +115,7 @@ impl<'de> Deserialize<'de> for GmailDateWindow { D: Deserializer<'de>, { #[derive(Deserialize)] + #[serde(deny_unknown_fields)] struct RawGmailDateWindow { after: String, before: String, @@ -368,6 +369,20 @@ mod tests { assert!(GmailProjectionView::parse("conversation").is_err()); } + #[test] + fn unknown_fields_are_rejected_at_every_settings_level() { + for value in [ + r#"{"unexpected":true}"#, + r#"{"gmail":{"unexpected":true}}"#, + r#"{"gmail":{"date_window":{"after":"2026-07-01","before":"2026-07-15","unexpected":true}}}"#, + ] { + assert!( + GmailMountSettings::from_json(value).is_err(), + "unknown field accepted in {value}" + ); + } + } + fn assert_settings_json_error(error: LocalityError, expected_message: &str) { let LocalityError::Validation(issues) = error else { panic!("expected validation error"); diff --git a/crates/locality-google-calendar/src/settings.rs b/crates/locality-google-calendar/src/settings.rs index 674fc9ab..3d9bc781 100644 --- a/crates/locality-google-calendar/src/settings.rs +++ b/crates/locality-google-calendar/src/settings.rs @@ -8,7 +8,7 @@ const DEFAULT_PAST_DAYS: i64 = 30; const DEFAULT_FUTURE_DAYS: i64 = 180; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct GoogleCalendarMountSettings { pub google_calendar: GoogleCalendarSettings, } @@ -60,7 +60,7 @@ impl GoogleCalendarMountSettings { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct GoogleCalendarSettings { pub date_window: Option, } @@ -83,6 +83,7 @@ impl<'de> Deserialize<'de> for GoogleCalendarDateWindow { D: Deserializer<'de>, { #[derive(Deserialize)] + #[serde(deny_unknown_fields)] struct RawGoogleCalendarDateWindow { after: String, before: String, @@ -301,4 +302,18 @@ mod tests { assert!(GoogleCalendarMountSettings::with_date_window("2026-07-31", "2026-07-31").is_err()); assert!(GoogleCalendarMountSettings::with_date_window("2026-08-01", "2026-07-31").is_err()); } + + #[test] + fn unknown_fields_are_rejected_at_every_settings_level() { + for value in [ + r#"{"unexpected":true}"#, + r#"{"google_calendar":{"unexpected":true}}"#, + r#"{"google_calendar":{"date_window":{"after":"2026-07-01","before":"2026-07-31","unexpected":true}}}"#, + ] { + assert!( + GoogleCalendarMountSettings::from_json(value).is_err(), + "unknown field accepted in {value}" + ); + } + } } diff --git a/crates/locality-google-docs/src/connector.rs b/crates/locality-google-docs/src/connector.rs index 946f6618..1a119c64 100644 --- a/crates/locality-google-docs/src/connector.rs +++ b/crates/locality-google-docs/src/connector.rs @@ -31,12 +31,22 @@ use crate::render::{ GoogleDocsNativeBundle, combined_remote_version, document_frontmatter, render_google_document, }; -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct GoogleDocsConfig { pub access_token: String, pub workspace_folder_id: Option, } +impl std::fmt::Debug for GoogleDocsConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("GoogleDocsConfig") + .field("access_token", &"") + .field("workspace_folder_id", &self.workspace_folder_id) + .finish() + } +} + impl GoogleDocsConfig { pub fn new(access_token: impl Into) -> Self { Self { diff --git a/crates/locality-google-docs/src/oauth.rs b/crates/locality-google-docs/src/oauth.rs index 5dc608ce..fa4bd624 100644 --- a/crates/locality-google-docs/src/oauth.rs +++ b/crates/locality-google-docs/src/oauth.rs @@ -1,3 +1,4 @@ +use std::fmt; use std::sync::OnceLock; use locality_connector::ConnectorCapabilities; @@ -28,7 +29,7 @@ pub const GOOGLE_DOCS_OAUTH_SCOPES: &[&str] = &[ static REQWEST_CRYPTO_PROVIDER: OnceLock<()> = OnceLock::new(); -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct StoredGoogleDocsCredential { pub kind: String, pub connector: String, @@ -46,6 +47,31 @@ pub struct StoredGoogleDocsCredential { pub expires_at: Option, } +impl fmt::Debug for StoredGoogleDocsCredential { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("StoredGoogleDocsCredential") + .field("kind", &self.kind) + .field("connector", &self.connector) + .field("access_token", &"") + .field("token_type", &self.token_type) + .field("oauth_client_id", &self.oauth_client_id) + .field("oauth_broker_url", &self.oauth_broker_url) + .field("account_id", &self.account_id) + .field("account_label", &self.account_label) + .field("workspace_id", &self.workspace_id) + .field("workspace_name", &self.workspace_name) + .field("scopes", &self.scopes) + .field( + "refresh_token_handle", + &self.refresh_token_handle.as_ref().map(|_| ""), + ) + .field("acquired_at", &self.acquired_at) + .field("expires_at", &self.expires_at) + .finish() + } +} + impl StoredGoogleDocsCredential { pub fn from_broker_token( token: OAuthBrokerToken, diff --git a/crates/locality-slack/fixtures/direct-v1/oauth-scopes.json b/crates/locality-slack/fixtures/direct-v1/auth-scopes.json similarity index 100% rename from crates/locality-slack/fixtures/direct-v1/oauth-scopes.json rename to crates/locality-slack/fixtures/direct-v1/auth-scopes.json diff --git a/crates/locality-slack/fixtures/direct-v1/settings-custom.json b/crates/locality-slack/fixtures/direct-v1/settings-custom.json index ff6bd61e..5a998ecb 100644 --- a/crates/locality-slack/fixtures/direct-v1/settings-custom.json +++ b/crates/locality-slack/fixtures/direct-v1/settings-custom.json @@ -1 +1 @@ -{"slack":{"history_limit":9,"types":["im","mpim"]}} +{"slack":{"history_limit":9,"types":["im","mpim"],"auto_join_public_channels":false}} diff --git a/crates/locality-slack/src/connector.rs b/crates/locality-slack/src/connector.rs index cb693b3a..45983b9d 100644 --- a/crates/locality-slack/src/connector.rs +++ b/crates/locality-slack/src/connector.rs @@ -1012,6 +1012,43 @@ mod tests { ); } + #[test] + fn disabled_auto_join_does_not_mutate_membership_or_project_unjoined_public_channels() { + let api = FakeSlackApi::default().with_conversations(vec![SlackConversation { + id: "C_unjoined".to_string(), + name: Some("unjoined".to_string()), + is_channel: true, + is_member: Some(false), + ..SlackConversation::default() + }]); + let settings = SlackMountSettings::from_json( + r#"{"slack":{"types":["public_channel"],"auto_join_public_channels":false}}"#, + ) + .expect("settings"); + let connector = SlackConnector::with_api( + SlackConfig::new("xoxb-token").with_settings(settings), + Arc::new(api.clone()), + ); + + let result = connector + .list_children(ListChildrenRequest { + mount_id: MountId::new("slack-main"), + container: ChildContainer::DirectoryChildren(RemoteId::new( + "slack-folder:channels", + )), + parent_path: PathBuf::from("channels"), + }) + .expect("list channels"); + + assert!( + api.joined_channels + .lock() + .expect("joined channels") + .is_empty() + ); + assert!(result.entries.is_empty()); + } + #[test] fn auto_join_public_channels_skips_unjoined_private_channels() { let api = FakeSlackApi::default().with_conversations(vec![SlackConversation { diff --git a/crates/locality-slack/src/settings.rs b/crates/locality-slack/src/settings.rs index c669e550..40fd5560 100644 --- a/crates/locality-slack/src/settings.rs +++ b/crates/locality-slack/src/settings.rs @@ -37,18 +37,20 @@ impl SlackConversationType { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct SlackMountSettings { #[serde(default)] pub slack: SlackSettings, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct SlackSettings { #[serde(default = "default_history_limit")] pub history_limit: u32, #[serde(default = "default_conversation_types")] pub types: BTreeSet, - #[serde(default, skip_serializing_if = "is_false")] + #[serde(default = "default_auto_join_public_channels")] pub auto_join_public_channels: bool, } @@ -99,16 +101,23 @@ impl SlackMountSettings { } fn normalize(&mut self) -> LocalityResult<()> { - self.slack.history_limit = self.slack.history_limit.clamp(1, MAX_SLACK_HISTORY_LIMIT); + if !(1..=MAX_SLACK_HISTORY_LIMIT).contains(&self.slack.history_limit) { + return Err(settings_validation(format!( + "Slack history_limit must be between 1 and {MAX_SLACK_HISTORY_LIMIT}" + ))); + } if self.slack.types.is_empty() { return Err(settings_validation( "Slack settings must include at least one Slack conversation type", )); } - self.slack.auto_join_public_channels = self + if !self .slack .types - .contains(&SlackConversationType::PublicChannel); + .contains(&SlackConversationType::PublicChannel) + { + self.slack.auto_join_public_channels = false; + } Ok(()) } } @@ -128,8 +137,8 @@ fn default_conversation_types() -> BTreeSet { .collect() } -fn is_false(value: &bool) -> bool { - !*value +fn default_auto_join_public_channels() -> bool { + true } fn settings_validation(message: impl Into) -> LocalityError { @@ -169,9 +178,9 @@ mod tests { } #[test] - fn parses_json_settings_with_clamped_history_limit() { + fn omitted_auto_join_uses_the_documented_default() { let settings = SlackMountSettings::from_json( - r#"{"slack":{"history_limit":50,"types":["public_channel","im"]}}"#, + r#"{"slack":{"history_limit":15,"types":["public_channel","im"]}}"#, ) .expect("parse settings"); @@ -183,6 +192,44 @@ mod tests { ); } + #[test] + fn explicit_false_disables_public_channel_auto_join() { + let settings = SlackMountSettings::from_json( + r#"{"slack":{"types":["public_channel"],"auto_join_public_channels":false}}"#, + ) + .expect("parse settings"); + + assert!(!settings.slack.auto_join_public_channels); + let encoded = settings.to_json().expect("settings json"); + assert_eq!( + encoded, + r#"{"slack":{"history_limit":15,"types":["public_channel"],"auto_join_public_channels":false}}"# + ); + let reparsed = SlackMountSettings::from_json(&encoded).expect("reparse settings"); + assert!(!reparsed.slack.auto_join_public_channels); + } + + #[test] + fn rejects_history_limits_outside_the_manifest_schema() { + for history_limit in [0, 16, 50] { + let error = SlackMountSettings::from_json(&format!( + r#"{{"slack":{{"history_limit":{history_limit}}}}}"# + )) + .expect_err("out-of-range history limit rejected"); + let LocalityError::Validation(issues) = error else { + panic!("expected settings validation error"); + }; + assert_eq!(issues.len(), 1); + assert!(issues[0].message.contains("between 1 and 15")); + } + } + + #[test] + fn rejects_unknown_settings_fields() { + assert!(SlackMountSettings::from_json(r#"{"unexpected":true}"#).is_err()); + assert!(SlackMountSettings::from_json(r#"{"slack":{"unexpected":true}}"#).is_err()); + } + #[test] fn derives_auto_join_from_public_channel_type() { let settings = SlackMountSettings::from_json( @@ -193,7 +240,7 @@ mod tests { assert!(!settings.slack.auto_join_public_channels); assert_eq!( settings.to_json().expect("settings json"), - r#"{"slack":{"history_limit":15,"types":["im"]}}"# + r#"{"slack":{"history_limit":15,"types":["im"],"auto_join_public_channels":false}}"# ); } diff --git a/crates/locality-slack/tests/direct_mode_compat.rs b/crates/locality-slack/tests/direct_mode_compat.rs index bd25d9f3..71f77b3d 100644 --- a/crates/locality-slack/tests/direct_mode_compat.rs +++ b/crates/locality-slack/tests/direct_mode_compat.rs @@ -17,7 +17,7 @@ use locality_slack::{ const DEFAULT_SETTINGS: &[u8] = include_bytes!("../fixtures/direct-v1/settings-default.json"); const CUSTOM_SETTINGS: &[u8] = include_bytes!("../fixtures/direct-v1/settings-custom.json"); const OAUTH_CAPABILITIES: &[u8] = include_bytes!("../fixtures/direct-v1/oauth-capabilities.json"); -const OAUTH_SCOPES: &[u8] = include_bytes!("../fixtures/direct-v1/oauth-scopes.json"); +const OAUTH_SCOPES: &[u8] = include_bytes!("../fixtures/direct-v1/auth-scopes.json"); const TREE_PATHS: &[u8] = include_bytes!("../fixtures/direct-v1/tree-paths.txt"); const NATIVE_USERS: &[u8] = include_bytes!("../fixtures/direct-v1/native-users.json"); const NATIVE_RECENT: &[u8] = include_bytes!("../fixtures/direct-v1/native-recent.json"); diff --git a/crates/localityd/Cargo.toml b/crates/localityd/Cargo.toml index 96672e0b..5e0dce9a 100644 --- a/crates/localityd/Cargo.toml +++ b/crates/localityd/Cargo.toml @@ -59,4 +59,5 @@ windows-sys = { version = "0.61", features = [ ] } [dev-dependencies] +jsonschema = { version = "0.33", default-features = false } rusqlite = { version = "0.39.0", features = ["bundled"] } diff --git a/crates/localityd/src/generation_mount.rs b/crates/localityd/src/generation_mount.rs index 757d19c1..91ff9bf7 100644 --- a/crates/localityd/src/generation_mount.rs +++ b/crates/localityd/src/generation_mount.rs @@ -37,6 +37,15 @@ pub(crate) struct SecureTarget { name: OsString, } +#[cfg(unix)] +impl Drop for SecureMount { + fn drop(&mut self) { + // Targets use dup'd root descriptors. Release coordinator ownership + // when the mount guard ends instead of waiting for every duplicate. + let _ = rustix::fs::flock(&self.root, rustix::fs::FlockOperation::Unlock); + } +} + impl SecureMount { pub(crate) fn open(root: &Path) -> io::Result { #[cfg(unix)] diff --git a/crates/localityd/src/source.rs b/crates/localityd/src/source.rs index 900a9ceb..ef8d528d 100644 --- a/crates/localityd/src/source.rs +++ b/crates/localityd/src/source.rs @@ -9,6 +9,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::time::Duration; +use locality_connector::manifest::{ConnectorManifest, ManifestError, bundled_connector_registry}; use locality_connector::{ ApplyPlanRequest, ApplyPlanResult, ApplyUndoRequest, ApplyUndoResult, BatchObserveRequest, BatchObserveResult, Connector, ConnectorCapabilities, ConnectorExecutionPolicy, ConnectorKind, @@ -96,7 +97,8 @@ type SourceValidationFn = fn(SourceValidationContext<'_>) -> LocalityResult, descriptor: fn() -> SourceDescriptor, resolve: SourceResolver, validate_changed_frontmatter: SourceValidationFn, @@ -105,21 +107,24 @@ struct SourceRegistration { const SOURCE_REGISTRY: &[SourceRegistration] = &[ SourceRegistration { - id: "notion", + manifest_id: "notion", + content_read_only_reason: None, descriptor: notion_source_descriptor, resolve: resolve_notion_source, validate_changed_frontmatter: crate::notion::validate_notion_changed_frontmatter, validate_create_frontmatter: crate::notion::validate_notion_create_frontmatter, }, SourceRegistration { - id: GOOGLE_DOCS_CONNECTOR_ID, + manifest_id: GOOGLE_DOCS_CONNECTOR_ID, + content_read_only_reason: None, descriptor: google_docs_source_descriptor, resolve: resolve_google_docs_source, validate_changed_frontmatter: crate::google_docs::validate_google_docs_frontmatter, validate_create_frontmatter: crate::google_docs::validate_google_docs_frontmatter, }, SourceRegistration { - id: GOOGLE_CALENDAR_CONNECTOR_ID, + manifest_id: GOOGLE_CALENDAR_CONNECTOR_ID, + content_read_only_reason: None, descriptor: google_calendar_source_descriptor, resolve: resolve_google_calendar_source, validate_changed_frontmatter: @@ -128,28 +133,32 @@ const SOURCE_REGISTRY: &[SourceRegistration] = &[ crate::google_calendar::validate_google_calendar_create_frontmatter, }, SourceRegistration { - id: GMAIL_CONNECTOR_ID, + manifest_id: GMAIL_CONNECTOR_ID, + content_read_only_reason: None, descriptor: gmail_source_descriptor, resolve: resolve_gmail_source, validate_changed_frontmatter: crate::gmail::validate_gmail_changed_frontmatter, validate_create_frontmatter: crate::gmail::validate_gmail_create_frontmatter, }, SourceRegistration { - id: GRANOLA_CONNECTOR_ID, + manifest_id: GRANOLA_CONNECTOR_ID, + content_read_only_reason: Some("Granola meetings are read-only"), descriptor: granola_source_descriptor, resolve: resolve_granola_source, validate_changed_frontmatter: crate::granola::validate_granola_frontmatter, validate_create_frontmatter: crate::granola::validate_granola_frontmatter, }, SourceRegistration { - id: LINEAR_CONNECTOR_ID, + manifest_id: LINEAR_CONNECTOR_ID, + content_read_only_reason: None, descriptor: linear_source_descriptor, resolve: resolve_linear_source, validate_changed_frontmatter: crate::linear::validate_linear_frontmatter, validate_create_frontmatter: crate::linear::validate_linear_create_frontmatter, }, SourceRegistration { - id: SLACK_CONNECTOR_ID, + manifest_id: SLACK_CONNECTOR_ID, + content_read_only_reason: Some("Slack conversations are read-only"), descriptor: slack_source_descriptor, resolve: resolve_slack_source, validate_changed_frontmatter: crate::slack::validate_slack_frontmatter, @@ -285,22 +294,15 @@ pub fn source_write_decision_for_path( reason: "mount is read-only", }; } + if let Some(decision) = registered_content_read_only_decision(&mount.connector) { + return decision; + } if mount.connector == "gmail" { return gmail_write_decision_for_path(relative_path); } if mount.connector == GOOGLE_CALENDAR_CONNECTOR_ID { return google_calendar_write_decision_for_path(relative_path); } - if mount.connector == GRANOLA_CONNECTOR_ID { - return SourceWriteDecision::ReadOnly { - reason: "Granola meetings are read-only", - }; - } - if mount.connector == SLACK_CONNECTOR_ID { - return SourceWriteDecision::ReadOnly { - reason: "Slack conversations are read-only", - }; - } if mount.connector == LINEAR_CONNECTOR_ID { return linear_write_decision_for_path(relative_path); } @@ -316,6 +318,9 @@ pub fn source_create_decision_for_parent_path( reason: "mount is read-only", }; } + if let Some(decision) = registered_content_read_only_decision(&mount.connector) { + return decision; + } if mount.connector == "gmail" { return if parent_path == Path::new("draft") { SourceWriteDecision::Writable @@ -334,16 +339,6 @@ pub fn source_create_decision_for_parent_path( } }; } - if mount.connector == GRANOLA_CONNECTOR_ID { - return SourceWriteDecision::ReadOnly { - reason: "Granola meetings are read-only", - }; - } - if mount.connector == SLACK_CONNECTOR_ID { - return SourceWriteDecision::ReadOnly { - reason: "Slack conversations are read-only", - }; - } if mount.connector == LINEAR_CONNECTOR_ID { return SourceWriteDecision::ReadOnly { reason: "Linear issue creates are not supported yet", @@ -361,6 +356,9 @@ pub fn source_move_decision_for_parent_path( reason: "mount is read-only", }; } + if let Some(decision) = registered_content_read_only_decision(&mount.connector) { + return decision; + } if mount.connector == "gmail" { return if parent_path == Path::new("draft") { SourceWriteDecision::Writable @@ -370,16 +368,6 @@ pub fn source_move_decision_for_parent_path( } }; } - if mount.connector == GRANOLA_CONNECTOR_ID { - return SourceWriteDecision::ReadOnly { - reason: "Granola meetings are read-only", - }; - } - if mount.connector == SLACK_CONNECTOR_ID { - return SourceWriteDecision::ReadOnly { - reason: "Slack conversations are read-only", - }; - } if mount.connector == LINEAR_CONNECTOR_ID { return linear_move_decision_for_parent_path(parent_path); } @@ -389,14 +377,55 @@ pub fn source_move_decision_for_parent_path( pub fn supported_source_connectors() -> Vec<&'static str> { SOURCE_REGISTRY .iter() - .map(|registration| registration.id) + .map(|registration| registration.manifest_id) + .collect() +} + +/// Descriptive manifest and code-owned descriptor paired by one runtime +/// registration. This does not expose resolver or validation authority. +pub struct RegisteredSourceContract { + pub manifest: &'static ConnectorManifest, + pub descriptor: SourceDescriptor, + pub content_read_only_reason: Option<&'static str>, +} + +pub fn registered_source_contracts() -> Result, ManifestError> { + let manifest_registry = bundled_connector_registry()?; + SOURCE_REGISTRY + .iter() + .map(|registration| { + let manifest = manifest_registry + .connector(registration.manifest_id) + .ok_or_else(|| { + ManifestError::Validation(vec![ + locality_connector::manifest::ManifestViolation { + path: "$.connectors".to_string(), + message: format!( + "runtime source `{}` has no connector manifest", + registration.manifest_id + ), + }, + ]) + })?; + Ok(RegisteredSourceContract { + manifest, + descriptor: (registration.descriptor)(), + content_read_only_reason: registration.content_read_only_reason, + }) + }) .collect() } fn source_registration(connector: &str) -> Option<&'static SourceRegistration> { SOURCE_REGISTRY .iter() - .find(|registration| registration.id == connector) + .find(|registration| registration.manifest_id == connector) +} + +fn registered_content_read_only_decision(connector: &str) -> Option { + source_registration(connector) + .and_then(|registration| registration.content_read_only_reason) + .map(|reason| SourceWriteDecision::ReadOnly { reason }) } fn notion_source_descriptor() -> SourceDescriptor { diff --git a/crates/localityd/tests/connector_manifest.rs b/crates/localityd/tests/connector_manifest.rs new file mode 100644 index 00000000..65e2b966 --- /dev/null +++ b/crates/localityd/tests/connector_manifest.rs @@ -0,0 +1,474 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; +use std::time::Duration; + +use locality_connector::Connector; +use locality_connector::conformance::{ + DirectFixtureAuth, check_capability_operation_agreement, check_debug_redaction, + check_direct_fixture_layout, check_manifest_asset_paths, check_manifest_identity, + check_read_only_rejection, +}; +use locality_connector::manifest::{ + AuthKind, BodyDiffMode as ManifestBodyDiffMode, ManifestEntityKind, + VirtualRenamePolicy as ManifestRenamePolicy, bundled_connector_registry, +}; +use locality_core::model::{EntityKind, MountId, RemoteId}; +use locality_core::push::BodyDiffMode; +use locality_gmail::{GmailConfig, GmailConnector, GmailMountSettings}; +use locality_google_calendar::{ + GoogleCalendarConfig, GoogleCalendarConnector, GoogleCalendarMountSettings, +}; +use locality_google_docs::{GoogleDocsConfig, GoogleDocsConnector, StoredGoogleDocsCredential}; +use locality_granola::{GranolaConfig, GranolaConnector}; +use locality_linear::{LinearConfig, LinearConnector}; +use locality_notion::{NotionConfig, NotionConnector}; +use locality_slack::{SlackConfig, SlackConnector, SlackMountSettings}; +use locality_store::MountConfig; +use localityd::source::{ + VirtualRenamePolicy, registered_source_contracts, source_create_decision_for_parent_path, + source_move_decision_for_parent_path, source_write_decision_for_path, + supported_source_connectors, +}; + +fn runtime_connectors() -> Vec<(&'static str, Box)> { + vec![ + ( + "notion", + Box::new(NotionConnector::new( + NotionConfig::default().with_token("notion-secret-sentinel"), + )), + ), + ( + "google-docs", + Box::new(GoogleDocsConnector::new( + GoogleDocsConfig::new("google-docs-secret-sentinel") + .with_workspace_folder_id(RemoteId::new("folder")), + )), + ), + ( + "google-calendar", + Box::new(GoogleCalendarConnector::new(GoogleCalendarConfig::new( + "google-calendar-secret-sentinel", + ))), + ), + ( + "gmail", + Box::new(GmailConnector::new(GmailConfig::new( + "gmail-secret-sentinel", + ))), + ), + ( + "granola", + Box::new(GranolaConnector::new(GranolaConfig::new( + "granola-secret-sentinel", + ))), + ), + ( + "linear", + Box::new(LinearConnector::new(LinearConfig::new( + "linear-secret-sentinel", + ))), + ), + ( + "slack", + Box::new(SlackConnector::new(SlackConfig::new( + "slack-secret-sentinel", + ))), + ), + ] +} + +#[test] +fn runtime_registry_has_one_manifest_per_connector_in_canonical_order() { + let registry = bundled_connector_registry().expect("manifest registry"); + let contracts = registered_source_contracts().expect("registered source contracts"); + let manifest_ids = registry + .connectors + .iter() + .map(|manifest| manifest.id.as_str()) + .collect::>(); + let registration_ids = contracts + .iter() + .map(|contract| contract.manifest.id.as_str()) + .collect::>(); + + assert_eq!(registration_ids, manifest_ids); + assert_eq!(supported_source_connectors(), manifest_ids); + assert_eq!( + registration_ids + .iter() + .copied() + .collect::>() + .len(), + registration_ids.len(), + "duplicate runtime source registration" + ); +} + +#[test] +fn connector_crates_cannot_omit_manifest_runtime_docs_or_icon_registration() { + let repository_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let infrastructure_crates = [ + "locality-connector", + "locality-core", + "locality-engine", + "locality-platform", + "locality-protocol", + "locality-store", + ] + .into_iter() + .collect::>(); + let mut connector_crates = fs::read_dir(repository_root.join("crates")) + .expect("read crates directory") + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| { + name.starts_with("locality-") && !infrastructure_crates.contains(name.as_str()) + }) + .filter(|name| { + fs::read_to_string(repository_root.join("crates").join(name).join("Cargo.toml")) + .is_ok_and(|cargo| cargo.contains("locality-connector.workspace = true")) + }) + .map(|name| format!("crates/{name}")) + .collect::>(); + connector_crates.sort(); + + let mut manifest_crates = bundled_connector_registry() + .expect("registry") + .connectors + .iter() + .map(|manifest| manifest.crate_path.clone()) + .collect::>(); + manifest_crates.sort(); + assert_eq!(connector_crates, manifest_crates); + + let runtime_ids = supported_source_connectors() + .into_iter() + .collect::>(); + for manifest in &bundled_connector_registry().expect("registry").connectors { + assert!(runtime_ids.contains(manifest.id.as_str())); + assert!( + repository_root + .join("docs-site/connectors") + .join(format!("{}.mdx", manifest.ui.docs_slug)) + .is_file() + ); + assert!( + repository_root + .join("apps/desktop/src/assets/connectors") + .join(&manifest.ui.icon) + .is_file() + ); + } +} + +#[test] +fn connector_kinds_capabilities_and_push_operations_match_manifests() { + let registry = bundled_connector_registry().expect("manifest registry"); + for (id, connector) in runtime_connectors() { + let manifest = registry.connector(id).expect("connector manifest"); + check_manifest_identity(manifest, connector.as_ref()).expect("manifest identity"); + check_capability_operation_agreement(manifest, connector.as_ref()) + .expect("capability and operation agreement"); + assert_eq!( + manifest.has_oauth_profile(), + manifest.capabilities.supports_oauth + ); + } +} + +#[test] +fn source_descriptors_match_manifest_defaults_and_projection_policy() { + for contract in registered_source_contracts().expect("registered source contracts") { + let manifest = contract.manifest; + let descriptor = contract.descriptor; + assert_eq!(descriptor.id(), manifest.id); + assert_eq!(descriptor.display_name(), manifest.display_name); + assert_eq!(descriptor.default_mount_id(), manifest.mount.default_id); + assert_eq!(descriptor.supports_oauth(), manifest.has_oauth_profile()); + assert_eq!( + descriptor.source_root_create_parent_kind(), + manifest + .projection + .source_root_create_parent_kind + .map(runtime_entity_kind) + ); + assert_eq!( + descriptor.create_entity_parent_kinds(), + manifest + .projection + .create_entity_parent_kinds + .iter() + .copied() + .map(runtime_entity_kind) + .collect::>() + ); + assert_eq!( + descriptor.move_entity_parent_kinds(), + manifest + .projection + .move_entity_parent_kinds + .iter() + .copied() + .map(runtime_entity_kind) + .collect::>() + ); + assert_eq!( + descriptor.periodic_discovery_interval(), + manifest + .projection + .periodic_discovery_seconds + .map(Duration::from_secs) + ); + assert_eq!( + descriptor.max_background_discovery_workers(), + manifest.projection.max_background_discovery_workers + ); + assert_eq!( + descriptor.body_diff_mode(), + match manifest.projection.body_diff_mode { + ManifestBodyDiffMode::Block => BodyDiffMode::Block, + ManifestBodyDiffMode::WholeEntity => BodyDiffMode::WholeEntity, + } + ); + assert_eq!( + descriptor.virtual_rename_policy(), + match manifest.projection.virtual_rename_policy { + ManifestRenamePolicy::FilenameDerived => VirtualRenamePolicy::FilenameDerived, + ManifestRenamePolicy::PreserveCanonical => VirtualRenamePolicy::PreserveCanonical, + } + ); + } +} + +#[test] +fn representative_runtime_settings_round_trip_through_manifest_schemas() { + assert_settings_schema_runtime_round_trip( + "google-calendar", + &[ + r#"{}"#, + r#"{"google_calendar":{"date_window":{"after":"2026-07-01","before":"2026-07-31"}}}"#, + ], + |json| GoogleCalendarMountSettings::from_json(json)?.to_json(), + ); + assert_settings_schema_runtime_round_trip( + "gmail", + &[ + r#"{}"#, + r#"{"gmail":{"date_window":{"after":"2026-07-01","before":"2026-07-31"},"view":"threads"}}"#, + ], + |json| GmailMountSettings::from_json(json)?.to_json(), + ); + assert_settings_schema_runtime_round_trip( + "slack", + &[ + r#"{"slack":{"history_limit":15,"types":["public_channel","private_channel","im","mpim"],"auto_join_public_channels":true}}"#, + r#"{"slack":{"history_limit":7,"types":["public_channel","im"],"auto_join_public_channels":false}}"#, + ], + |json| SlackMountSettings::from_json(json)?.to_json(), + ); +} + +#[test] +fn read_only_manifests_reject_host_write_create_and_move_paths() { + for contract in registered_source_contracts().expect("registered source contracts") { + let manifest = contract.manifest; + assert_eq!( + contract.content_read_only_reason.is_some(), + manifest.mount.read_only, + "code-owned host policy drifted for {}", + manifest.id + ); + if !manifest.mount.read_only { + continue; + } + + assert!(manifest.push_operations.is_empty()); + let mut mount = MountConfig::new( + MountId::new(format!("{}-conformance", manifest.id)), + &manifest.id, + "/tmp/source", + ); + mount.read_only = false; + check_read_only_rejection( + &manifest, + [ + source_write_decision_for_path(&mount, Path::new("item/page.md")).is_writable(), + source_create_decision_for_parent_path(&mount, Path::new("item")).is_writable(), + source_move_decision_for_parent_path(&mount, Path::new("item")).is_writable(), + ], + ) + .expect("read-only host rejection"); + } +} + +#[test] +fn manifest_docs_icons_and_existing_direct_fixture_layout_exist() { + let repository_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + for manifest in &bundled_connector_registry().expect("registry").connectors { + check_manifest_asset_paths(manifest).expect("safe manifest assets"); + assert!( + repository_root + .join("apps/desktop/src/assets/connectors") + .join(&manifest.ui.icon) + .is_file(), + "missing icon for {}", + manifest.id + ); + assert!( + repository_root + .join("docs-site/connectors") + .join(format!("{}.mdx", manifest.ui.docs_slug)) + .is_file(), + "missing docs for {}", + manifest.id + ); + } + + let grandfathered_without_direct_v1 = [ + "notion", + "google-docs", + "google-calendar", + "gmail", + "granola", + "linear", + ]; + let mut missing_direct_v1 = Vec::new(); + for manifest in &bundled_connector_registry().expect("registry").connectors { + let crate_root = repository_root.join(&manifest.crate_path); + if !crate_root.join("fixtures/direct-v1").is_dir() { + missing_direct_v1.push(manifest.id.as_str()); + continue; + } + let default_profile = manifest + .profiles + .iter() + .find(|profile| profile.id == manifest.default_profile_id) + .expect("default profile"); + let auth = match default_profile.auth_kind { + AuthKind::Oauth => DirectFixtureAuth::Oauth, + AuthKind::Token => DirectFixtureAuth::Token, + AuthKind::ApiKey => DirectFixtureAuth::ApiKey, + }; + check_direct_fixture_layout(&crate_root, "direct-v1", auth) + .unwrap_or_else(|error| panic!("{} direct-v1: {error}", manifest.id)); + } + assert_eq!(missing_direct_v1, grandfathered_without_direct_v1); +} + +#[test] +fn connector_configuration_and_oauth_debug_output_redact_secrets() { + let google_docs_config = GoogleDocsConfig::new("google-docs-secret-sentinel"); + check_debug_redaction(&google_docs_config, &["google-docs-secret-sentinel"]) + .expect("Google Docs config redaction"); + + let credential = StoredGoogleDocsCredential { + kind: "oauth".to_string(), + connector: "google-docs".to_string(), + access_token: "google-docs-access-secret".to_string(), + token_type: Some("Bearer".to_string()), + oauth_client_id: Some("public-client-id".to_string()), + oauth_broker_url: Some("https://auth.locality.dev".to_string()), + account_id: None, + account_label: None, + workspace_id: None, + workspace_name: None, + scopes: Vec::new(), + refresh_token_handle: Some("google-docs-refresh-secret".to_string()), + acquired_at: 0, + expires_at: None, + }; + check_debug_redaction( + &credential, + &["google-docs-access-secret", "google-docs-refresh-secret"], + ) + .expect("Google Docs OAuth credential redaction"); + + check_debug_redaction( + &NotionConfig::default().with_token("notion-secret-sentinel"), + &["notion-secret-sentinel"], + ) + .expect("Notion config redaction"); + check_debug_redaction( + &GoogleCalendarConfig::new("calendar-secret-sentinel"), + &["calendar-secret-sentinel"], + ) + .expect("Google Calendar config redaction"); + check_debug_redaction( + &GmailConfig::new("gmail-secret-sentinel"), + &["gmail-secret-sentinel"], + ) + .expect("Gmail config redaction"); + check_debug_redaction( + &GranolaConfig::new("granola-secret-sentinel"), + &["granola-secret-sentinel"], + ) + .expect("Granola config redaction"); + check_debug_redaction( + &LinearConfig::new("linear-secret-sentinel"), + &["linear-secret-sentinel"], + ) + .expect("Linear config redaction"); + check_debug_redaction( + &SlackConfig::new("slack-secret-sentinel"), + &["slack-secret-sentinel"], + ) + .expect("Slack config redaction"); +} + +fn runtime_entity_kind(kind: ManifestEntityKind) -> EntityKind { + match kind { + ManifestEntityKind::Page => EntityKind::Page, + ManifestEntityKind::Database => EntityKind::Database, + ManifestEntityKind::Directory => EntityKind::Directory, + } +} + +fn assert_settings_schema_runtime_round_trip( + connector_id: &str, + examples: &[&str], + runtime_round_trip: impl Fn(&str) -> locality_core::LocalityResult, +) { + let registry = bundled_connector_registry().expect("manifest registry"); + let manifest = registry + .connector(connector_id) + .expect("connector manifest"); + let validator = jsonschema::validator_for(&manifest.mount.settings_schema) + .unwrap_or_else(|error| panic!("{connector_id} settings schema: {error}")); + + for example in examples { + let input = serde_json::from_str::(example).expect("settings JSON"); + let input_errors = validator + .iter_errors(&input) + .map(|error| error.to_string()) + .collect::>(); + assert!( + input_errors.is_empty(), + "{connector_id} representative input violates schema: {input_errors:?}" + ); + + let encoded = runtime_round_trip(example).unwrap_or_else(|error| { + panic!("{connector_id} runtime rejected schema input: {error}") + }); + let runtime_value = + serde_json::from_str::(&encoded).expect("runtime settings JSON"); + let output_errors = validator + .iter_errors(&runtime_value) + .map(|error| error.to_string()) + .collect::>(); + assert!( + output_errors.is_empty(), + "{connector_id} runtime output violates schema: {output_errors:?}" + ); + + let encoded_again = runtime_round_trip(&encoded) + .unwrap_or_else(|error| panic!("{connector_id} runtime rejected its output: {error}")); + assert_eq!( + serde_json::from_str::(&encoded_again).expect("second output"), + runtime_value, + "{connector_id} settings normalization is not stable" + ); + } +} diff --git a/docs-site/connectors/gmail.mdx b/docs-site/connectors/gmail.mdx new file mode 100644 index 00000000..4727a68c --- /dev/null +++ b/docs-site/connectors/gmail.mdx @@ -0,0 +1,63 @@ +--- +title: "Gmail connector" +description: "Mount Gmail messages and threads as files and create reviewed drafts." +--- + +The Gmail connector projects inbox and sent mail as read-only Markdown and +uses reviewed files under `draft/` to create Gmail drafts. + +## Connect + +```bash +loc connect gmail --name gmail-default +``` + +Locality requests scoped Gmail read-only and compose access. It does not request +the full-mailbox `https://mail.google.com/` scope. + +## Mount + +```bash +loc mount gmail ~/Locality/gmail-main --projection plain-files +loc pull ~/Locality/gmail-main +``` + +Optional mount settings include `--view messages|threads` and a bounded date +window with both `--after YYYY-MM-DD` and `--before YYYY-MM-DD`. + +## Projection + +```text +gmail-main/ + inbox/ + message-id.md + sent/ + message-id.md + draft/ +``` + +- `inbox/` and `sent/` are read-only; +- attachments are projected under stable local attachment paths; +- `draft/` accepts new Markdown files directly inside the directory. + +## Creating a draft + +```markdown +--- +to: + - person@example.com +subject: Follow up +--- + +Thanks for the conversation. Here are the next steps. +``` + +Review and push the file with `loc diff` and `loc push -y`. Locality creates a +Gmail UI draft; it does not send mail. + +## Current limits + +- Existing inbox and sent messages cannot be edited. +- Draft files must be direct children of `draft/`. +- V1 creates drafts but does not send, reply, forward, label, archive, or delete + messages. diff --git a/docs-site/connectors/granola.mdx b/docs-site/connectors/granola.mdx new file mode 100644 index 00000000..e611c2a6 --- /dev/null +++ b/docs-site/connectors/granola.mdx @@ -0,0 +1,54 @@ +--- +title: "Granola connector" +description: "Mount Granola meeting summaries and transcripts as read-only Markdown." +--- + +The Granola connector uses Granola's supported public REST API and is strictly +read-only. It does not inspect the desktop database, use private endpoints, or +write meeting notes. + +## Connect + +Granola API keys are available on supported Business and Enterprise plans. + +```bash +printf '%s' "$GRANOLA_API_KEY" | loc connect granola --api-key-stdin +``` + +## Mount + +```bash +loc mount granola ~/Locality/granola-main +loc pull ~/Locality/granola-main +``` + +## Projection + +```text +granola-main/ + Weekly product sync — 2026-07-14 17.30.00 UTC/ + summary.md + transcript.md +``` + +- meeting directories use title-first names plus UTC creation time; +- `summary.md` uses Granola's Markdown summary with a text fallback; +- `transcript.md` preserves returned transcript chunks in order; +- a stable transcript file explains when Granola returns no transcript. + +## Sync and safety + +Locality performs periodic incremental discovery with overlap so delayed +updates are retained safely. Meeting content is untrusted input and can be +sensitive. + +All Granola files are read-only. Locality rejects edits, creates, renames, +moves, deletes, push, undo, and autosave operations for the mount. + +## Current limits + +- Granola's public API and MCP surface do not support meeting-note writes. +- The API has no webhooks; changes arrive through periodic discovery, explicit + pull, or normal freshness work. +- Transcript retention can remove a transcript while leaving the meeting + summary available. diff --git a/docs-site/connectors/slack.mdx b/docs-site/connectors/slack.mdx index 92eb2846..351d3afd 100644 --- a/docs-site/connectors/slack.mdx +++ b/docs-site/connectors/slack.mdx @@ -12,9 +12,11 @@ Markdown. loc connect slack ``` -Locality requests read-only bot scopes for conversation metadata and history, -users and team metadata, and file metadata. It does not request `chat:write`, -admin scopes, search scopes, or user email scope. +Locality requests bot scopes for conversation metadata and history, users and +team metadata, file metadata, and `channels:join`. The last scope allows the +app to join public channels before reading them; this membership mutation is +separate from content writes. Locality does not request `chat:write`, admin +scopes, search scopes, or user email scope. ## Mount @@ -26,9 +28,13 @@ loc pull ~/Locality/slack-main Default settings: ```json -{"slack":{"history_limit":15,"types":["public_channel","private_channel","im","mpim"]}} +{"slack":{"history_limit":15,"types":["public_channel","private_channel","im","mpim"],"auto_join_public_channels":true}} ``` +Set `auto_join_public_channels` to `false` to prevent membership changes. +Unjoined public channels will be omitted; already joined public channels remain +readable. This option does not change the connector's read-only content policy. + ## Projection ```text diff --git a/docs-site/docs.json b/docs-site/docs.json index bbd88cde..fd487a8b 100644 --- a/docs-site/docs.json +++ b/docs-site/docs.json @@ -78,6 +78,8 @@ "connectors/notion", "connectors/google-docs", "connectors/google-calendar", + "connectors/gmail", + "connectors/granola", "connectors/linear", "connectors/slack" ] diff --git a/docs/README.md b/docs/README.md index cd04cefc..b6904019 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,8 @@ Use this directory for repo-facing documentation that helps contributors underst and client architecture for cloud coding-agent sandboxes. - [`freshness-wait-transport.md`](freshness-wait-transport.md): public, capability-gated durable freshness wait-attempt wire contract. +- [`connector-development.md`](connector-development.md): connector manifest, + host-hook, fixture, security-boundary, and add-a-connector contract. ## Static collaboration artifacts diff --git a/docs/connector-development.md b/docs/connector-development.md new file mode 100644 index 00000000..40a793ac --- /dev/null +++ b/docs/connector-development.md @@ -0,0 +1,174 @@ +# Connector Development + +Locality first-party connectors are compiled Rust crates. The public connector +manifest is language-neutral discovery metadata; it is not a plugin ABI and it +does not grant credential, network, filesystem, or push authority. Trusted host +code remains responsible for auth resolution, write policy, validation, +concurrency checks, and operation execution. + +## Add-a-connector checklist + +Complete every item in one change. CI compares these surfaces so a connector +crate cannot ship by itself. + +1. Add `crates/locality-` to the workspace. Use a stable lowercase + kebab-case connector ID and depend on `locality-connector`. +2. Implement `Connector::kind`, `capabilities`, + `supported_push_operations`, enumeration, fetch, render, and only the write + methods the provider actually supports. Do not advertise portable or batch + behavior until its full path is reachable and tested. +3. Add the connector to `connectors/registry.json` and keep it valid against + `connectors/registry.schema.json`. Record the exact runtime ID/version, + default profile and connection IDs, auth kinds/scopes/actions, mount + defaults/settings schema, descriptive capabilities/operations, projection + policy, crate path, icon, and docs slug. +4. Add the connection/profile creation path. Credentials go into the credential + store behind a `secret_ref`; never place credentials, executable commands, + broker sessions, or bearer values in the manifest or mount settings. +5. Add one `SourceRegistration` in `crates/localityd/src/source.rs`, paired to + the manifest ID. Add the daemon resolver, source descriptor, frontmatter + validators, read/write/create/move decisions, hydration adapter, and + reconciliation hooks that apply to the source. +6. Add CLI connect and mount routing without changing existing commands. Keep + provider-specific mount settings in the provider crate and serialize the + default represented by the manifest. +7. Add the desktop source ID, setup/auth classification, display metadata, and + `apps/desktop/src/assets/connectors/.svg` icon. Add OAuth-service routing + only when the connector actually uses the hosted OAuth broker. +8. Add `docs/-connector.md`, public + `docs-site/connectors/.mdx`, docs navigation, README support, and + any provider-specific security or live-test instructions. +9. Add the direct fixture layout below and use + `locality_connector::conformance` for identity, capability/operation, safe + path, read-only, redaction, and fixture checks. +10. Run the contract, provider, daemon, CLI, docs, formatting, and workspace + commands listed below. Verify live behavior only with a dedicated scratch + account and explicit live-test credentials. + +## Required direct fixture layout + +New provider crates must start with this credential-free layout: + +```text +crates/locality-/fixtures/direct-v1/ + .gitattributes + tree-paths.txt + native-.json + .md + settings-default.json + auth-scopes.json # OAuth connectors; exact standardized filename + auth-kind.txt # token/API-key connectors +``` + +`tree-paths.txt` is the canonical ordered projection. Each +`native-.json` has a matching exact rendered Markdown fixture. Settings +must contain no credentials. OAuth scope fixtures contain scope names only; +token/API-key fixtures contain only the auth-kind enum. Add more versioned +directories instead of silently changing an incompatible fixture contract. + +Use `check_direct_fixture_layout` to enforce the complete versioned layout. +The registry-v1 grandfathering list is exactly `notion`, `google-docs`, +`google-calendar`, `gmail`, `granola`, and `linear`: those connectors may omit +the entire `direct-v1` directory while their existing fixtures are migrated. +They may not add a partial directory. Slack is not grandfathered. New +connectors are never added to the list and must provide the complete layout; +once a grandfathered connector adds it, remove that connector from the list in +the daemon contract test. OAuth fixtures must be named `auth-scopes.json`; +`oauth-scopes.json` is rejected rather than treated as an alias. + +## Host hooks and boundaries + +The provider crate owns API DTOs/client behavior, quota/retry classification, +native fetch, canonical rendering/parsing, and provider operation lowering. +`locality-connector` owns reusable protocol, manifest, network, and conformance +types. The daemon owns runtime registration, credential resolution, source +descriptors, scheduling, path-level write decisions, hydration, and reconcile. +The CLI and desktop own setup presentation; they do not bypass daemon or +connector checks. + +The manifest describes those surfaces for discovery and drift testing. Hosts +must not generate security behavior from JSON. In particular: + +- a listed action or operation never authorizes a remote call; +- a writable mount still passes code-owned path policy, parsing, validation, + guardrails, approval, concurrency preflight, and connector apply; +- read-only connectors reject edit, create, move, delete, push, undo, and + autosave paths in trusted code even if a mount record is malformed; +- credentials and refresh handles stay behind `secret_ref` and every auth or + client `Debug` implementation redacts them; +- docs/icon identifiers are safe relative identifiers resolved below fixed + repository roots, never arbitrary paths or URLs. + +## Direct and hosted implementations + +Direct connectors, portable connector/projection contracts, clients, and the +manifest remain in this public repository. Direct mode resolves a local +connection and calls the provider from the Locality host. + +Hosted service orchestration, PostgreSQL persistence, AWS integration, and +OpenTofu stay in the private repository. A hosted adapter may consume an exact +public connector revision, but the public crates must not depend on private +hosted code. Do not use this manifest branch to enable currently unreachable +portable/batch paths or introduce a dynamic ABI/plugin loader. + +## Minimal read-only example + +The connector advertises no push operations and fails closed on apply. Real +implementations still provide enumerate/fetch/render methods omitted here for +brevity: + +```rust +use std::collections::BTreeSet; +use locality_connector::{ + ApplyPlanRequest, ApplyPlanResult, Connector, ConnectorCapabilities, + ConnectorKind, +}; +use locality_core::{LocalityError, LocalityResult}; +use locality_core::planner::PushOperationKind; + +struct ExampleConnector; + +impl Connector for ExampleConnector { + fn kind(&self) -> ConnectorKind { + ConnectorKind("example") + } + + fn capabilities(&self) -> ConnectorCapabilities { + ConnectorCapabilities::read_only() + } + + fn supported_push_operations(&self) -> BTreeSet { + BTreeSet::new() + } + + // enumerate, fetch, render, and parse omitted + + fn apply(&self, _: ApplyPlanRequest<'_>) -> LocalityResult { + Err(LocalityError::Unsupported("example connector is read-only")) + } +} +``` + +Its manifest uses `"read_only": true`, read-only or empty profile actions, and +an empty `push_operations` array. The daemon must also return read-only +decisions for write, create, and move paths. Conformance tests then compare +`kind()`, capabilities, operations, descriptor defaults, and host rejection to +the manifest without making a provider request. + +## Test commands + +```sh +cargo fmt --all -- --check +cargo test -p locality-connector --all-targets +cargo test -p localityd --test connector_manifest +cargo test -p localityd --test source_descriptor +cargo test -p locality- --all-targets +cargo test -p loc-cli --all-targets +cargo test --workspace --all-targets +jq empty connectors/registry.json connectors/registry.schema.json docs-site/docs.json +make docs-validate +make docs-broken-links +``` + +Run ignored live tests separately and never make them a prerequisite for the +credential-free conformance suite. diff --git a/docs/connector-sdk.md b/docs/connector-sdk.md index d329184d..8820cf16 100644 --- a/docs/connector-sdk.md +++ b/docs/connector-sdk.md @@ -14,6 +14,12 @@ validation, conflict detection, and network admission mechanics to the host: First-party connectors compile in as Rust crates. A future third-party connector ABI should be possible if this trait remains narrow, explicit, and host-mediated. +The versioned public connector catalog lives in `connectors/registry.json` and +is validated by `locality-connector`. It is descriptive metadata, not executable +authority: resolver, credential, path-write, validation, and apply behavior +remain in trusted code. See [Connector Development](connector-development.md) +for the exact registration and fixture contract. + ## Network Policy Each connector supplies a `ConnectorNetworkConfig` for the quota scope enforced diff --git a/docs/slack-connector.md b/docs/slack-connector.md index 82cf1da6..025c947f 100644 --- a/docs/slack-connector.md +++ b/docs/slack-connector.md @@ -12,9 +12,11 @@ loc mount slack ~/Locality/slack-main ``` Locality requests Slack's `channels:join` scope. Mounts whose `--types` include -`public_channel` join public channels before reading history. This mutates -Slack membership for the connected app. Private channels still require an -explicit Slack invite. +`public_channel` join public channels before reading history by default. This +mutates Slack membership for the connected app, and the manifest describes it +separately as `membership_operations: ["join_public_channels"]`. It is not a +content push operation and does not grant message or file write support. +Private channels still require an explicit Slack invite. The default Slack connector settings are: @@ -22,6 +24,11 @@ The default Slack connector settings are: {"slack":{"history_limit":15,"types":["public_channel","private_channel","im","mpim"],"auto_join_public_channels":true}} ``` +Set `auto_join_public_channels` to `false` in mount settings to avoid the +membership mutation. Unjoined public channels are then omitted rather than +joined or projected; public channels where the app is already a member remain +readable. + ## OAuth scopes Locality requests bot scopes for channel metadata and history, public channel