diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 0000000..875aaee --- /dev/null +++ b/.clippy.toml @@ -0,0 +1 @@ +warn-on-all-wildcard-imports = true diff --git a/.gitea/workflows/agents.yml b/.gitea/workflows/agents.yml new file mode 100644 index 0000000..7f49767 --- /dev/null +++ b/.gitea/workflows/agents.yml @@ -0,0 +1,49 @@ +name: Agents +run-name: >- + AI bot at + #${{ gitea.event.issue.number || gitea.event.pull_request.number }} + +on: + issues: + types: [opened] + pull_request: + types: [opened, review_requested] + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +# New human events replace stale runs for the same work item. AI-bot comments +# get unique groups so their skipped workflows cannot cancel an active login. +concurrency: + group: >- + agents-${{ gitea.event.sender.login == 'bot' && gitea.run_id || + gitea.event.issue.number || gitea.event.pull_request.number }} + cancel-in-progress: true + +jobs: + bot: + # Nothing the bot account posts can start a run, so it cannot trigger itself. + if: > + gitea.event.sender.login != 'bot' && + ((gitea.event_name == 'issues' && + contains(gitea.event.issue.body, '@bot')) || + (gitea.event_name == 'pull_request' && + ((gitea.event.action == 'opened' && + contains(gitea.event.pull_request.body, '@bot')) || + gitea.event.requested_reviewer.login == 'bot')) || + (gitea.event_name == 'issue_comment' && + contains(gitea.event.comment.body, '@bot')) || + (gitea.event_name == 'pull_request_review_comment' && + contains(gitea.event.review.content, '@bot'))) + runs-on: self-hosted + container: + volumes: + - bot-home:/root + steps: + - name: Run AI bot + # Intentionally follow protected main so centrally managed profiles and + # quota fallbacks update without per-repository credential changes. + uses: https://git.capsulizers.com/commons/bot-agents@main + with: + master-key: ${{ secrets.BOT_AGENTS_MASTER_KEY }} diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..32da818 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +concurrency: + group: ${{ gitea.workflow }}-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: self-hosted + container: + volumes: + - memona-plugin-target:/cargo-target + env: + CARGO_BUILD_JOBS: 2 + CARGO_TARGET_DIR: /cargo-target/serialized + CARGO_HOME: /cargo-target/cargo-home + RUSTUP_HOME: /cargo-target/rustup-home + RUSTUP_TOOLCHAIN: stable + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install system packages + run: >- + apt-get update && apt-get install -y --no-install-recommends + build-essential ca-certificates curl util-linux + + # The same lock protects the shared toolchain and every Cargo command. + # Keep its target and lock paths in sync with Memona's plugin checks. + - name: Check and build plugin + run: | + flock --no-fork "$CARGO_TARGET_DIR.lock" sh -ec ' + export PATH="$CARGO_HOME/bin:$PATH" + if [ ! -x "$CARGO_HOME/bin/rustup" ]; then + mkdir -p "$CARGO_HOME" + curl --proto =https --tlsv1.2 -fsSL https://sh.rustup.rs -o "$CARGO_HOME/rustup-init.sh" + sh "$CARGO_HOME/rustup-init.sh" -y --no-modify-path --default-toolchain none + rm "$CARGO_HOME/rustup-init.sh" + fi + rustup toolchain install stable --profile minimal --component clippy,rustfmt --target wasm32-wasip2 --no-self-update + cargo fmt --check + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked --lib + cargo build --locked --release --target wasm32-wasip2 + ' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..33043ba --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,3 @@ +style_edition = "2024" +max_width = 80 +tab_spaces = 2 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e99d3cc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,364 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memona-plugin-aindrive" +version = "0.1.0" +dependencies = [ + "base64", + "serde", + "serde_json", + "tokio", + "wit-bindgen", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01016da373cd8f7ef12624f796309f5c31ba8d646dd08856c02cd741d823c622" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.259.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d0246511d901aacf25d2dc9111f0054947de5e093fe662099e709ff7530dc3" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.259.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7bf119eb01f4a246864c10bd87c8f26c566350abb25aa4d5273c415bbab338" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.259.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f7c12eac7bb587801590f6a67ff0bc84d0748513864b71108e4b311cc3df694" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cb4b5556c3a791e86838ea287782bdafa704d55b0e68b5b81a3a16b9ea5f4b" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ad22a37ecbc0e1fdca34d2b670ba41368cb0920735643e00fdf39a16ee5431" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ba5213d4332c260a0d4e69e34b0f7e4b85ddae10b6ebfbac00d9c185ba628a" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f19d63da8e478a370ef8192d90ccb517965b7747d6b46cd2d831dd0ef4d2541" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.259.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092f783dee3253265fcde131475055f8c275ac0176a9c0396db1dc7b5544bfb4" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.259.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6390f4f02493ce678d509c859cf1698e38929fb75c8012a6ee2f7d6b6cb66cd7" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..15088ee --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "memona-plugin-aindrive" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "An aindrive filesystem provider for Memona, built as a WASI component." +repository = "https://git.capsulizers.com/commons/memona-plugin-aindrive" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["raw_value"] } +wit-bindgen = "=0.62.0" + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +base64 = "0.22" +tokio = { version = "1", features = ["macros", "rt"] } + +[lints.clippy] +panic = "deny" +unwrap_used = "deny" +expect_used = "deny" +wildcard_imports = "deny" + +[profile.dev] +debug = 0 diff --git a/README.md b/README.md index 63b51d6..757adc0 100644 --- a/README.md +++ b/README.md @@ -2,5 +2,68 @@ Opens an [aindrive](https://github.com/ainetwork-ai/aindrive) drive as a folder inside Memona, so agents and the file manager work in it like any local -directory, from any machine. It reaches the drive through aindrive's remote MCP -endpoint with the drive's access token. +directory, from any machine. The plugin is a WASI component: it speaks no +sockets of its own and makes every request through the host's HTTP interface, +to the drive's remote MCP endpoint, with the drive's access token. + +It registers one provider, `aindrive`, for the `aindrive` scheme. + +This package requires a Memona version that supports plugin manifest schema 2. + +## Connecting + +In aindrive, open the drive and choose **MCP** in its sidebar. Copy the +**Server URL**, then under **Option B · Access token** generate a token and +copy it; aindrive shows it only once. + +| Field | Meaning | +| --- | --- | +| Server URL | The drive's MCP server URL, for example `https://aindrive.ainetwork.ai/mcp/d/AbCdEf123456`. The drive's own page address, `…/d/AbCdEf123456`, works too, as does a self-hosted server. | +| Access token | The `aind_pat_…` token, stored as a secret | + +Saving the connection checks the address and the token. It does not need the +machine that shares the drive to be online, but its files only answer while +that machine runs aindrive; until then they report the folder unavailable. + +## What works + +Browsing folders and reading files. aindrive reads a file whole, at most +8 MiB at a time, so a larger file is refused as unsupported rather than cut +short. A read is checked against the size the drive reported a moment before, +so a file that changes in between is a conflict rather than a mix of two +versions. Names the drive's machine allows but Memona cannot show, such as one +containing a backslash, are left out of listings. + +Saving, creating folders, renaming and deleting are not supported yet, and +neither is change polling. + +## Building + +```sh +rustup target add wasm32-wasip2 +cargo build --locked --release --target wasm32-wasip2 +``` + +The component lands at +`target/wasm32-wasip2/release/memona_plugin_aindrive.wasm`. + +`wit/filesystem.wit` is a copy of the canonical Memona filesystem contract, and +`src/sdk.rs` generates the guest bindings from it, so this repository builds +without a Memona checkout. Refresh the copy from `plugin_system/sdk/wit` in the +[Memona repository](https://git.capsulizers.com/lead/memona) when the contract +moves, and keep `apiVersion` in `manifest.json` in step. + +## Packaging for the plugin store + +A `.memona-plugin` package is a ZIP archive with the files at its root, never +inside a directory: + +```sh +cargo build --locked --release --target wasm32-wasip2 +cp target/wasm32-wasip2/release/memona_plugin_aindrive.wasm plugin.wasm +zip memona-plugin-aindrive.memona-plugin \ + manifest.json plugin.wasm README.md LICENSE +``` + +`manifest.json` and `plugin.wasm` are required; `README.md` becomes the guide on +the plugin's public page and `LICENSE` is shown beside it. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..d270e04 --- /dev/null +++ b/manifest.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 2, + "id": "memona/aindrive", + "name": "aindrive", + "version": "0.1.0", + "apiVersion": "0.1.0", + "description": "Open an aindrive drive as a folder, and let agents work in it.", + "license": "MIT", + "entry": "plugin.wasm", + "networkPorts": [], + "providers": [ + { + "id": "aindrive", + "name": "aindrive", + "schemes": ["aindrive"], + "configFields": [ + { + "key": "url", + "label": "Server URL", + "description": "From the drive's MCP panel in aindrive, for example https://aindrive.ainetwork.ai/mcp/d/AbCdEf123456", + "kind": "text", + "required": true + }, + { + "key": "token", + "label": "Access token", + "description": "Generated under Access token in the same panel", + "kind": "password", + "required": true + } + ] + } + ] +} diff --git a/src/address.rs b/src/address.rs new file mode 100644 index 0000000..ba8f800 --- /dev/null +++ b/src/address.rs @@ -0,0 +1,142 @@ +//! Where a drive answers, and how a Memona path names something in it. + +use crate::sdk::ErrorCode; + +/// The drive's MCP endpoint, from whichever drive address was pasted: the +/// server URL aindrive's MCP panel shows (`…/mcp/d/`) or the drive's own +/// page (`…/d/`, with or without a folder after it). Anything before the +/// drive segments is kept, so a server hosted under a path still works. +pub fn endpoint(input: &str) -> Result { + let input = input.trim(); + let rest = input + .strip_prefix("https://") + .or_else(|| input.strip_prefix("http://")) + .ok_or(ErrorCode::InvalidInput)?; + let origin_end = + input.len() - rest.len() + rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let (origin, tail) = input.split_at(origin_end); + if origin_end == input.len() - rest.len() { + return Err(ErrorCode::InvalidInput); + } + let path = tail.split(['?', '#']).next().unwrap_or_default(); + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let marker = segments + .iter() + .position(|segment| *segment == "d") + .ok_or(ErrorCode::InvalidInput)?; + let id = segments.get(marker + 1).ok_or(ErrorCode::InvalidInput)?; + if id.is_empty() + || id.len() > 64 + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(ErrorCode::InvalidInput); + } + let prefix_end = match marker.checked_sub(1) { + Some(before) if segments[before] == "mcp" => before, + _ => marker, + }; + let mut url = origin.to_owned(); + for segment in &segments[..prefix_end] { + url.push('/'); + url.push_str(segment); + } + url.push_str("/mcp/d/"); + url.push_str(id); + Ok(url) +} + +/// A drive path is relative to the drive root, and the root itself is empty. +pub fn drive_path(path: &str) -> &str { + path.trim_matches('/') +} + +/// A name Memona can show as one path segment. The drive's device may hold +/// names the host refuses, and one of those would fail the whole listing. +pub fn showable(name: &str) -> bool { + !name.is_empty() + && name != "." + && name != ".." + && !name.contains(['/', '\\']) + && !name.chars().any(char::is_control) +} + +#[cfg(test)] +mod tests { + use super::{drive_path, endpoint, showable}; + use crate::sdk::ErrorCode; + + #[tokio::test] + async fn the_mcp_panel_url_is_used_as_given() { + assert_eq!( + endpoint("https://aindrive.ainetwork.ai/mcp/d/KQHGpl8hFXq8"), + Ok("https://aindrive.ainetwork.ai/mcp/d/KQHGpl8hFXq8".to_owned()) + ); + assert_eq!( + endpoint(" https://aindrive.ainetwork.ai/mcp/d/KQHGpl8hFXq8/ \n"), + Ok("https://aindrive.ainetwork.ai/mcp/d/KQHGpl8hFXq8".to_owned()) + ); + } + + #[tokio::test] + async fn a_drive_page_address_finds_the_same_endpoint() { + for page in [ + "https://aindrive.ainetwork.ai/d/KQHGpl8hFXq8", + "https://aindrive.ainetwork.ai/d/KQHGpl8hFXq8?path=notes", + "https://aindrive.ainetwork.ai/d/KQHGpl8hFXq8/notes/today.md", + "https://aindrive.ainetwork.ai/d/KQHGpl8hFXq8/manage#members", + ] { + assert_eq!( + endpoint(page), + Ok("https://aindrive.ainetwork.ai/mcp/d/KQHGpl8hFXq8".to_owned()), + "{page}" + ); + } + } + + #[tokio::test] + async fn a_self_hosted_server_keeps_its_port_and_base_path() { + assert_eq!( + endpoint("http://localhost:3737/d/a_b-C"), + Ok("http://localhost:3737/mcp/d/a_b-C".to_owned()) + ); + assert_eq!( + endpoint("https://files.example/aindrive/mcp/d/abc"), + Ok("https://files.example/aindrive/mcp/d/abc".to_owned()) + ); + } + + #[tokio::test] + async fn an_address_without_a_drive_is_refused() { + for input in [ + "", + "aindrive.ainetwork.ai/d/abc", + "ftp://aindrive.ainetwork.ai/d/abc", + "https://", + "https://aindrive.ainetwork.ai", + "https://aindrive.ainetwork.ai/d/", + "https://aindrive.ainetwork.ai/d/a%2Fb", + "https://aindrive.ainetwork.ai/drives/abc", + ] { + assert_eq!(endpoint(input), Err(ErrorCode::InvalidInput), "{input}"); + } + } + + #[tokio::test] + async fn memona_paths_become_drive_relative() { + assert_eq!(drive_path("/"), ""); + assert_eq!(drive_path("/notes/today.md"), "notes/today.md"); + assert_eq!(drive_path("/notes/"), "notes"); + } + + #[tokio::test] + async fn names_the_host_would_refuse_are_not_shown() { + assert!(showable("작가노트_2026.md")); + assert!(showable(".env")); + assert!(!showable("")); + assert!(!showable("..")); + assert!(!showable("a\\b")); + assert!(!showable("line\nbreak")); + } +} diff --git a/src/decode.rs b/src/decode.rs new file mode 100644 index 0000000..528b30f --- /dev/null +++ b/src/decode.rs @@ -0,0 +1,200 @@ +//! Base64 decoding cheap enough for Memona's per-call fuel. A read hands over +//! up to 1 MiB in one call, and a general decoder spends more instructions on +//! that than the call is given. Here every two characters cost one table +//! lookup, and the characters are read where they arrived, never copied: +//! the host charges fuel for each byte a plugin copies. + +use crate::sdk::ErrorCode; + +const ALPHABET: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +/// Set in a looked-up word when either character is not base64. +const INVALID: u32 = 1 << 31; + +/// Two tables indexed by a pair of characters, read as a little-endian `u16`: +/// the group's first pair and its second pair. Each entry holds that pair's +/// bits already in place in the group's three bytes, read as a little-endian +/// word, so the two halves only need an OR. +static PAIRS: [[u32; 1 << 16]; 2] = pairs(); + +const fn bytes_of(group: u32) -> u32 { + ((group >> 16) & 0xff) | (((group >> 8) & 0xff) << 8) | ((group & 0xff) << 16) +} + +const fn pairs() -> [[u32; 1 << 16]; 2] { + let mut tables = [[INVALID; 1 << 16]; 2]; + let mut high = 0; + while high < 64 { + let mut low = 0; + while low < 64 { + let index = ALPHABET[high] as usize | (ALPHABET[low] as usize) << 8; + let bits = (high as u32) << 6 | low as u32; + tables[0][index] = bytes_of(bits << 12); + tables[1][index] = bytes_of(bits); + low += 1; + } + high += 1; + } + tables +} + +/// One group of four characters: its three bytes in the low 24 bits, and +/// the invalid flag if any character was not base64. +fn group(chars: [u8; 4]) -> u32 { + let chars = u32::from_le_bytes(chars); + PAIRS[0][(chars & 0xffff) as usize] | PAIRS[1][(chars >> 16) as usize] +} + +/// Decodes whole groups into `out`, three bytes for every four characters. +/// Sixteen characters go through each step of the main loop, which measured +/// a third cheaper in fuel per character than four at a time. +fn groups(chars: &[u8], out: &mut [u8]) -> u32 { + let wide = chars.chunks_exact(16); + let (wide_out, rest_out) = out.split_at_mut(chars.len() / 16 * 12); + let rest = wide.remainder().chunks_exact(4); + let mut flags = 0; + for (chars, bytes) in wide.zip(wide_out.chunks_exact_mut(12)) { + let (Some(low), Some(high)) = + (chars.first_chunk::<8>(), chars.last_chunk::<8>()) + else { + continue; + }; + let low = u64::from_le_bytes(*low); + let high = u64::from_le_bytes(*high); + let [a, b, c, d] = [low, low >> 32, high, high >> 32] + .map(|half| group((half as u32).to_le_bytes())); + flags |= a | b | c | d; + let front = u64::from(a) | u64::from(b) << 24 | u64::from(c) << 48; + let back = c >> 16 | d << 8; + bytes[..8].copy_from_slice(&front.to_le_bytes()); + bytes[8..].copy_from_slice(&back.to_le_bytes()); + } + for (chars, bytes) in rest.zip(rest_out.chunks_exact_mut(3)) { + if let Some(chars) = chars.first_chunk::<4>() { + let word = group(*chars); + flags |= word; + bytes.copy_from_slice(&word.to_le_bytes()[..3]); + } + } + flags +} + +/// Decodes standard base64 that arrived in several pieces, read as one run, +/// into `out`. A group split between two pieces is joined on its own. The +/// run holds whole groups and no padding; `last` decodes the final group. +pub fn run<'a>( + pieces: impl Iterator, + out: &mut [u8], +) -> Result<(), ErrorCode> { + let mut written = 0; + let mut carry = [0_u8; 4]; + let mut carried = 0; + let mut flags = 0; + for mut piece in pieces { + if carried > 0 { + let taken = piece.len().min(4 - carried); + carry[carried..carried + taken].copy_from_slice(&piece[..taken]); + carried += taken; + piece = &piece[taken..]; + if carried < 4 { + continue; + } + let target = out + .get_mut(written..written + 3) + .ok_or(ErrorCode::Internal)?; + let word = group(carry); + flags |= word; + target.copy_from_slice(&word.to_le_bytes()[..3]); + written += 3; + } + let whole = piece.len() / 4 * 4; + let length = whole / 4 * 3; + let target = out + .get_mut(written..written + length) + .ok_or(ErrorCode::Internal)?; + flags |= groups(&piece[..whole], target); + written += length; + carried = piece.len() - whole; + carry[..carried].copy_from_slice(&piece[whole..]); + } + if carried > 0 || written != out.len() || flags & INVALID != 0 { + return Err(ErrorCode::Unavailable); + } + Ok(()) +} + +/// The bytes of a final group, which may end in one or two `=`. +pub fn last(chars: [u8; 4]) -> Result, ErrorCode> { + let padding = chars.iter().rev().take_while(|c| **c == b'=').count(); + if padding > 2 { + return Err(ErrorCode::Unavailable); + } + let mut padded = chars; + for char in &mut padded[4 - padding..] { + *char = b'A'; + } + let word = group(padded); + if word & INVALID != 0 { + return Err(ErrorCode::Unavailable); + } + Ok(word.to_le_bytes()[..3 - padding].to_vec()) +} + +#[cfg(test)] +mod tests { + use super::{last, run}; + use crate::sdk::ErrorCode; + use base64::Engine; + use base64::engine::general_purpose::STANDARD; + + /// Decodes all of `encoded`, split at `cuts`, as the provider does. + fn decode(encoded: &[u8], cuts: &[usize]) -> Result, ErrorCode> { + if encoded.is_empty() { + return Ok(Vec::new()); + } + let (body, tail) = encoded.split_at(encoded.len() - 4); + let mut pieces = Vec::new(); + let mut from = 0; + for cut in cuts.iter().map(|cut| (*cut).min(body.len())) { + pieces.push(&body[from..cut.max(from)]); + from = cut.max(from); + } + pieces.push(&body[from..]); + let mut out = vec![0; body.len() / 4 * 3]; + run(pieces.into_iter(), &mut out)?; + let tail: [u8; 4] = tail.try_into().map_err(|_| ErrorCode::Internal)?; + out.extend(last(tail)?); + Ok(out) + } + + #[tokio::test] + async fn every_length_and_split_decodes_as_a_general_decoder_would() + -> Result<(), ErrorCode> { + let bytes: Vec = (0..=255).rev().cycle().take(1000).collect(); + for length in 0..1000 { + let encoded = STANDARD.encode(&bytes[..length]); + let encoded = encoded.as_bytes(); + for cuts in [&[][..], &[1], &[5, 6, 7], &[17, 33], &[2, 4, 6, 8, 10]] { + assert_eq!(decode(encoded, cuts)?, bytes[..length], "{length}"); + } + } + Ok(()) + } + + #[tokio::test] + async fn anything_but_standard_base64_is_refused() { + for bad in ["QUJ-QUJD", "QUJ_QUJD", "QU\nJQUJD", "QU=DQUJD", "Q===QUJD"] { + assert_eq!( + run([bad.as_bytes()].into_iter(), &mut [0; 6]), + Err(ErrorCode::Unavailable), + "{bad}" + ); + } + assert_eq!(last(*b"Q==="), Err(ErrorCode::Unavailable)); + assert_eq!(last(*b"QU=D"), Err(ErrorCode::Unavailable)); + assert_eq!( + run([&b"QUJ"[..]].into_iter(), &mut [0; 0]), + Err(ErrorCode::Unavailable) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7ff4aa2 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,411 @@ +//! An aindrive filesystem provider for Memona. It speaks to the drive's +//! remote MCP endpoint through the host's HTTP, with the drive's access token. + +mod address; +mod decode; +mod mcp; +mod sdk; +#[cfg(test)] +mod session_tests; +#[cfg(test)] +mod test_host; + +use sdk::{ + Capabilities, ChangePage, Entry, EntryKind, ErrorCode, FileInfo, Guest, + GuestReader, GuestWriter, ListPage, Locator, Reader, Setting, WriteCondition, + Writer, +}; +use serde::de::DeserializeOwned; +use serde_json::json; +use std::cell::{Cell, RefCell}; +use std::ops::Range; + +#[cfg(not(test))] +use sdk::host; +#[cfg(test)] +use test_host as host; + +const MAX_CHUNK: u32 = 1024 * 1024; +/// The drive's device reads at most 8 MiB in one call and cuts the rest, so +/// a longer file is refused before any of it is read. +const MAX_READ: u64 = 8 * 1024 * 1024; +/// A whole 8 MiB file in base64, with room for the reply around it. +const MAX_REPLY: usize = 12 * 1024 * 1024; +/// Without these the folder cannot even be browsed. +const REQUIRED_TOOLS: [&str; 3] = ["list_files", "stat", "read_file"]; + +struct Session { + endpoint: String, + authorization: String, +} + +impl Session { + fn from_config(config: &[(String, Setting)]) -> Result { + let lookup = |wanted: &str| { + config + .iter() + .find(|(key, _)| key == wanted) + .and_then(|(_, value)| match value { + Setting::Text(text) | Setting::Secret(text) => { + let text = text.trim(); + (!text.is_empty()).then(|| text.to_owned()) + } + Setting::Toggle(_) => None, + }) + }; + let endpoint = + address::endpoint(&lookup("url").ok_or(ErrorCode::InvalidInput)?)?; + let token = lookup("token").ok_or(ErrorCode::InvalidInput)?; + Ok(Self { + endpoint, + authorization: format!("Bearer {token}"), + }) + } +} + +thread_local! { + static SESSION: RefCell> = const { RefCell::new(None) }; +} + +struct Aindrive; + +fn transport(error: host::HostError) -> ErrorCode { + match error { + host::HostError::Denied => ErrorCode::Denied, + host::HostError::Cancelled => ErrorCode::Cancelled, + host::HostError::InvalidInput => ErrorCode::InvalidInput, + host::HostError::Internal => ErrorCode::Internal, + host::HostError::Unavailable => ErrorCode::Unavailable, + } +} + +/// The drive-relative path a locator names; the drive root is empty. +fn route(target: &Locator) -> String { + let Locator::Path(path) = target; + address::drive_path(path).to_owned() +} + +fn header(name: &str, value: &str) -> host::Header { + host::Header { + name: name.to_owned(), + value: value.to_owned(), + } +} + +/// Sends one JSON-RPC body to the drive and returns the reply in the pieces +/// the host handed over. They are not joined here: Memona's host charges +/// fuel for every byte a plugin copies, and a file's reply joined whole would +/// spend most of a call's budget before any of it was decoded. +fn post(body: &[u8]) -> Result>, ErrorCode> { + let (url, authorization) = SESSION.with_borrow(|session| { + session + .as_ref() + .map(|session| (session.endpoint.clone(), session.authorization.clone())) + .ok_or(ErrorCode::Internal) + })?; + let call = host::http(&host::Request { + method: host::Method::Post, + url, + headers: vec![ + header("content-type", "application/json"), + header("accept", "application/json, text/event-stream"), + header("authorization", &authorization), + ], + }) + .map_err(transport)?; + for piece in body.chunks(MAX_CHUNK as usize) { + call.push(piece).map_err(transport)?; + } + let head = call.respond().map_err(transport)?; + mcp::status(head.status)?; + let mut pieces = Vec::new(); + let mut total = 0; + loop { + let piece = call.pull(MAX_CHUNK).map_err(transport)?; + if piece.is_empty() { + return Ok(pieces); + } + total += piece.len(); + if total > MAX_REPLY { + return Err(ErrorCode::Unavailable); + } + pieces.push(piece); + } +} + +/// A reply small enough to read as one buffer, which it usually already is. +fn joined(mut pieces: Vec>) -> Vec { + match pieces.len() { + 1 => pieces.pop().unwrap_or_default(), + _ => pieces.concat(), + } +} + +/// Calls one of the drive's tools and reads its structured answer. +fn tool( + name: &str, + arguments: &serde_json::Value, +) -> Result { + mcp::reply(&joined(post(&mcp::call(name, arguments))?)) +} + +fn info_of(entry: &mcp::Entry) -> FileInfo { + FileInfo { + kind: if entry.is_dir { + EntryKind::Directory + } else { + EntryKind::File + }, + // A folder's size is the device's directory record, not its contents. + size: if entry.is_dir { 0 } else { entry.size }, + revision: entry.revision(), + } +} + +/// One file read whole from the drive and handed over in pieces, decoded +/// from the reply as each piece is asked for. +struct Download { + reply: Vec>, + /// Where the base64 contents sit in the reply, read as one run. + encoded: Range, + size: u64, + position: Cell, + revision: Option, +} + +impl Download { + /// Where the characters of group `index`, three bytes each, start. + fn chars(&self, index: usize) -> usize { + self.encoded.start + index * 4 + } + + /// The bytes of one group, the last of which may be padded. + fn group(&self, index: usize) -> Result, ErrorCode> { + let mut chars = [0_u8; 4]; + let mut filled = 0; + let at = self.chars(index); + for slice in mcp::slices(&self.reply, at..at + 4) { + chars[filled..filled + slice.len()].copy_from_slice(slice); + filled += slice.len(); + } + let bytes = decode::last(chars)?; + let final_group = self.encoded.len() / 4 - 1; + if filled != 4 || (index != final_group && bytes.len() != 3) { + return Err(ErrorCode::Unavailable); + } + Ok(bytes) + } + + /// Decodes at most `wanted` bytes from byte `start`. A start inside a + /// group of three answers only the rest of that group, a short piece the + /// host reads past, so every later piece starts where a group does and no + /// decoded bytes are shifted into place. + fn decode(&self, start: u64, wanted: usize) -> Result, ErrorCode> { + let first = usize::try_from(start / 3).map_err(|_| ErrorCode::Internal)?; + let skip = (start % 3) as usize; + if skip != 0 { + let bytes = self.group(first)?; + let rest = bytes.get(skip..).unwrap_or_default(); + return Ok(rest[..rest.len().min(wanted)].to_vec()); + } + let final_group = self.encoded.len() / 4 - 1; + let end = first + wanted.div_ceil(3); + let whole = end.min(final_group); + let mut bytes = Vec::with_capacity((end - first) * 3); + bytes.resize((whole - first) * 3, 0); + let chars = self.chars(first)..self.chars(whole); + decode::run(mcp::slices(&self.reply, chars), &mut bytes)?; + if end > final_group { + bytes.extend(self.group(final_group)?); + } + bytes.truncate(wanted); + Ok(bytes) + } +} + +impl GuestReader for Download { + fn pull(&self, limit: u32) -> Result, ErrorCode> { + let start = self.position.get(); + let wanted = u64::from(limit.min(MAX_CHUNK)).min(self.size - start); + if wanted == 0 { + return Ok(Vec::new()); + } + let bytes = self.decode(start, wanted as usize)?; + self.position.set(start + bytes.len() as u64); + Ok(bytes) + } + + fn revision(&self) -> Option { + self.revision.clone() + } +} + +/// Starts reading one file. `read_file` has no offset and no revision, so +/// the file is statted first and read whole. If its length then disagrees +/// with the stat, the file changed in between, and the read is refused as a +/// conflict rather than paired with the wrong revision. +fn download(target: &Locator, offset: u64) -> Result { + let path = route(target); + let entry: mcp::Entry = tool("stat", &json!({ "path": path }))?; + if entry.is_dir { + return Err(ErrorCode::InvalidInput); + } + if entry.size > MAX_READ { + return Err(ErrorCode::Unsupported); + } + let (reply, encoded) = if offset >= entry.size { + (Vec::new(), 0..0) + } else { + let length = usize::try_from(entry.size.div_ceil(3) * 4) + .map_err(|_| ErrorCode::Unsupported)?; + let reply = post(&mcp::call( + "read_file", + &json!({ "path": path, "encoding": "base64" }), + ))?; + let encoded = mcp::contents(&reply, length)?; + (reply, encoded) + }; + Ok(Download { + reply, + encoded, + size: entry.size, + position: Cell::new(offset.min(entry.size)), + revision: entry.revision(), + }) +} + +/// Writing is not offered yet, so no transfer is ever started. +enum Upload {} + +impl GuestWriter for Upload { + fn push(&self, _data: Vec) -> Result<(), ErrorCode> { + match *self {} + } + + fn finish(&self) -> Result, ErrorCode> { + match *self {} + } +} + +impl Guest for Aindrive { + type Reader = Download; + type Writer = Upload; + + /// Asking which tools the token may call checks the address and the token + /// without waking the drive's device, so a connection can be saved while + /// that machine sleeps; its files answer once it is back. + fn open( + _provider_id: String, + config: Vec<(String, Setting)>, + ) -> Result { + SESSION.set(None); + SESSION.set(Some(Session::from_config(&config)?)); + let probe = post(mcp::TOOLS).and_then(|pieces| mcp::tools(&joined(pieces))); + let tools = match probe { + Ok(tools) => tools, + Err(error) => { + SESSION.set(None); + return Err(error); + } + }; + if !REQUIRED_TOOLS + .iter() + .all(|required| tools.iter().any(|tool| tool == required)) + { + SESSION.set(None); + return Err(ErrorCode::Denied); + } + Ok(Capabilities { + write: false, + mkdir: false, + rename: false, + remove: false, + conditional_write: false, + watch: false, + virtual_directories: false, + }) + } + + fn close() { + SESSION.set(None); + } + + /// The stat tool refuses the drive root, which always exists, so the root + /// is listed instead: that still tells whether the device can be reached. + fn stat(target: Locator) -> Result { + let path = route(&target); + if path.is_empty() { + let _: mcp::Listing = tool("list_files", &json!({ "path": "" }))?; + return Ok(FileInfo { + kind: EntryKind::Directory, + size: 0, + revision: None, + }); + } + let entry: mcp::Entry = tool("stat", &json!({ "path": path }))?; + Ok(info_of(&entry)) + } + + /// aindrive returns a whole folder at once, so the page is taken from it. + fn list( + target: Locator, + cursor: Option, + limit: u32, + ) -> Result { + let start: usize = match cursor { + Some(value) => value.parse().map_err(|_| ErrorCode::InvalidInput)?, + None => 0, + }; + let listing: mcp::Listing = + tool("list_files", &json!({ "path": route(&target) }))?; + let children: Vec = listing + .entries + .iter() + .filter(|entry| address::showable(&entry.name)) + .map(|entry| Entry { + name: entry.name.clone(), + info: info_of(entry), + }) + .collect(); + let end = start.saturating_add(limit as usize).min(children.len()); + let entries = children.get(start..end).unwrap_or_default().to_vec(); + Ok(ListPage { + entries, + cursor: (end < children.len()).then(|| end.to_string()), + }) + } + + fn open_read(target: Locator, offset: u64) -> Result { + Ok(Reader::new(download(&target, offset)?)) + } + + fn open_write( + _target: Locator, + _condition: WriteCondition, + _size_hint: Option, + ) -> Result { + Err(ErrorCode::Unsupported) + } + + fn mkdir(_target: Locator) -> Result { + Err(ErrorCode::Unsupported) + } + + fn rename( + _target: Locator, + _destination: String, + ) -> Result { + Err(ErrorCode::Unsupported) + } + + fn remove(_target: Locator) -> Result<(), ErrorCode> { + Err(ErrorCode::Unsupported) + } + + fn poll(_cursor: Option) -> Result { + Err(ErrorCode::Unsupported) + } +} + +#[cfg(not(test))] +sdk::export!(Aindrive with_types_in sdk); diff --git a/src/mcp.rs b/src/mcp.rs new file mode 100644 index 0000000..f026b2f --- /dev/null +++ b/src/mcp.rs @@ -0,0 +1,427 @@ +//! The drive's remote MCP endpoint: one JSON-RPC `tools/call` per request, +//! answered either as JSON or as a single server-sent event. + +use crate::sdk::ErrorCode; +use serde::Deserialize; +use serde::de::DeserializeOwned; +use serde_json::value::RawValue; +use std::borrow::Cow; +use std::ops::Range; + +/// Lists the tools this token may call. It never reaches the drive's device, +/// so it checks the address and the token even while the device is offline. +pub const TOOLS: &[u8] = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#; + +/// The body of one tool call. The endpoint keeps no session, so every call +/// stands alone without an `initialize` first. +pub fn call(tool: &str, arguments: &serde_json::Value) -> Vec { + serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { "name": tool, "arguments": arguments }, + }) + .to_string() + .into_bytes() +} + +/// What an HTTP status means before any body is read. A 404 is the drive +/// itself missing: paths that are not there arrive as tool errors instead. +pub fn status(code: u16) -> Result<(), ErrorCode> { + match code { + 200..=299 => Ok(()), + 401 | 403 => Err(ErrorCode::Denied), + 404 => Err(ErrorCode::NotFound), + _ => Err(ErrorCode::Unavailable), + } +} + +/// The message of a reply sent the way the MCP SDK sends one: a single +/// event, the whole message on its one `data:` line. It is borrowed as it +/// stands, without walking a file's worth of bytes to find where it ends. +fn single_event(body: &[u8]) -> Option<&[u8]> { + body + .strip_prefix(b"event: message\n") + .unwrap_or(body) + .strip_prefix(b"data: ")? + .strip_suffix(b"\n\n") +} + +/// The JSON-RPC message in any other reply: plain JSON, or the last event of +/// a stream, whose `data:` lines are joined as server-sent events define. +fn message(body: &[u8]) -> Result, ErrorCode> { + if body.trim_ascii_start().starts_with(b"{") { + return Ok(Cow::Borrowed(body)); + } + let mut event: Vec<&[u8]> = Vec::new(); + let mut last = Vec::new(); + for line in body.split(|byte| *byte == b'\n') { + let line = line.strip_suffix(b"\r").unwrap_or(line); + if line.is_empty() && !event.is_empty() { + last = std::mem::take(&mut event); + } + if let Some(data) = line.strip_prefix(b"data:") { + event.push(data.strip_prefix(b" ").unwrap_or(data)); + } + } + if !event.is_empty() { + last = event; + } + match last.as_slice() { + [] => Err(ErrorCode::Unavailable), + [only] => Ok(Cow::Borrowed(only)), + many => Ok(Cow::Owned(many.join(&b'\n'))), + } +} + +#[derive(Deserialize)] +#[serde(bound = "T: DeserializeOwned")] +struct Envelope { + result: Option, + error: Option, +} + +/// A tool result, read in the same pass as its envelope: a file's contents +/// are its largest part, and every extra pass over them costs fuel. +#[derive(Deserialize)] +#[serde(bound = "T: DeserializeOwned")] +struct Outcome { + #[serde(default, rename = "isError")] + is_error: bool, + #[serde(default)] + content: Vec, + #[serde(rename = "structuredContent")] + structured: Option, +} + +#[derive(Deserialize)] +struct Text { + #[serde(default)] + text: String, +} + +fn envelope(body: &[u8]) -> Result { + let envelope: Envelope = + match single_event(body).map(serde_json::from_slice) { + Some(Ok(envelope)) => envelope, + _ => serde_json::from_slice(&message(body)?) + .map_err(|_| ErrorCode::Unavailable)?, + }; + if envelope.error.is_some() { + return Err(ErrorCode::InvalidInput); + } + envelope.result.ok_or(ErrorCode::Unavailable) +} + +/// A tool's structured answer, or the error it reported. A JSON-RPC error, +/// as opposed to a tool error, means the request itself was malformed. +pub fn reply(body: &[u8]) -> Result { + let outcome: Outcome = envelope(body)?; + if outcome.is_error { + let text = outcome.content.first().map(|part| part.text.as_str()); + return Err(failure(text.unwrap_or_default())); + } + outcome.structured.ok_or(ErrorCode::Unavailable) +} + +/// The names of the tools a `tools/list` answer offers. +pub fn tools(body: &[u8]) -> Result, ErrorCode> { + #[derive(Deserialize)] + struct Listed { + tools: Vec, + } + #[derive(Deserialize)] + struct Tool { + name: String, + } + let listed: Listed = envelope(body)?; + Ok(listed.tools.into_iter().map(|tool| tool.name).collect()) +} + +/// One entry of a listing or a stat, as the drive's device reported it. +#[derive(Deserialize)] +pub struct Entry { + pub name: String, + #[serde(rename = "isDir")] + pub is_dir: bool, + #[serde(default)] + pub size: u64, + /// Kept as the device wrote it: a revision is compared as text, and a + /// float printed again could be spelled differently. + #[serde(default, rename = "mtimeMs")] + mtime: Option>, +} + +impl Entry { + /// The revision aindrive compares a conditional write against, + /// `m-s`. A folder has none. + pub fn revision(&self) -> Option { + if self.is_dir { + return None; + } + let mtime = self + .mtime + .as_deref() + .map(RawValue::get) + .filter(|text| *text != "null") + .unwrap_or("0"); + Some(format!("m{mtime}-s{}", self.size)) + } +} + +#[derive(Deserialize)] +pub struct Listing { + pub entries: Vec, +} + +/// A whole file, as `read_file` returns it. The device stops at 8 MiB and +/// says so with `truncated` rather than failing. +#[derive(Deserialize)] +pub struct Contents { + pub content: String, + pub encoding: String, + #[serde(default)] + pub truncated: bool, +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +/// The parts of `pieces`, read as one run, that `range` covers. +pub fn slices( + pieces: &[Vec], + range: Range, +) -> impl Iterator { + let mut offset = 0; + pieces.iter().filter_map(move |piece| { + let from = offset; + offset += piece.len(); + let start = range.start.max(from); + let end = range.end.min(offset); + (start < end).then(|| &piece[start - from..end - from]) + }) +} + +/// Where the base64 contents sit in a `read_file` reply, read as one run, +/// found without reading through them. They are nearly the whole reply, and +/// parsing them as a JSON string would spend a call's fuel on bytes base64 +/// never escapes. The stat says how long they must be; the rest of the reply, +/// with them left out, is still read whole to check it was a complete base64 +/// read. +pub fn contents( + pieces: &[Vec], + length: usize, +) -> Result, ErrorCode> { + const SECTION: &[u8] = br#""structuredContent":"#; + const FIELD: &[u8] = br#""content":""#; + let head = pieces.first().map(Vec::as_slice).unwrap_or_default(); + let start = + find(&head[..head.len().min(64 * 1024)], SECTION).and_then(|at| { + let from = at + SECTION.len(); + let near = &head[from..head.len().min(from + 1024)]; + find(near, FIELD).map(|field| from + field + FIELD.len()) + }); + let Some(start) = start else { + // A failed read carries no contents and is small enough to join. + let _: Contents = reply(&pieces.concat())?; + return Err(ErrorCode::Unavailable); + }; + // Contents of another length are another version of the file than the + // one statted, so the revision in hand does not describe them. + let end = start.saturating_add(length); + let total = pieces.iter().map(Vec::len).sum(); + if slices(pieces, end..total) + .next() + .and_then(|rest| rest.first()) + != Some(&b'"') + { + return Err(ErrorCode::Conflict); + } + let mut rest = head[..start].to_vec(); + for slice in slices(pieces, end..total) { + rest.extend_from_slice(slice); + } + let contents: Contents = reply(&rest)?; + if !contents.content.is_empty() + || contents.encoding != "base64" + || contents.truncated + { + return Err(ErrorCode::Unavailable); + } + Ok(start..end) +} + +/// What a tool error means to a filesystem caller. aindrive writes them as +/// `[code] message`, and an `internal` one carries the device's own error, +/// so the device's errno names are read too. Messages are never shown on: +/// they can hold the absolute path on the owner's machine. +fn failure(text: &str) -> ErrorCode { + let (code, message) = text + .strip_prefix('[') + .and_then(|rest| rest.split_once("] ")) + .unwrap_or(("internal", text)); + match code { + "not_found" => ErrorCode::NotFound, + "forbidden" => ErrorCode::Denied, + "invalid_params" if message.starts_with("conflict") => ErrorCode::Conflict, + "invalid_params" => ErrorCode::InvalidInput, + _ => device(message), + } +} + +fn device(message: &str) -> ErrorCode { + const KNOWN: &[(&str, ErrorCode)] = &[ + ("ENOENT", ErrorCode::NotFound), + ("EACCES", ErrorCode::Denied), + ("EPERM", ErrorCode::Denied), + ("reserved path", ErrorCode::Denied), + ("EEXIST", ErrorCode::Conflict), + ("ENOTEMPTY", ErrorCode::Conflict), + ("EISDIR", ErrorCode::InvalidInput), + ("is a directory", ErrorCode::InvalidInput), + ("ENOTDIR", ErrorCode::InvalidInput), + ("ENAMETOOLONG", ErrorCode::InvalidInput), + ("path too long", ErrorCode::InvalidInput), + ]; + KNOWN + .iter() + .find(|(needle, _)| message.contains(needle)) + .map_or(ErrorCode::Unavailable, |(_, code)| *code) +} + +#[cfg(test)] +mod tests { + use super::{Entry, Listing, call, reply, status, tools}; + use crate::sdk::ErrorCode; + use serde::Deserialize; + + #[derive(Debug, Deserialize, PartialEq)] + struct Answer { + ok: bool, + } + + fn tool_error(text: &str) -> Vec { + let message = serde_json::json!({ + "result": { + "_meta": {}, + "content": [{ "type": "text", "text": text }], + "isError": true, + }, + "jsonrpc": "2.0", + "id": 1, + }); + format!("event: message\ndata: {message}\n\n").into_bytes() + } + + #[tokio::test] + async fn a_call_names_the_tool_and_its_arguments() -> Result<(), ErrorCode> { + let body = call("list_files", &serde_json::json!({ "path": "notes" })); + let sent: serde_json::Value = + serde_json::from_slice(&body).map_err(|_| ErrorCode::Internal)?; + assert_eq!(sent["method"], "tools/call"); + assert_eq!(sent["params"]["name"], "list_files"); + assert_eq!(sent["params"]["arguments"]["path"], "notes"); + Ok(()) + } + + #[tokio::test] + async fn an_event_stream_and_plain_json_read_the_same() + -> Result<(), ErrorCode> { + let json = br#"{"result":{"content":[{"type":"text","text":"ok"}],"structuredContent":{"ok":true}},"jsonrpc":"2.0","id":1}"#; + let event = [b"event: message\r\ndata: ", &json[..], b"\r\n\r\n"].concat(); + assert_eq!(reply::(json)?, Answer { ok: true }); + assert_eq!(reply::(&event)?, Answer { ok: true }); + let split = b"event: message\ndata: {\"result\":\ndata: {\"structuredContent\":{\"ok\":true}}}\n\n"; + assert_eq!(reply::(split)?, Answer { ok: true }); + Ok(()) + } + + #[tokio::test] + async fn tool_errors_keep_missing_denied_and_conflicting_apart() { + let cases = [ + ("[not_found] no entry at notes/a.md", ErrorCode::NotFound), + ( + "[forbidden] forbidden (need editor, have viewer)", + ErrorCode::Denied, + ), + ("[forbidden] payment required for paid", ErrorCode::Denied), + ( + "[invalid_params] conflict: the file changed since that revision", + ErrorCode::Conflict, + ), + ("[invalid_params] invalid path: ..", ErrorCode::InvalidInput), + ("[internal] agent offline", ErrorCode::Unavailable), + ( + "[internal] ENOENT: no such file or directory, scandir '/Users/me/x'", + ErrorCode::NotFound, + ), + ( + "[internal] ENOTDIR: not a directory", + ErrorCode::InvalidInput, + ), + ("[internal] is a directory", ErrorCode::InvalidInput), + ("something unexpected", ErrorCode::Unavailable), + ]; + for (text, expected) in cases { + assert_eq!(reply::(&tool_error(text)), Err(expected), "{text}"); + } + } + + #[tokio::test] + async fn a_rejected_request_is_not_mistaken_for_a_reply() { + let rejected = + br#"{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"bad"}}"#; + assert_eq!(reply::(rejected), Err(ErrorCode::InvalidInput)); + assert_eq!(reply::(b""), Err(ErrorCode::Unavailable)); + assert_eq!(reply::(b""), Err(ErrorCode::Unavailable)); + } + + #[tokio::test] + async fn the_tool_list_names_what_the_token_may_call() -> Result<(), ErrorCode> + { + let body = br#"event: message +data: {"result":{"tools":[{"name":"list_files","inputSchema":{}},{"name":"read_file"}]},"jsonrpc":"2.0","id":1} + +"#; + assert_eq!(tools(body)?, vec!["list_files", "read_file"]); + Ok(()) + } + + #[tokio::test] + async fn a_revision_spells_the_modification_time_as_the_device_did() + -> Result<(), ErrorCode> { + let listing: Listing = serde_json::from_str( + r#"{"entries":[ + {"name":"a.md","isDir":false,"size":1837,"mtimeMs":1791726579102.7002}, + {"name":"b.md","isDir":false,"size":5,"mtimeMs":1791726579100}, + {"name":"c.md","isDir":false,"size":0}, + {"name":"notes","isDir":true,"size":96,"mtimeMs":1791726603429.636} + ]}"#, + ) + .map_err(|_| ErrorCode::Internal)?; + let revisions: Vec<_> = + listing.entries.iter().map(Entry::revision).collect(); + assert_eq!( + revisions, + [ + Some("m1791726579102.7002-s1837".to_owned()), + Some("m1791726579100-s5".to_owned()), + Some("m0-s0".to_owned()), + None, + ] + ); + Ok(()) + } + + #[tokio::test] + async fn statuses_keep_the_token_apart_from_the_drive() { + assert_eq!(status(200), Ok(())); + assert_eq!(status(401), Err(ErrorCode::Denied)); + assert_eq!(status(403), Err(ErrorCode::Denied)); + assert_eq!(status(404), Err(ErrorCode::NotFound)); + assert_eq!(status(502), Err(ErrorCode::Unavailable)); + } +} diff --git a/src/sdk.rs b/src/sdk.rs new file mode 100644 index 0000000..668b629 --- /dev/null +++ b/src/sdk.rs @@ -0,0 +1,15 @@ +//! Rust guest bindings for the versioned Memona filesystem component contract, +//! generated from the copy of the canonical WIT under `wit/`. + +wit_bindgen::generate!({ + path: "wit", + world: "filesystem", + pub_export_macro: true, +}); + +pub use exports::memona::filesystem::provider::{ + Capabilities, ChangePage, Entry, EntryKind, ErrorCode, FileInfo, Guest, + GuestReader, GuestWriter, ListPage, Locator, Reader, WriteCondition, Writer, +}; +pub use memona::filesystem::host; +pub use memona::filesystem::host::Setting; diff --git a/src/session_tests.rs b/src/session_tests.rs new file mode 100644 index 0000000..8a97957 --- /dev/null +++ b/src/session_tests.rs @@ -0,0 +1,287 @@ +//! The provider against an in-memory aindrive. Live QA separately runs the +//! compiled component through Memona's host against the real service. + +use super::{Aindrive, SESSION, download, test_host}; +use crate::sdk::{EntryKind, ErrorCode, Guest, GuestReader, Locator, Setting}; +use std::collections::BTreeMap; + +const ENDPOINT: &str = "https://aindrive.ainetwork.ai/mcp/d/KQHGpl8hFXq8"; + +fn config() -> Vec<(String, Setting)> { + vec![ + ( + "url".to_owned(), + Setting::Text("https://aindrive.ainetwork.ai/d/KQHGpl8hFXq8".to_owned()), + ), + ( + "token".to_owned(), + Setting::Secret(" aind_pat_secret\n".to_owned()), + ), + ] +} + +fn at(path: &str) -> Locator { + Locator::Path(path.to_owned()) +} + +fn sample() -> test_host::Drive { + let mut files = BTreeMap::new(); + for (path, bytes, mtime) in [ + ("PAGE.md", &b"# Studio"[..], "1791731214739.939"), + ("notes/작가노트_2026.md", &b"notes"[..], "1791726579100"), + ("notes/odd\\name.md", &b"x"[..], "1"), + ("artworks.tsv", &b"title\tyear"[..], "1791726579103.648"), + ] { + files.insert(path.to_owned(), test_host::file(bytes, mtime)); + } + test_host::Drive { + files, + folders: vec!["outputs".to_owned()], + ..test_host::Drive::default() + } +} + +#[tokio::test] +async fn opening_checks_the_token_against_the_drive_endpoint() +-> Result<(), ErrorCode> { + let drive = test_host::install(sample()); + let capabilities = Aindrive::open("aindrive".to_owned(), config())?; + assert!(!capabilities.write && !capabilities.remove && !capabilities.watch); + let drive = drive.borrow(); + let [seen] = drive.seen.as_slice() else { + return Err(ErrorCode::Internal); + }; + assert_eq!(seen.url, ENDPOINT); + assert_eq!(seen.method, "tools/list"); + assert_eq!( + seen.authorization.as_deref(), + Some("Bearer aind_pat_secret") + ); + Ok(()) +} + +#[tokio::test] +async fn a_refused_token_or_a_missing_drive_leaves_no_connection() { + for (status, expected) in + [(401, ErrorCode::Denied), (404, ErrorCode::NotFound)] + { + let _drive = test_host::install(test_host::Drive { + status: Some(status), + ..sample() + }); + assert_eq!( + Aindrive::open("aindrive".to_owned(), config()).err(), + Some(expected) + ); + assert!(SESSION.with_borrow(Option::is_none)); + } +} + +#[tokio::test] +async fn a_token_that_cannot_read_files_is_refused() { + let _drive = test_host::install(test_host::Drive { + tools: Some(vec!["search"]), + ..sample() + }); + assert_eq!( + Aindrive::open("aindrive".to_owned(), config()).err(), + Some(ErrorCode::Denied) + ); + assert!(SESSION.with_borrow(Option::is_none)); +} + +#[tokio::test] +async fn a_config_without_a_token_or_drive_is_invalid() { + let _drive = test_host::install(sample()); + let only_url = config().into_iter().take(1).collect(); + let only_token = config().into_iter().skip(1).collect(); + for config in [only_url, only_token] { + assert_eq!( + Aindrive::open("aindrive".to_owned(), config).err(), + Some(ErrorCode::InvalidInput) + ); + } +} + +#[tokio::test] +async fn listing_pages_through_a_folder_and_hides_unshowable_names() +-> Result<(), ErrorCode> { + let _drive = test_host::install(sample()); + Aindrive::open("aindrive".to_owned(), config())?; + let first = Aindrive::list(at("/"), None, 2)?; + let names: Vec<_> = first.entries.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, ["PAGE.md", "artworks.tsv"]); + assert_eq!( + first.entries[0].info.revision.as_deref(), + Some("m1791731214739.939-s8") + ); + let rest = Aindrive::list(at("/"), first.cursor, 2)?; + let names: Vec<_> = rest.entries.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, ["notes", "outputs"]); + assert!(rest.cursor.is_none()); + assert!(matches!(rest.entries[0].info.kind, EntryKind::Directory)); + assert_eq!(rest.entries[0].info.size, 0); + assert!(rest.entries[0].info.revision.is_none()); + + let notes = Aindrive::list(at("/notes/"), None, 10)?; + let names: Vec<_> = notes.entries.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, ["작가노트_2026.md"]); + Ok(()) +} + +#[tokio::test] +async fn listing_a_file_or_a_missing_folder_says_which() -> Result<(), ErrorCode> +{ + let _drive = test_host::install(sample()); + Aindrive::open("aindrive".to_owned(), config())?; + assert_eq!( + Aindrive::list(at("/PAGE.md"), None, 10).err(), + Some(ErrorCode::InvalidInput) + ); + assert_eq!( + Aindrive::list(at("/gone"), None, 10).err(), + Some(ErrorCode::NotFound) + ); + assert_eq!( + Aindrive::list(at("/"), Some("x".to_owned()), 10).err(), + Some(ErrorCode::InvalidInput) + ); + Ok(()) +} + +#[tokio::test] +async fn stat_reports_the_root_files_and_folders() -> Result<(), ErrorCode> { + let _drive = test_host::install(sample()); + Aindrive::open("aindrive".to_owned(), config())?; + let root = Aindrive::stat(at("/"))?; + assert!(matches!(root.kind, EntryKind::Directory)); + let page = Aindrive::stat(at("/PAGE.md"))?; + assert!(matches!(page.kind, EntryKind::File)); + assert_eq!(page.size, 8); + assert_eq!(page.revision.as_deref(), Some("m1791731214739.939-s8")); + assert!(matches!( + Aindrive::stat(at("/outputs"))?.kind, + EntryKind::Directory + )); + assert_eq!( + Aindrive::stat(at("/missing.md")).err(), + Some(ErrorCode::NotFound) + ); + Ok(()) +} + +/// Reads all of a file from `offset`, as the host does, in pulls of +/// `limit` bytes that may come back shorter. +fn read_from( + path: &str, + offset: u64, + limit: u32, +) -> Result<(Vec, Option), ErrorCode> { + let reader = download(&at(path), offset)?; + let mut read = Vec::new(); + loop { + let piece = reader.pull(limit)?; + assert!(piece.len() <= limit as usize); + if piece.is_empty() { + return Ok((read, reader.revision())); + } + read.extend_from_slice(&piece); + } +} + +#[tokio::test] +async fn reads_start_anywhere_and_never_exceed_the_limit() +-> Result<(), ErrorCode> { + // Longer than one of the test host's 4 KiB reply pieces, so groups of + // base64 characters are split between pieces. + let bytes: Vec = (0..=255).cycle().take(20_000).collect(); + let mut drive = sample(); + drive + .files + .insert("data.bin".to_owned(), test_host::file(&bytes, "5")); + let _drive = test_host::install(drive); + Aindrive::open("aindrive".to_owned(), config())?; + for offset in [0_u64, 1, 2, 3, 3070, 3071, 10_000, 19_998, 19_999] { + for limit in [1, 7, 4096, 1024 * 1024] { + let (read, revision) = read_from("/data.bin", offset, limit)?; + assert_eq!(read, bytes[offset as usize..], "{offset} by {limit}"); + assert_eq!(revision.as_deref(), Some("m5-s20000")); + } + } + assert!(read_from("/data.bin", 20_000, 10)?.0.is_empty()); + assert!(read_from("/data.bin", 50_000, 10)?.0.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn files_shorter_than_a_group_and_empty_files_read_whole() +-> Result<(), ErrorCode> { + let mut drive = sample(); + for (name, bytes) in [("one", &b"a"[..]), ("two", b"ab"), ("empty", b"")] { + drive + .files + .insert(name.to_owned(), test_host::file(bytes, "1")); + } + let _drive = test_host::install(drive); + Aindrive::open("aindrive".to_owned(), config())?; + assert_eq!(read_from("/one", 0, 10)?.0, b"a"); + assert_eq!(read_from("/two", 1, 10)?.0, b"b"); + assert_eq!(read_from("/empty", 0, 10)?.0, b""); + Ok(()) +} + +#[tokio::test] +async fn a_file_changed_between_stat_and_read_is_a_conflict() +-> Result<(), ErrorCode> { + let _drive = test_host::install(test_host::Drive { + change_on_read: Some(test_host::file(b"# Studio, longer now", "2")), + ..sample() + }); + Aindrive::open("aindrive".to_owned(), config())?; + assert_eq!( + download(&at("/PAGE.md"), 0).err(), + Some(ErrorCode::Conflict) + ); + Ok(()) +} + +#[tokio::test] +async fn folders_missing_files_and_oversized_files_are_not_read() +-> Result<(), ErrorCode> { + let mut drive = sample(); + drive.files.insert( + "large.bin".to_owned(), + test_host::file(&vec![0; 8 * 1024 * 1024 + 1], "9"), + ); + let drive = test_host::install(drive); + Aindrive::open("aindrive".to_owned(), config())?; + assert_eq!( + download(&at("/notes"), 0).err(), + Some(ErrorCode::InvalidInput) + ); + assert_eq!( + download(&at("/missing.md"), 0).err(), + Some(ErrorCode::NotFound) + ); + drive.borrow_mut().seen.clear(); + assert_eq!( + download(&at("/large.bin"), 0).err(), + Some(ErrorCode::Unsupported) + ); + let tools: Vec<_> = drive + .borrow() + .seen + .iter() + .map(|seen| seen.tool.clone()) + .collect(); + assert_eq!(tools, [Some("stat".to_owned())]); + Ok(()) +} + +#[tokio::test] +async fn nothing_is_requested_before_a_connection_opens() { + let drive = test_host::install(sample()); + Aindrive::close(); + assert_eq!(Aindrive::stat(at("/")).err(), Some(ErrorCode::Internal)); + assert!(drive.borrow().seen.is_empty()); +} diff --git a/src/test_host.rs b/src/test_host.rs new file mode 100644 index 0000000..b47c0c8 --- /dev/null +++ b/src/test_host.rs @@ -0,0 +1,258 @@ +//! An in-memory aindrive behind the generated synchronous `Guest` API. It +//! answers tool calls the way the drive's MCP endpoint does, as one +//! server-sent event; live QA covers Memona's real HTTP host and the service. + +pub use crate::sdk::host::{Header, HostError, Method, Request, Response}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use serde_json::{Value, json}; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::rc::Rc; + +pub struct File { + pub bytes: Vec, + /// Written into replies verbatim, the way the device's float prints. + pub mtime: &'static str, +} + +#[derive(Default)] +pub struct Drive { + /// Drive-relative paths. A folder is any prefix of a file, or listed here. + pub files: BTreeMap, + pub folders: Vec, + /// The tools `tools/list` offers; every tool by default. + pub tools: Option>, + /// The status every request answers with, when not 200. + pub status: Option, + /// Replaces a file between a stat and the read that follows it. + pub change_on_read: Option, + pub seen: Vec, +} + +pub struct Seen { + pub url: String, + pub authorization: Option, + pub method: String, + pub tool: Option, +} + +thread_local! { + static ACTIVE: RefCell>>> = + const { RefCell::new(None) }; +} + +pub fn install(drive: Drive) -> Rc> { + let drive = Rc::new(RefCell::new(drive)); + ACTIVE.set(Some(drive.clone())); + drive +} + +pub fn file(bytes: &[u8], mtime: &'static str) -> File { + File { + bytes: bytes.to_vec(), + mtime, + } +} + +pub fn http(request: &Request) -> Result { + if !matches!(request.method, Method::Post) { + return Err(HostError::InvalidInput); + } + let drive = ACTIVE + .with_borrow(Clone::clone) + .ok_or(HostError::Internal)?; + Ok(Exchange { + drive, + request: request.clone(), + body: RefCell::new(Vec::new()), + reply: RefCell::new(None), + }) +} + +pub struct Exchange { + drive: Rc>, + request: Request, + body: RefCell>, + reply: RefCell>>, +} + +impl Exchange { + pub fn push(&self, data: &[u8]) -> Result<(), HostError> { + if data.len() > 1024 * 1024 || self.reply.borrow().is_some() { + return Err(HostError::InvalidInput); + } + self.body.borrow_mut().extend_from_slice(data); + Ok(()) + } + + pub fn respond(&self) -> Result { + let call: Value = serde_json::from_slice(&self.body.borrow()) + .map_err(|_| HostError::InvalidInput)?; + let mut drive = self.drive.borrow_mut(); + let authorization = self + .request + .headers + .iter() + .find(|header| header.name == "authorization") + .map(|header| header.value.clone()); + drive.seen.push(Seen { + url: self.request.url.clone(), + authorization, + method: call["method"].as_str().unwrap_or_default().to_owned(), + tool: call["params"]["name"].as_str().map(str::to_owned), + }); + let (status, reply) = match drive.status { + Some(status) => (status, b"{\"error\":\"refused\"}".to_vec()), + None => (200, event(&drive.answer(&call))), + }; + *self.reply.borrow_mut() = Some(reply); + Ok(Response { + status, + headers: Vec::new(), + }) + } + + /// Hands the reply over in pieces smaller than asked for, so a caller + /// that stops at the first short piece loses the rest. + pub fn pull(&self, limit: u32) -> Result, HostError> { + let mut reply = self.reply.borrow_mut(); + let reply = reply.as_mut().ok_or(HostError::InvalidInput)?; + let count = reply.len().min(limit as usize).min(4096); + Ok(reply.drain(..count).collect()) + } +} + +fn event(message: &Value) -> Vec { + format!("event: message\ndata: {message}\n\n").into_bytes() +} + +fn success(structured: Value) -> Value { + json!({ + "result": { + "_meta": { "ai.aindrive/a2ui": [] }, + "content": [{ "type": "text", "text": "ok" }], + "structuredContent": structured, + }, + "jsonrpc": "2.0", + "id": 1, + }) +} + +fn failure(text: &str) -> Value { + json!({ + "result": { + "_meta": {}, + "content": [{ "type": "text", "text": text }], + "isError": true, + }, + "jsonrpc": "2.0", + "id": 1, + }) +} + +impl Drive { + fn answer(&mut self, call: &Value) -> Value { + if call["method"] == "tools/list" { + let tools = self.tools.clone().unwrap_or_else(|| { + vec![ + "list_files", + "read_file", + "write_file", + "delete_path", + "stat", + ] + }); + let tools: Vec = + tools.iter().map(|name| json!({ "name": name })).collect(); + return json!({ "result": { "tools": tools }, "jsonrpc": "2.0", "id": 1 }); + } + let arguments = &call["params"]["arguments"]; + let path = arguments["path"].as_str().unwrap_or_default().to_owned(); + match call["params"]["name"].as_str().unwrap_or_default() { + "list_files" => match self.children(&path) { + Some(entries) => success(json!({ "entries": entries })), + None if self.files.contains_key(&path) => { + failure("[internal] ENOTDIR: not a directory, scandir '/Users/me'") + } + None => failure("[internal] ENOENT: no such file or directory"), + }, + "stat" if path.is_empty() => failure("[invalid_params] path required"), + "stat" => match self.entry(&path) { + Some(entry) => success(entry), + None => failure(&format!("[not_found] no entry at {path}")), + }, + "read_file" => { + if let Some(changed) = self.change_on_read.take() { + self.files.insert(path.clone(), changed); + } + match self.files.get(&path) { + Some(file) => success(json!({ + "method": "read", + "content": STANDARD.encode(&file.bytes), + "encoding": "base64", + "truncated": false, + })), + None if self.is_folder(&path) => failure("[internal] is a directory"), + None => failure("[internal] ENOENT: no such file or directory"), + } + } + _ => failure("[internal] unknown tool"), + } + } + + fn is_folder(&self, path: &str) -> bool { + path.is_empty() + || self.folders.iter().any(|folder| folder == path) + || self + .files + .keys() + .any(|file| file.starts_with(&format!("{path}/"))) + } + + fn entry(&self, path: &str) -> Option { + let name = path.rsplit('/').next().unwrap_or(path); + if let Some(file) = self.files.get(path) { + let entry = format!( + r#"{{"name":{},"path":{},"isDir":false,"size":{},"mtimeMs":{}}}"#, + json!(name), + json!(path), + file.bytes.len(), + file.mtime, + ); + return serde_json::from_str(&entry).ok(); + } + self.is_folder(path).then(|| { + json!({ "name": name, "path": path, "isDir": true, "size": 96, + "mtimeMs": 1_791_726_544_422.142_f64 }) + }) + } + + fn children(&self, path: &str) -> Option> { + if !self.is_folder(path) { + return None; + } + let prefix = if path.is_empty() { + String::new() + } else { + format!("{path}/") + }; + let mut names: Vec<&str> = self + .files + .keys() + .chain(self.folders.iter()) + .filter_map(|item| item.strip_prefix(&prefix)) + .filter_map(|rest| rest.split('/').next()) + .filter(|name| !name.is_empty()) + .collect(); + names.dedup(); + names.sort_unstable(); + names.dedup(); + Some( + names + .into_iter() + .filter_map(|name| self.entry(&format!("{prefix}{name}"))) + .collect(), + ) + } +} diff --git a/wit/filesystem.wit b/wit/filesystem.wit new file mode 100644 index 0000000..824db1d --- /dev/null +++ b/wit/filesystem.wit @@ -0,0 +1,200 @@ +package memona:filesystem@0.1.0; + +/// Host services scoped to the current connection. No ambient filesystem, +/// environment, sockets, credentials, or network permissions are inherited. +interface host { + /// Stable failures without backend messages or credential-bearing URLs. + enum host-error { denied, unavailable, invalid-input, cancelled, internal } + /// Severity of a diagnostic event. The host may discard message contents. + enum log-level { debug, info, warn, error } + + /// Every method the host will send. A verb outside this list cannot be + /// spelled, so no plugin can smuggle one past the transport rules. + enum method { + get, head, post, put, patch, delete, + propfind, proppatch, mkcol, copy, move, lock, unlock, report, + } + /// An HTTP header. Host-controlled transport headers cannot be overridden. + record header { name: string, value: string } + /// Request head. At most 64 headers of 8 KiB each; the body is streamed. + record request { method: method, url: string, headers: list
} + /// Response head. The body is read from the exchange that produced it. + record response { status: u16, headers: list
} + /// One HTTP request and its response, streamed in bounded chunks so a + /// transfer is never held whole in memory. A body of any size crosses in + /// pieces, which is what multipart uploads of at least 5 MiB require. + /// Dropping the handle abandons the request. + resource exchange { + /// Appends at most 1,048,576 bytes to the request body. Every push + /// must land before `respond`. + push: func(data: list) -> result<_, host-error>; + /// Ends the request body and waits for the response head. Calling it + /// twice is invalid-input. + respond: func() -> result; + /// Reads at most 1,048,576 bytes of the response body, after + /// `respond`. An empty result means the body is complete. + pull: func(limit: u32) -> result, host-error>; + } + /// Begins a request to any HTTP or HTTPS host, including LAN and loopback + /// addresses. Each redirect and DNS result is checked, and DNS is pinned. + /// Cross-origin redirects remove request headers; HTTPS cannot downgrade. + /// The host bounds total bytes per exchange, not buffer size. + http: func(request: request) -> result; + + /// One open TCP connection. Dropping the handle closes the socket, and the + /// host closes every socket when the connection is disposed. + resource tcp { + /// Reads at most `limit` bytes, between 1 and 1,048,576. An empty + /// result means the peer closed its side, not that data is pending. + read: func(limit: u32) -> result, host-error>; + /// Writes at most 1,048,576 bytes and returns how many were accepted. + write: func(data: list) -> result; + } + /// Opens a socket to any host, including LAN and loopback addresses, on a + /// package-declared port. The host resolves and pins DNS and starts TLS + /// when asked. Ports negotiated at runtime, such as an FTP data channel, + /// must be declared. + connect: func(host: string, port: u16, tls: bool) -> result; + + /// One datagram socket connected to a single peer. + resource udp { + /// Sends one datagram of at most 65,507 bytes. + send: func(data: list) -> result; + /// Receives one datagram of at most `limit` bytes. An empty result + /// means none arrived inside the host's wait, not that the peer left. + receive: func(limit: u32) -> result, host-error>; + } + /// Connects a datagram socket under the same port rules as `connect`, for + /// the few filesystem protocols that still need one. + bind: func(host: string, port: u16) -> result; + + /// A configuration value, typed as the package's manifest declared it. + /// A secret is never logged by the host and never leaves the connection. + variant setting { text(string), secret(string), toggle(bool) } + /// Reads configuration or a session-only credential for this connection. + config: func(key: string) -> option; + + /// Emits a diagnostic event. Never include secrets or private document data. + log: func(level: log-level, message: string); +} + +/// Filesystem operations for one connection. Paths are absolute within its +/// root, starting with `/`; they are never local OS paths or resource URIs. +/// Colons and percent signs are literal; paths must never be URL-decoded. +/// Dot segments, backslashes, and controls are invalid. +/// Every invocation is bounded by host fuel, memory, time, and cancellation. +interface provider { + use host.{setting}; + + /// Stable operation failures. A mutation interrupted after dispatch can + /// report outcome-unknown: inspect remote state before deciding to retry. + enum error-code { + not-found, denied, unsupported, conflict, unavailable, invalid-input, + cancelled, outcome-unknown, internal + } + /// Anything that is neither a file nor a directory, such as a link, is + /// reported as other rather than flattened into one of the two. + enum entry-kind { file, directory, other } + + /// An opaque version token, at most 4096 UTF-8 bytes. + type revision = string; + + /// How one resource is named: a path within the connection root. + variant locator { path(string) } + + /// Optional operations. False capabilities must return unsupported. + /// Conditional-write promises atomic create-only and revision comparisons. + /// Virtual-directories says a directory is only ever a prefix of something + /// stored, as in a flat object store: the host then never offers to create + /// an empty one, warns that removing the last child removes the folder, + /// and does not call a vanished folder an error. Such a provider may still + /// accept mkdir by writing a marker, and must answer unsupported when + /// asked to rename a directory. + record capabilities { + write: bool, mkdir: bool, rename: bool, remove: bool, + conditional-write: bool, watch: bool, + virtual-directories: bool, + } + /// Byte length and an opaque revision, each if known. + record file-info { + kind: entry-kind, + size: u64, + revision: option, + } + /// One child name, without slashes, dot segments, or control characters. + record entry { name: string, info: file-info } + /// At most the requested number of entries. A cursor (maximum 4096 bytes) + /// identifies another page; absence means enumeration is complete. + record list-page { entries: list, cursor: option } + /// Atomic preconditions: absent file, exact opaque revision, or unconditional + /// replacement. Providers must never simulate comparisons non-atomically. + variant write-condition { create-only, if-match(revision), overwrite } + /// Changes since a previous polling cursor. + enum change-kind { created, modified, removed } + /// A changed resource path relative to the connection root. + record change { path: string, kind: change-kind } + /// At most 256 changes and a cursor of at most 4096 UTF-8 bytes. + record change-page { changes: list, cursor: option } + + /// One transfer being read. The handle survives between host calls, so a + /// file of any size is read as a series of separately bounded calls. + /// Dropping it releases whatever the provider held open. + resource reader { + /// Reads onward from the last position, at most `limit` bytes, with + /// `limit` between 1 and 1,048,576. An empty result means end of file. + pull: func(limit: u32) -> result, error-code>; + /// The revision this transfer is reading, once the provider knows it. + /// A provider that sees the revision change mid-transfer must fail the + /// next pull with conflict rather than mix two versions. + revision: func() -> option; + } + /// One transfer being written. Dropping the handle before it finishes + /// abandons the transfer, and the provider must leave no partial file. + resource writer { + /// Appends at most 1,048,576 bytes. Oversized pushes are rejected, + /// never truncated. + push: func(data: list) -> result<_, error-code>; + /// Commits the transfer and evaluates the write condition now, against + /// the state the caller last saw. A none result means the commit needs + /// another call: keep calling until it answers some, and treat an + /// interruption in between as outcome-unknown. + finish: func() -> result, error-code>; + } + + /// Opens exactly one host-owned connection per component instance. The host + /// passes only this connection's configuration and in-memory credentials. + open: func(provider-id: string, config: list>) -> result; + /// Best-effort cleanup before disposal. The host may dispose immediately + /// after a trap, cancellation, permission change, or process shutdown. + close: func(); + /// Reads metadata without changing the resource. + stat: func(target: locator) -> result; + /// Enumerates 1-256 direct children per request, without recursive traversal. + %list: func(target: locator, cursor: option, limit: u32) -> result; + /// Begins reading at a byte offset. Streaming, rather than one capped read, + /// is what lets a provider serve a file larger than a single call allows. + open-read: func(target: locator, offset: u64) -> result; + /// Begins replacing one complete file if the write capability is present. + /// The size hint, when the caller knows it, lets a provider choose between + /// a single request and a multipart upload before the first byte. + open-write: func(target: locator, condition: write-condition, size-hint: option) -> result; + /// Creates one directory if the mkdir capability is present. + mkdir: func(target: locator) -> result; + /// Renames within this connection if the rename capability is present. + rename: func(target: locator, destination: string) -> result; + /// Removes one file or empty directory if the remove capability is present. + remove: func(target: locator) -> result<_, error-code>; + /// Polls for a bounded change page if the watch capability is present. No + /// background callback or persistent subscription is installed by the host. + poll: func(cursor: option) -> result; +} + +/// Memona filesystem extension ABI 0.1.0, distributed as a WebAssembly component. +/// Rust guests can target wasm32-wasip2. Selected WASI 0.2 CLI, clocks, random, +/// IO and filesystem interfaces are allowed for the standard library, with +/// empty arguments/environment, empty preopens, and discarded stdio. WASI +/// sockets and HTTP imports are not allowed; networking uses the host interface. +world filesystem { + import host; + export provider; +}