Open an aindrive drive as a read-only folder in Memona
The plugin reaches a drive through aindrive's remote MCP endpoint with the drive's access token. Opening a connection asks which tools the token may call, so it works while the drive's machine sleeps. Listing, stat and reading map to list_files, stat and read_file; a read is statted first, and a file whose length changed in between is refused as a conflict. Reads decode only the window asked for, straight from the reply pieces, so any 1 MiB slice of a file up to aindrive's 8 MiB limit fits Memona's per-call fuel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
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
|
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
|
directory, from any machine. The plugin is a WASI component: it speaks no
|
||||||
endpoint with the drive's access token.
|
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