Open an aindrive drive as a read-only folder in Memona #1
@@ -0,0 +1 @@
|
||||
warn-on-all-wildcard-imports = true
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
'
|
||||
@@ -0,0 +1 @@
|
||||
/target/
|
||||
@@ -0,0 +1,3 @@
|
||||
style_edition = "2024"
|
||||
max_width = 80
|
||||
tab_spaces = 2
|
||||
Generated
+364
@@ -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"
|
||||
+28
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+142
@@ -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/<id>`) or the drive's own
|
||||
/// page (`…/d/<id>`, 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<String, ErrorCode> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
+200
@@ -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<Item = &'a [u8]>,
|
||||
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<Vec<u8>, 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<Vec<u8>, 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<u8> = (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)
|
||||
);
|
||||
}
|
||||
}
|
||||
+411
@@ -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<Self, ErrorCode> {
|
||||
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<Option<Session>> = 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<Vec<Vec<u8>>, 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<u8>>) -> Vec<u8> {
|
||||
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<T: DeserializeOwned>(
|
||||
name: &str,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<T, ErrorCode> {
|
||||
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<Vec<u8>>,
|
||||
/// Where the base64 contents sit in the reply, read as one run.
|
||||
encoded: Range<usize>,
|
||||
size: u64,
|
||||
position: Cell<u64>,
|
||||
revision: Option<String>,
|
||||
}
|
||||
|
||||
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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<String> {
|
||||
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<Download, ErrorCode> {
|
||||
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<u8>) -> Result<(), ErrorCode> {
|
||||
match *self {}
|
||||
}
|
||||
|
||||
fn finish(&self) -> Result<Option<FileInfo>, 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<Capabilities, ErrorCode> {
|
||||
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<FileInfo, ErrorCode> {
|
||||
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<String>,
|
||||
limit: u32,
|
||||
) -> Result<ListPage, ErrorCode> {
|
||||
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<Entry> = 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<Reader, ErrorCode> {
|
||||
Ok(Reader::new(download(&target, offset)?))
|
||||
}
|
||||
|
||||
fn open_write(
|
||||
_target: Locator,
|
||||
_condition: WriteCondition,
|
||||
_size_hint: Option<u64>,
|
||||
) -> Result<Writer, ErrorCode> {
|
||||
Err(ErrorCode::Unsupported)
|
||||
}
|
||||
|
||||
fn mkdir(_target: Locator) -> Result<FileInfo, ErrorCode> {
|
||||
Err(ErrorCode::Unsupported)
|
||||
}
|
||||
|
||||
fn rename(
|
||||
_target: Locator,
|
||||
_destination: String,
|
||||
) -> Result<FileInfo, ErrorCode> {
|
||||
Err(ErrorCode::Unsupported)
|
||||
}
|
||||
|
||||
fn remove(_target: Locator) -> Result<(), ErrorCode> {
|
||||
Err(ErrorCode::Unsupported)
|
||||
}
|
||||
|
||||
fn poll(_cursor: Option<String>) -> Result<ChangePage, ErrorCode> {
|
||||
Err(ErrorCode::Unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
sdk::export!(Aindrive with_types_in sdk);
|
||||
+427
@@ -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<u8> {
|
||||
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<Cow<'_, [u8]>, 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<T> {
|
||||
result: Option<T>,
|
||||
error: Option<serde::de::IgnoredAny>,
|
||||
}
|
||||
|
||||
/// 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<T> {
|
||||
#[serde(default, rename = "isError")]
|
||||
is_error: bool,
|
||||
#[serde(default)]
|
||||
content: Vec<Text>,
|
||||
#[serde(rename = "structuredContent")]
|
||||
structured: Option<T>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Text {
|
||||
#[serde(default)]
|
||||
text: String,
|
||||
}
|
||||
|
||||
fn envelope<T: DeserializeOwned>(body: &[u8]) -> Result<T, ErrorCode> {
|
||||
let envelope: Envelope<T> =
|
||||
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<T: DeserializeOwned>(body: &[u8]) -> Result<T, ErrorCode> {
|
||||
let outcome: Outcome<T> = 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<Vec<String>, ErrorCode> {
|
||||
#[derive(Deserialize)]
|
||||
struct Listed {
|
||||
tools: Vec<Tool>,
|
||||
}
|
||||
#[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<Box<RawValue>>,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// The revision aindrive compares a conditional write against,
|
||||
/// `m<mtimeMs>-s<size>`. A folder has none.
|
||||
pub fn revision(&self) -> Option<String> {
|
||||
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<Entry>,
|
||||
}
|
||||
|
||||
/// 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<usize> {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
}
|
||||
|
||||
/// The parts of `pieces`, read as one run, that `range` covers.
|
||||
pub fn slices(
|
||||
pieces: &[Vec<u8>],
|
||||
range: Range<usize>,
|
||||
) -> impl Iterator<Item = &[u8]> {
|
||||
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<u8>],
|
||||
length: usize,
|
||||
) -> Result<Range<usize>, 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<u8> {
|
||||
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::<Answer>(json)?, Answer { ok: true });
|
||||
assert_eq!(reply::<Answer>(&event)?, Answer { ok: true });
|
||||
let split = b"event: message\ndata: {\"result\":\ndata: {\"structuredContent\":{\"ok\":true}}}\n\n";
|
||||
assert_eq!(reply::<Answer>(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::<Answer>(&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::<Answer>(rejected), Err(ErrorCode::InvalidInput));
|
||||
assert_eq!(reply::<Answer>(b"<html>"), Err(ErrorCode::Unavailable));
|
||||
assert_eq!(reply::<Answer>(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));
|
||||
}
|
||||
}
|
||||
+15
@@ -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;
|
||||
@@ -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<u8>, Option<String>), 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<u8> = (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());
|
||||
}
|
||||
@@ -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<u8>,
|
||||
/// 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<String, File>,
|
||||
pub folders: Vec<String>,
|
||||
/// The tools `tools/list` offers; every tool by default.
|
||||
pub tools: Option<Vec<&'static str>>,
|
||||
/// The status every request answers with, when not 200.
|
||||
pub status: Option<u16>,
|
||||
/// Replaces a file between a stat and the read that follows it.
|
||||
pub change_on_read: Option<File>,
|
||||
pub seen: Vec<Seen>,
|
||||
}
|
||||
|
||||
pub struct Seen {
|
||||
pub url: String,
|
||||
pub authorization: Option<String>,
|
||||
pub method: String,
|
||||
pub tool: Option<String>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ACTIVE: RefCell<Option<Rc<RefCell<Drive>>>> =
|
||||
const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
pub fn install(drive: Drive) -> Rc<RefCell<Drive>> {
|
||||
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<Exchange, HostError> {
|
||||
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<RefCell<Drive>>,
|
||||
request: Request,
|
||||
body: RefCell<Vec<u8>>,
|
||||
reply: RefCell<Option<Vec<u8>>>,
|
||||
}
|
||||
|
||||
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<Response, HostError> {
|
||||
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<Vec<u8>, 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<u8> {
|
||||
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<Value> =
|
||||
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<Value> {
|
||||
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<Vec<Value>> {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<header> }
|
||||
/// Response head. The body is read from the exchange that produced it.
|
||||
record response { status: u16, headers: list<header> }
|
||||
/// 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<u8>) -> result<_, host-error>;
|
||||
/// Ends the request body and waits for the response head. Calling it
|
||||
/// twice is invalid-input.
|
||||
respond: func() -> result<response, host-error>;
|
||||
/// 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<list<u8>, 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<exchange, host-error>;
|
||||
|
||||
/// 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<list<u8>, host-error>;
|
||||
/// Writes at most 1,048,576 bytes and returns how many were accepted.
|
||||
write: func(data: list<u8>) -> result<u32, host-error>;
|
||||
}
|
||||
/// 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<tcp, host-error>;
|
||||
|
||||
/// One datagram socket connected to a single peer.
|
||||
resource udp {
|
||||
/// Sends one datagram of at most 65,507 bytes.
|
||||
send: func(data: list<u8>) -> result<u32, host-error>;
|
||||
/// 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<list<u8>, 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<udp, host-error>;
|
||||
|
||||
/// 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<setting>;
|
||||
|
||||
/// 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<revision>,
|
||||
}
|
||||
/// 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<entry>, cursor: option<string> }
|
||||
/// 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<change>, cursor: option<string> }
|
||||
|
||||
/// 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<list<u8>, 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<revision>;
|
||||
}
|
||||
/// 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<u8>) -> 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<option<file-info>, 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<tuple<string, setting>>) -> result<capabilities, error-code>;
|
||||
/// 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<file-info, error-code>;
|
||||
/// Enumerates 1-256 direct children per request, without recursive traversal.
|
||||
%list: func(target: locator, cursor: option<string>, limit: u32) -> result<list-page, error-code>;
|
||||
/// 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<reader, error-code>;
|
||||
/// 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<u64>) -> result<writer, error-code>;
|
||||
/// Creates one directory if the mkdir capability is present.
|
||||
mkdir: func(target: locator) -> result<file-info, error-code>;
|
||||
/// Renames within this connection if the rename capability is present.
|
||||
rename: func(target: locator, destination: string) -> result<file-info, error-code>;
|
||||
/// 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<string>) -> result<change-page, error-code>;
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user