Open an aindrive drive as a read-only folder in Memona
Agents / bot (pull_request) Has been skipped
CI / checks (pull_request) Failing after 1m21s

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:
2026-10-12 22:51:35 +09:00
parent 4ef9b217fa
commit 3c2cd123b7
17 changed files with 2536 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
warn-on-all-wildcard-imports = true
+49
View File
@@ -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 }}
+51
View File
@@ -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
'
+1
View File
@@ -0,0 +1 @@
/target/
+3
View File
@@ -0,0 +1,3 @@
style_edition = "2024"
max_width = 80
tab_spaces = 2
Generated
+364
View File
@@ -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
View File
@@ -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
+65 -2
View File
@@ -2,5 +2,68 @@
Opens an [aindrive](https://github.com/ainetwork-ai/aindrive) drive as a
folder inside Memona, so agents and the file manager work in it like any local
directory, from any machine. It reaches the drive through aindrive's remote MCP
endpoint with the drive's access token.
directory, from any machine. The plugin is a WASI component: it speaks no
sockets of its own and makes every request through the host's HTTP interface,
to the drive's remote MCP endpoint, with the drive's access token.
It registers one provider, `aindrive`, for the `aindrive` scheme.
This package requires a Memona version that supports plugin manifest schema 2.
## Connecting
In aindrive, open the drive and choose **MCP** in its sidebar. Copy the
**Server URL**, then under **Option B · Access token** generate a token and
copy it; aindrive shows it only once.
| Field | Meaning |
| --- | --- |
| Server URL | The drive's MCP server URL, for example `https://aindrive.ainetwork.ai/mcp/d/AbCdEf123456`. The drive's own page address, `…/d/AbCdEf123456`, works too, as does a self-hosted server. |
| Access token | The `aind_pat_…` token, stored as a secret |
Saving the connection checks the address and the token. It does not need the
machine that shares the drive to be online, but its files only answer while
that machine runs aindrive; until then they report the folder unavailable.
## What works
Browsing folders and reading files. aindrive reads a file whole, at most
8 MiB at a time, so a larger file is refused as unsupported rather than cut
short. A read is checked against the size the drive reported a moment before,
so a file that changes in between is a conflict rather than a mix of two
versions. Names the drive's machine allows but Memona cannot show, such as one
containing a backslash, are left out of listings.
Saving, creating folders, renaming and deleting are not supported yet, and
neither is change polling.
## Building
```sh
rustup target add wasm32-wasip2
cargo build --locked --release --target wasm32-wasip2
```
The component lands at
`target/wasm32-wasip2/release/memona_plugin_aindrive.wasm`.
`wit/filesystem.wit` is a copy of the canonical Memona filesystem contract, and
`src/sdk.rs` generates the guest bindings from it, so this repository builds
without a Memona checkout. Refresh the copy from `plugin_system/sdk/wit` in the
[Memona repository](https://git.capsulizers.com/lead/memona) when the contract
moves, and keep `apiVersion` in `manifest.json` in step.
## Packaging for the plugin store
A `.memona-plugin` package is a ZIP archive with the files at its root, never
inside a directory:
```sh
cargo build --locked --release --target wasm32-wasip2
cp target/wasm32-wasip2/release/memona_plugin_aindrive.wasm plugin.wasm
zip memona-plugin-aindrive.memona-plugin \
manifest.json plugin.wasm README.md LICENSE
```
`manifest.json` and `plugin.wasm` are required; `README.md` becomes the guide on
the plugin's public page and `LICENSE` is shown beside it.
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+287
View File
@@ -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());
}
+258
View File
@@ -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(),
)
}
}
+200
View File
@@ -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;
}