From 2b33d9f6f043c08d79d03ceabdd2a03af6fdfb28 Mon Sep 17 00:00:00 2001
From: RoyLin
Date: Sun, 19 Jul 2026 05:29:13 +0800
Subject: [PATCH 1/9] feat(ocr): add built-in first-use capability
---
.github/workflows/release.yml | 48 +-
Cargo.lock | 20 +
Cargo.toml | 7 +-
README.md | 117 +++-
.../skills/a3s-use-browser/SKILL.md | 5 +-
crates/browser-driver/src/mcp.rs | 4 +
crates/extension/src/lib.rs | 5 +-
crates/ocr/Cargo.toml | 34 +
crates/ocr/README.md | 34 +
crates/ocr/skills/a3s-use-ocr/SKILL.md | 48 ++
crates/ocr/src/cli.rs | 205 ++++++
crates/ocr/src/client.rs | 649 ++++++++++++++++++
crates/ocr/src/lib.rs | 18 +
crates/ocr/src/main.rs | 39 ++
crates/ocr/src/mcp.rs | 170 +++++
crates/ocr/src/models.rs | 105 +++
crates/ocr/src/provider.rs | 393 +++++++++++
crates/office/skills/a3s-use-office/SKILL.md | 25 +-
.../skills/a3s-use-office/references/mcp.md | 15 +-
docs/architecture.md | 43 +-
src/capability_registry.rs | 154 ++++-
src/cli.rs | 88 ++-
src/cli_tests.rs | 50 +-
src/lib.rs | 6 +
src/mcp.rs | 131 +++-
src/mcp/office.rs | 96 ++-
src/mcp/office/tests.rs | 26 +-
src/ocr_builtin.rs | 95 +++
tests/cli.rs | 77 ++-
29 files changed, 2608 insertions(+), 99 deletions(-)
create mode 100644 crates/ocr/Cargo.toml
create mode 100644 crates/ocr/README.md
create mode 100644 crates/ocr/skills/a3s-use-ocr/SKILL.md
create mode 100644 crates/ocr/src/cli.rs
create mode 100644 crates/ocr/src/client.rs
create mode 100644 crates/ocr/src/lib.rs
create mode 100644 crates/ocr/src/main.rs
create mode 100644 crates/ocr/src/mcp.rs
create mode 100644 crates/ocr/src/models.rs
create mode 100644 crates/ocr/src/provider.rs
create mode 100644 src/ocr_builtin.rs
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index dfee133e..0116089b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -42,7 +42,7 @@ jobs:
test "${{ inputs.release_tag }}" = "v${version}"
fi
- run: cargo fmt --all -- --check
- - run: cargo test --workspace --all-features --locked
+ - run: cargo test --workspace --all-features --locked -- --test-threads=1
- run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
binaries:
@@ -84,12 +84,13 @@ jobs:
version="${{ needs.validate.outputs.version }}"
archive="a3s-use-${version}-${{ matrix.name }}.tar.gz"
stage="${RUNNER_TEMP}/a3s-use-${version}-${{ matrix.name }}"
- install -d "${stage}/skills" "${stage}/skill-data" "${stage}/office-skills" "${stage}/dashboard"
+ install -d "${stage}/skills" "${stage}/skill-data" "${stage}/office-skills" "${stage}/ocr-skills" "${stage}/dashboard"
install -m 0755 "target/${{ matrix.target }}/release/a3s-use" "${stage}/a3s-use"
install -m 0755 "target/${{ matrix.target }}/release/a3s-use-browser-driver" "${stage}/a3s-use-browser-driver"
cp -R crates/browser-driver/skills/. "${stage}/skills/"
cp -R crates/browser-driver/skill-data/. "${stage}/skill-data/"
cp -R crates/office/skills/. "${stage}/office-skills/"
+ cp -R crates/ocr/skills/. "${stage}/ocr-skills/"
cp -R crates/browser-driver/dashboard/out/. "${stage}/dashboard/"
install -m 0644 LICENSE README.md THIRD_PARTY_NOTICES.md "${stage}/"
install -m 0644 crates/browser-driver/LICENSE-APACHE-2.0 "${stage}/LICENSE-APACHE-2.0"
@@ -110,6 +111,7 @@ jobs:
Copy-Item -Recurse "crates/browser-driver/skills" "$stage/skills"
Copy-Item -Recurse "crates/browser-driver/skill-data" "$stage/skill-data"
Copy-Item -Recurse "crates/office/skills" "$stage/office-skills"
+ Copy-Item -Recurse "crates/ocr/skills" "$stage/ocr-skills"
Copy-Item -Recurse "crates/browser-driver/dashboard/out" "$stage/dashboard"
Copy-Item LICENSE,README.md,THIRD_PARTY_NOTICES.md $stage
Copy-Item crates/browser-driver/LICENSE-APACHE-2.0 "$stage/LICENSE-APACHE-2.0"
@@ -128,6 +130,7 @@ jobs:
test -x "${install_root}/a3s-use-browser-driver"
test -f "${install_root}/skill-data/core/SKILL.md"
test -f "${install_root}/office-skills/a3s-use-office/SKILL.md"
+ test -f "${install_root}/ocr-skills/a3s-use-ocr/SKILL.md"
test -f "${install_root}/dashboard/index.html"
test -f "${install_root}/LICENSE-APACHE-2.0"
test -f "${install_root}/UPSTREAM.md"
@@ -143,6 +146,29 @@ jobs:
"agentcore", "core", "dogfood", "electron", "slack", "vercel-sandbox"
}
PY
+ "${install_root}/a3s-use" ocr doctor --json > "${RUNNER_TEMP}/ocr-doctor.json"
+ python3 - "${RUNNER_TEMP}/ocr-doctor.json" <<'PY'
+ import json, pathlib, sys
+ value = json.loads(pathlib.Path(sys.argv[1]).read_text())
+ assert value["ok"] is True
+ assert value["data"]["readiness"] in {"ready", "missing", "broken", "unknown"}
+ PY
+ "${install_root}/a3s-use" capability snapshot --json > "${RUNNER_TEMP}/capabilities.json"
+ python3 - "${RUNNER_TEMP}/capabilities.json" "${install_root}" <<'PY'
+ import json, pathlib, sys
+ value = json.loads(pathlib.Path(sys.argv[1]).read_text())
+ root = pathlib.Path(sys.argv[2]).resolve()
+ ocr = next(
+ capability
+ for capability in value["data"]["registry"]["capabilities"]
+ if capability["id"] == "use/ocr"
+ )
+ assert ocr["mcp"]["target"] == "ocr-native"
+ assert len(ocr["skills"]) == 1
+ assert pathlib.Path(ocr["skills"][0]["path"]).resolve() == (
+ root / "ocr-skills" / "a3s-use-ocr" / "SKILL.md"
+ )
+ PY
A3S_OFFICECLI_EXECUTABLE="${install_root}/must-not-be-invoked" \
"${install_root}/a3s-use" office skills list --json > "${RUNNER_TEMP}/office-skills.json"
python3 - "${RUNNER_TEMP}/office-skills.json" <<'PY'
@@ -179,6 +205,7 @@ jobs:
"$root/a3s-use-browser-driver.exe",
"$root/skill-data/core/SKILL.md",
"$root/office-skills/a3s-use-office/SKILL.md",
+ "$root/ocr-skills/a3s-use-ocr/SKILL.md",
"$root/dashboard/index.html",
"$root/LICENSE-APACHE-2.0",
"$root/UPSTREAM.md",
@@ -194,6 +221,19 @@ jobs:
if (-not $officeSkills.ok -or $officeSkills.data.Count -ne 1 -or $officeSkills.data[0].name -ne "a3s-use-office") {
throw "Packaged Office Skill smoke failed"
}
+ $ocr = (& "$root/a3s-use.exe" ocr doctor --json | ConvertFrom-Json)
+ if (-not $ocr.ok -or -not $ocr.data.readiness) { throw "Built-in OCR doctor smoke failed" }
+ $capabilities = (& "$root/a3s-use.exe" capability snapshot --json | ConvertFrom-Json)
+ $ocrCapability = $capabilities.data.registry.capabilities |
+ Where-Object id -eq "use/ocr"
+ if ($ocrCapability.mcp.target -ne "ocr-native" -or $ocrCapability.skills.Count -ne 1) {
+ throw "Built-in OCR capability projection smoke failed"
+ }
+ $expectedOcrSkill = (Resolve-Path "$root/ocr-skills/a3s-use-ocr/SKILL.md").Path
+ $actualOcrSkill = (Resolve-Path $ocrCapability.skills[0].path).Path
+ if (-not [StringComparer]::OrdinalIgnoreCase.Equals($actualOcrSkill, $expectedOcrSkill)) {
+ throw "Built-in OCR Skill was not projected from the installed release"
+ }
$requests = @(
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"release-smoke","version":"1"}}}',
'{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}',
@@ -223,7 +263,7 @@ jobs:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }}
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- - name: Publish Core then Browser
+ - name: Publish Core, OCR, then Browser
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_TOKEN }}
VERSION: ${{ needs.validate.outputs.version }}
@@ -262,6 +302,8 @@ jobs:
publish_once a3s-use-core
wait_until_visible a3s-use-core
+ publish_once a3s-use-ocr
+ wait_until_visible a3s-use-ocr
publish_once a3s-use-browser
release:
diff --git a/Cargo.lock b/Cargo.lock
index 14cb1eb3..80e0f846 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -14,6 +14,7 @@ dependencies = [
"a3s-use-browser",
"a3s-use-core",
"a3s-use-extension",
+ "a3s-use-ocr",
"a3s-use-office",
"anyhow",
"async-trait",
@@ -115,6 +116,25 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "a3s-use-ocr"
+version = "0.1.1"
+dependencies = [
+ "a3s-use-core",
+ "axum",
+ "base64",
+ "clap",
+ "reqwest",
+ "rmcp",
+ "schemars",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "tempfile",
+ "tokio",
+ "url",
+]
+
[[package]]
name = "a3s-use-office"
version = "0.1.1"
diff --git a/Cargo.toml b/Cargo.toml
index a136a13b..8c17bb9f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,6 +5,7 @@ members = [
"crates/browser-driver",
"crates/office",
"crates/extension",
+ "crates/ocr",
]
resolver = "2"
@@ -48,7 +49,7 @@ license.workspace = true
repository.workspace = true
authors.workspace = true
rust-version = "1.85"
-description = "Typed Browser, Office, and external application capabilities for A3S"
+description = "Typed Browser, Office, OCR, and external application capabilities for A3S"
[lib]
name = "a3s_use"
@@ -59,7 +60,7 @@ name = "a3s-use"
path = "src/main.rs"
[features]
-default = ["browser", "office", "extensions", "mcp"]
+default = ["browser", "office", "ocr", "extensions", "mcp"]
browser = ["dep:a3s-use-browser"]
office = [
"dep:a3s-use-office",
@@ -67,6 +68,7 @@ office = [
"dep:futures-util",
"dep:getrandom",
]
+ocr = ["dep:a3s-use-ocr"]
extensions = ["dep:a3s-use-extension"]
mcp = [
"dep:axum",
@@ -84,6 +86,7 @@ lightpanda = ["browser", "a3s-use-browser/lightpanda"]
a3s-use-core = { version = "0.1.1", path = "crates/core" }
a3s-use-browser = { version = "0.1.1", path = "crates/browser", optional = true }
a3s-use-office = { version = "0.1.1", path = "crates/office", optional = true }
+a3s-use-ocr = { version = "0.1.1", path = "crates/ocr", optional = true }
a3s-use-extension = { version = "0.1.1", path = "crates/extension", optional = true }
anyhow.workspace = true
axum = { workspace = true, optional = true }
diff --git a/README.md b/README.md
index 9b8376a1..5b4916fa 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
- Use browsers, Office documents, and independently shipped application domains through native CLI, standard MCP, and Skills
+ Use browsers, Office documents, OCR, and independently shipped application domains through native CLI, standard MCP, and Skills
@@ -14,6 +14,7 @@
Quick Start •
Browser •
Office •
+ OCR •
Extensions •
Architecture •
Development
@@ -23,10 +24,10 @@
## Overview
-**A3S Use** is the application-capability layer for A3S. Browser and Office are
-first-party domains in the default distribution. Independently distributed
-packages can add more domains without rebuilding Use by declaring native CLI,
-standard MCP, and/or `SKILL.md` surfaces in an A3S ACL manifest.
+**A3S Use** is the application-capability layer for A3S. Browser, native Office,
+and OCR are first-party domains in the default distribution. Independently
+distributed packages can add more domains without rebuilding Use by declaring
+native CLI, standard MCP, and/or `SKILL.md` surfaces in an A3S ACL manifest.
The primary user entry point is `a3s use`; `a3s-use` is the standalone binary
used by the umbrella CLI and remains available for direct use, automation, and
@@ -95,7 +96,14 @@ a3s use mcp serve browser
a3s use mcp serve office-native
# Keep using the pinned OfficeCLI compatibility MCP server where needed.
+a3s use mcp serve office-compat
+# Legacy alias:
a3s use mcp serve office
+
+# Built-in OCR; provider readiness remains explicit.
+a3s use ocr doctor --json
+a3s use ocr extract ./scan.png --language eng --json
+a3s use mcp serve ocr
```
Every domain argument accepted by `a3s use ...` can also be passed directly to
@@ -103,8 +111,8 @@ Every domain argument accepted by `a3s use ...` can also be passed directly to
## Features
-- **Built-In Browser and Office**: Keep stable first-party command routes while
- reporting provider readiness separately
+- **Built-In Browser, Office, and OCR**: Keep stable first-party command routes
+ while reporting provider readiness separately
- **Typed Rust Contracts**: Embed Browser rendering and Office operations
without starting a CLI process or an MCP server
- **Agent Browser Compatibility**: Provide the locked 82-command vocabulary,
@@ -129,6 +137,9 @@ Every domain argument accepted by `a3s use ...` can also be passed directly to
safe Word, Spreadsheet, Presentation, native MCP, and compatibility workflows
- **External Domains**: Install process-isolated packages that expose any useful
combination of CLI, MCP, and Skill surfaces
+- **First-Party OCR Domain**: Extract text and bounded layout evidence with
+ a local Tesseract provider or an explicitly configured vision endpoint,
+ without silently installing a provider or hiding remote image transfer
- **Hot-Plug Discovery**: Publish immutable generation/revision snapshots so a
resident host can add, replace, or remove live capabilities without restarting
- **Content-Bound Skills**: Project an absolute package path and lowercase
@@ -149,6 +160,7 @@ Every domain argument accepted by `a3s use ...` can also be passed directly to
| Browser | Built in | Full Browser vocabulary | A3S Use standard MCP server | Six packaged Browser Skills | A3S Use |
| Office | Built in | Stable Office vocabulary | Typed native preview plus OfficeCLI compatibility server | Packaged `a3s-use-office` Skill | A3S Use native engine; OfficeCLI compatibility in 0.1.x |
| Box | Reserved built-in route | Native A3S Box vocabulary | — | — | Umbrella A3S CLI |
+| OCR | Built in | Doctor and typed image extraction | `ocr_doctor` and `ocr_extract` | One provider-safe OCR Skill | A3S Use process and explicitly configured provider |
| External domain | Installed extension | Optional native executable | Optional standard MCP server | Optional `SKILL.md` | Extension package plus A3S Use lifecycle |
The Box route is component-backed. The umbrella CLI resolves its authoritative
@@ -157,12 +169,13 @@ does not copy Box, discover a replacement on `PATH`, or write a second receipt.
### Cargo feature matrix
-Default features are `browser`, `office`, `extensions`, and `mcp`.
+Default features are `browser`, `office`, `ocr`, `extensions`, and `mcp`.
| Feature | Included capability |
| --- | --- |
| `browser` | Typed Browser library, stateless rendering, and full Browser driver delegation |
| `office` | Typed Office contracts, native OOXML read engine, and temporary OfficeCLI compatibility |
+| `ocr` | Built-in typed OCR CLI/MCP with local Tesseract and explicit vision providers |
| `extensions` | ACL manifests, package receipts, hot-plug registry, and external CLI/MCP/Skill routes |
| `mcp` | Standard MCP servers plus the managed Browser Streamable HTTP lifecycle |
| `lightpanda` | Explicit opt-in Lightpanda provider support in addition to Chrome |
@@ -179,6 +192,7 @@ A compiled command surface is not proof that its provider is installed. Use
| `a3s-use-browser-driver` | Complete interactive Browser CLI, MCP tools, Skills, Dashboard, and compatibility runtime |
| `a3s-use-office` | Native OOXML foundation, typed Office operations, and compatibility lifecycle |
| `a3s-use-extension` | A3S ACL manifest model, package registry, leases, and native surface descriptors |
+| `a3s-use-ocr` | Typed local/vision OCR providers, CLI, MCP tools, and release-packaged Skill assets |
| `a3s-use` | Facade library, standalone CLI host, capability projection, and MCP entry points |
## Quick Start
@@ -198,9 +212,9 @@ a3s use doctor --json
Prebuilt archives are also published on
[GitHub Releases](https://github.com/A3S-Lab/Use/releases). A complete archive
contains `a3s-use`, its sibling `a3s-use-browser-driver`, Browser Skills, the
-first-party Office Skill, the Dashboard, and license/provenance notices. Keep
-those packaged assets together; installing only the facade binary does not
-provide the complete Browser and Office Skill surfaces.
+first-party Office and OCR Skills, the Dashboard, and license/provenance
+notices. Keep those packaged assets together; installing only the facade binary
+does not provide the complete Browser, Office, and OCR Skill surfaces.
Build all binaries from source with:
@@ -515,7 +529,10 @@ agents without starting OfficeCLI. Discover its metadata with
`office skills get a3s-use-office`, append its four format/MCP references with
`--full`, or locate the installed directory with `office skills path`. The
capability snapshot binds the Skill path and lowercase SHA-256 so a resident
-host can verify the bytes before loading them.
+host can verify the bytes before loading them. Resident Code hosts receive the
+native engine as canonical route `use/office` targeting `office-native`; a ready
+OfficeCLI installation is projected separately as `use/office-compat` targeting
+`office-compat`.
Other `0.1.x` commands and the default `mcp serve office` target still use a
compatibility backend pinned to OfficeCLI `1.0.136`. This is a migration
boundary, not a native-promotion claim. The default routes will be promoted
@@ -1586,6 +1603,34 @@ compatibility response can return
See [Native Office Engine](docs/native-office.md) for the complete requirements,
compatibility scope, safety invariants, delivery gates, and migration plan.
+## OCR
+
+`a3s-use-ocr` implements the reserved built-in `ocr` route. The default Use
+release packages its `a3s-use-ocr` Skill and exposes `ocr_doctor` plus
+`ocr_extract` over standard MCP, so a resident A3S Code session receives
+`mcp__use_ocr__*` without installing a separate extension.
+
+OCR never installs a provider silently. `auto` prefers an explicitly configured
+or discoverable Tesseract executable. Vision OCR is enabled only when its model
+and endpoint configuration are present; non-loopback endpoints require HTTPS
+and an API key, and the diagnostic discloses that the complete source image
+leaves the device. Supported inputs are bounded local PNG, JPEG, WebP, GIF,
+BMP, and TIFF files. The result binds the canonical source path, media type,
+byte length, and SHA-256 alongside text and any available
+confidence/bounding-box evidence.
+
+```bash
+a3s use ocr doctor --json
+a3s use ocr extract ./scan.png --language eng --json
+a3s use mcp serve ocr
+```
+
+A3S Code may first-use install the verified parent Use release. OCR provider
+selection remains explicit, and remote vision extraction still escalates to
+the parent TUI before source bytes leave the device.
+
+See the [OCR crate](crates/ocr/README.md) for configuration and provider
+boundaries.
## External Extensions
External Use domains stay behind process boundaries. A package contains an
@@ -1636,15 +1681,17 @@ roadmap work; Use does not silently install arbitrary Homebrew, npm, Cargo,
system, or `PATH` packages.
Built-in and management routes are reserved. Extensions cannot shadow
-`browser`, `office`, `box`, `component`, `capability`, or other host commands.
+`browser`, `office`, `ocr`, `box`, `component`, `capability`, or other host
+commands.
## Live Host Integration
Resident hosts consume `capability snapshot` and `capability watch`. The
-projection presents Browser, Office, Box, and enabled extensions through one
-read-only schema while preserving each binding's `built-in` or `extension`
-origin. The extension generation advances on receipt mutations; a content
-revision also changes when built-in readiness or packaged Skill content changes.
+projection presents Browser, native Office, OCR, Box, and enabled extensions
+through one read-only schema while preserving each binding's `built-in` or
+`extension` origin. The extension generation advances on receipt mutations; a
+content revision also changes when built-in readiness or packaged Skill content
+changes.
```bash
a3s-use capability snapshot --json
@@ -1662,10 +1709,18 @@ tools. Projected Skills provide guidance only and cannot expand permissions or
authorize installation. Code verifies their projected SHA-256 before loading
the exact bytes.
+The built-in Office projection is intentionally host-oriented: `use/office`
+always exposes the in-process native MCP target when MCP support is compiled,
+without consulting OfficeCLI. A discovered OfficeCLI provider is a separate
+optional `use/office-compat` route, so native readiness and compatibility
+installation cannot mask or replace each other.
+
A capability becomes callable only after its MCP connection is ready. A
removed or replaced route leaves the worker catalog before its old connection
-drains. Starting Code never installs Use: component installation remains an
-explicit umbrella CLI action.
+drains. Code TUI resolves the catalogued Use component on first launch and may
+install its verified release before terminal takeover. Offline mode and
+`A3S_NO_AUTO_INSTALL=1` remain strict no-mutation boundaries; setup failure is
+non-fatal and stays visible through `/use`.
## Protocol and Lifecycle Boundaries
@@ -1702,13 +1757,13 @@ crash, and in-flight calls retain the exact package generation they accepted.
a3s use
│
a3s-use host
- ┌─────────────┼──────────────┐
- │ │ │
- Browser Office extension registry
- typed + driver native OOXML CLI / MCP / Skill
- + 0.1 compat
- │ │ │
- └──────── capability snapshot/watch ───────► A3S Code
+ ┌──────────┬──────────┬──────────┬──────────────┐
+ │ │ │ │ │
+ Browser Office OCR extension registry
+ typed + driver OOXML local/vision CLI / MCP / Skill
+ + 0.1 compat
+ │ │ │ │
+ └──────── capability snapshot/watch ───────────► A3S Code
a3s-search ── Arc ──► a3s-use-browser
@@ -1717,10 +1772,12 @@ crash, and in-flight calls retain the exact package generation they accepted.
The dependency arrows are intentional. Search links only the Browser contract,
so rendering does not require `a3s-use`, MCP, or a resident process. Office is
-an in-process typed engine with a temporary 0.1.x compatibility process;
-external domains retain their process boundaries. A3S Code consumes the
-read-only projection and connects standard MCP/Skill surfaces; it does not gain
-component installation authority.
+an in-process typed engine with a temporary 0.1.x compatibility process. OCR
+uses an explicitly present local Tesseract executable or an explicitly
+configured vision provider; it never installs either silently. External
+domains retain their process boundaries. A3S Code consumes the read-only
+projection and connects standard MCP/Skill surfaces; bounded provider
+installation requests still require the parent TUI's authority.
Source is split between the facade under `src/` and focused workspace crates
under `crates/`. See [Architecture](docs/architecture.md) for package leases,
diff --git a/crates/browser-driver/skills/a3s-use-browser/SKILL.md b/crates/browser-driver/skills/a3s-use-browser/SKILL.md
index 072d4ef5..ee03a73a 100644
--- a/crates/browser-driver/skills/a3s-use-browser/SKILL.md
+++ b/crates/browser-driver/skills/a3s-use-browser/SKILL.md
@@ -10,7 +10,10 @@ Use the host surface that is already available:
- In an A3S Code `use` worker, call the available
`mcp__use_browser__*` tools directly. The host owns installation and MCP
- lifecycle; do not run component installation or shell commands there.
+ lifecycle; do not run component installation or shell commands there. Call
+ `mcp__use_browser__agent_browser_doctor` first. If its managed browser is
+ missing, request `mcp__use_browser__agent_browser_install`; the parent TUI
+ must obtain HITL approval before that mutation can run.
- In a CLI-only agent host, use the `a3s use browser ...` commands below.
Install the built-in capability and its managed runtime when needed:
diff --git a/crates/browser-driver/src/mcp.rs b/crates/browser-driver/src/mcp.rs
index 99fb2a30..c1585c07 100644
--- a/crates/browser-driver/src/mcp.rs
+++ b/crates/browser-driver/src/mcp.rs
@@ -352,6 +352,8 @@ const CORE_PROFILE_TOOLS: &[&str] = &[
TOOL_TAB_CLOSE,
TOOL_EVAL,
TOOL_CLOSE,
+ TOOL_DOCTOR,
+ TOOL_INSTALL,
];
const NETWORK_PROFILE_TOOLS: &[&str] = &[
@@ -3744,6 +3746,8 @@ mod tests {
assert!(names.contains(&TOOL_SNAPSHOT));
assert!(names.contains(&TOOL_CLICK));
assert!(names.contains(&TOOL_SCREENSHOT));
+ assert!(names.contains(&TOOL_DOCTOR));
+ assert!(names.contains(&TOOL_INSTALL));
assert!(names.contains(&TOOL_GET_CDP_URL));
assert!(names.contains(&TOOL_NETWORK_HAR_START));
assert!(names.contains(&TOOL_REACT_SUSPENSE));
diff --git a/crates/extension/src/lib.rs b/crates/extension/src/lib.rs
index ffc918a5..cc76e2dc 100644
--- a/crates/extension/src/lib.rs
+++ b/crates/extension/src/lib.rs
@@ -23,6 +23,9 @@ const RESERVED_ROUTES: &[&str] = &[
"box",
"capability",
"office",
+ "office-compat",
+ "office-native",
+ "ocr",
"capabilities",
"component",
"extension",
@@ -437,7 +440,7 @@ extension "acme/slack" {
#[test]
fn rejects_reserved_routes() {
- for route in ["browser", "box"] {
+ for route in ["browser", "box", "ocr"] {
let manifest = MANIFEST.replace(
"route = \"slack\"",
&format!("route = \"{route}\""),
diff --git a/crates/ocr/Cargo.toml b/crates/ocr/Cargo.toml
new file mode 100644
index 00000000..4a5f58aa
--- /dev/null
+++ b/crates/ocr/Cargo.toml
@@ -0,0 +1,34 @@
+[package]
+name = "a3s-use-ocr"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+authors.workspace = true
+rust-version.workspace = true
+description = "Typed built-in optical character recognition for A3S Use"
+
+[lib]
+name = "a3s_use_ocr"
+path = "src/lib.rs"
+
+[[bin]]
+name = "a3s-use-ocr"
+path = "src/main.rs"
+
+[dependencies]
+a3s-use-core = { version = "0.1.1", path = "../core" }
+base64.workspace = true
+clap.workspace = true
+reqwest = { workspace = true, features = ["json"] }
+rmcp.workspace = true
+schemars.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+sha2.workspace = true
+tokio.workspace = true
+url.workspace = true
+
+[dev-dependencies]
+axum.workspace = true
+tempfile.workspace = true
diff --git a/crates/ocr/README.md b/crates/ocr/README.md
new file mode 100644
index 00000000..d61b4096
--- /dev/null
+++ b/crates/ocr/README.md
@@ -0,0 +1,34 @@
+# A3S Use OCR
+
+`a3s-use-ocr` implements the first-party built-in OCR domain for A3S Use. A3S
+Code receives it as `mcp__use_ocr__*` through the release-matched Use registry,
+without a separate extension install. It exposes the same typed extraction
+through a native CLI and standard stdio MCP, and does not silently install an
+OCR provider.
+
+Provider selection is explicit:
+
+- `A3S_OCR_PROVIDER=auto|tesseract|vision`
+- `A3S_OCR_TESSERACT_EXECUTABLE=/absolute/path/to/tesseract`
+- `A3S_OCR_VISION_MODEL=`
+- `A3S_OCR_VISION_BASE_URL=https://provider.example/v1/`
+- `A3S_OCR_VISION_API_KEY=`
+- `A3S_OCR_TIMEOUT_MS=60000`
+
+`auto` prefers a configured or discoverable local Tesseract executable. It uses
+the vision provider only when the vision environment is configured. Remote
+vision endpoints require HTTPS and an API key; loopback HTTP is allowed for a
+local provider.
+
+Build and exercise the domain through the Use facade:
+
+```bash
+a3s use ocr doctor --json
+a3s use ocr extract ./scan.png --language eng --json
+a3s use mcp serve ocr
+```
+
+The A3S Use release packages the OCR Skill beside the facade binary. A3S Code
+can first-use install that verified release and hot-plug the built-in route.
+Provider setup remains explicit: local Tesseract never sends source bytes
+off-device, while a configured remote vision provider requires parent HITL.
diff --git a/crates/ocr/skills/a3s-use-ocr/SKILL.md b/crates/ocr/skills/a3s-use-ocr/SKILL.md
new file mode 100644
index 00000000..9ca0950b
--- /dev/null
+++ b/crates/ocr/skills/a3s-use-ocr/SKILL.md
@@ -0,0 +1,48 @@
+---
+name: a3s-use-ocr
+description: Extract text and layout evidence from local image files through the built-in A3S Use OCR domain. Use when an agent needs optical character recognition for a PNG, JPEG, WebP, GIF, BMP, or TIFF image and must preserve the source digest, provider disclosure, confidence, and bounding-box evidence.
+---
+
+# A3S Use OCR
+
+Use the host-provided A3S Use surface. In an A3S Code `use` worker, call
+`mcp__use_ocr__ocr_doctor` and `mcp__use_ocr__ocr_extract` directly. The host
+owns the MCP process; do not run a shell command, install a provider, or read the
+file through another tool.
+
+## Workflow
+
+1. Call `mcp__use_ocr__ocr_doctor`.
+2. Confirm which provider is ready and whether `sendsSourceOffDevice` is true.
+3. Call `mcp__use_ocr__ocr_extract` with the exact local image path from the
+ task. Supply language identifiers only when known.
+4. Preserve the returned source path, media type, size, and SHA-256 in the
+ result. Treat text, confidence, and bounding boxes as OCR evidence, not as a
+ verified transcription.
+
+The local Tesseract provider does not send the image over the network. The
+vision provider sends the complete source image and prompt to its disclosed
+endpoint. Do not use a non-loopback vision provider unless the user has
+authorized that data transfer. Never install, repair, or switch providers from
+inside the `use` worker.
+
+In a CLI-only host, equivalent commands are:
+
+```bash
+a3s use ocr doctor --json
+a3s use ocr extract "$IMAGE" --language eng --json
+```
+
+`a3s-use-ocr` accepts the same arguments when invoked as a standalone
+development binary.
+
+## Boundaries
+
+- Only bounded local image files are accepted. URLs and PDF rasterization are
+ outside this domain.
+- Keep the default prompt for faithful transcription. A custom vision prompt
+ must remain an extraction instruction; do not ask the provider to interpret
+ unrelated content.
+- Never report vision output as calibrated confidence or layout evidence.
+- Do not silently fall back from a requested provider. Report typed provider,
+ source, and response errors to the parent agent.
diff --git a/crates/ocr/src/cli.rs b/crates/ocr/src/cli.rs
new file mode 100644
index 00000000..ea76bcda
--- /dev/null
+++ b/crates/ocr/src/cli.rs
@@ -0,0 +1,205 @@
+use std::path::PathBuf;
+
+use a3s_use_core::{UseError, UseResult};
+use clap::error::ErrorKind;
+use clap::{Parser, Subcommand, ValueEnum};
+use serde::Serialize;
+
+use crate::{OcrClient, OcrMcpServer, OcrProviderKind, OcrRequest};
+
+#[derive(Debug)]
+pub struct CommandOutput {
+ pub human: String,
+ pub json: serde_json::Value,
+ pub exit_code: u8,
+ pub should_print: bool,
+}
+
+impl CommandOutput {
+ fn data(value: T) -> UseResult
+ where
+ T: Serialize,
+ {
+ let data = serde_json::to_value(value).map_err(output_error)?;
+ let human = serde_json::to_string_pretty(&data).map_err(output_error)?;
+ Ok(Self {
+ human,
+ json: serde_json::json!({
+ "schemaVersion": 1,
+ "ok": true,
+ "data": data,
+ }),
+ exit_code: 0,
+ should_print: true,
+ })
+ }
+
+ fn text(value: String) -> Self {
+ Self {
+ human: value.clone(),
+ json: serde_json::json!({
+ "schemaVersion": 1,
+ "ok": true,
+ "data": { "text": value },
+ }),
+ exit_code: 0,
+ should_print: true,
+ }
+ }
+
+ fn silent() -> Self {
+ Self {
+ human: String::new(),
+ json: serde_json::Value::Null,
+ exit_code: 0,
+ should_print: false,
+ }
+ }
+}
+
+#[derive(Debug, Parser)]
+#[command(
+ name = "a3s-use-ocr",
+ version,
+ about = "Typed built-in OCR for A3S Use",
+ arg_required_else_help = true
+)]
+struct Cli {
+ /// Emit one versioned JSON document.
+ #[arg(long, global = true)]
+ json: bool,
+
+ #[command(subcommand)]
+ command: Command,
+}
+
+#[derive(Debug, Subcommand)]
+enum Command {
+ /// Inspect provider readiness without reading an image.
+ Doctor,
+ /// Extract text and available layout evidence from one local image.
+ Extract {
+ path: PathBuf,
+ /// OCR language identifier; may be repeated.
+ #[arg(long = "language")]
+ languages: Vec,
+ /// Tesseract page segmentation mode from 0 through 13.
+ #[arg(long = "psm")]
+ page_segmentation_mode: Option,
+ /// Override the configured OCR provider for this call.
+ #[arg(long, value_enum)]
+ provider: Option,
+ /// Vision-only extraction instruction.
+ #[arg(long)]
+ prompt: Option,
+ },
+ /// Run an extension protocol surface.
+ Serve {
+ /// Serve standard MCP over stdin/stdout.
+ #[arg(long)]
+ mcp: bool,
+ },
+}
+
+#[derive(Debug, Clone, Copy, ValueEnum)]
+enum ProviderArg {
+ Auto,
+ Tesseract,
+ Vision,
+}
+
+impl From for OcrProviderKind {
+ fn from(value: ProviderArg) -> Self {
+ match value {
+ ProviderArg::Auto => Self::Auto,
+ ProviderArg::Tesseract => Self::Tesseract,
+ ProviderArg::Vision => Self::Vision,
+ }
+ }
+}
+
+pub async fn run(args: Vec) -> UseResult {
+ let mut argv = vec!["a3s-use-ocr".to_string()];
+ argv.extend(args);
+ let cli = match Cli::try_parse_from(argv) {
+ Ok(cli) => cli,
+ Err(error)
+ if matches!(
+ error.kind(),
+ ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
+ ) =>
+ {
+ return Ok(CommandOutput::text(error.to_string()));
+ }
+ Err(error) => return Err(usage_error(error.to_string())),
+ };
+
+ if let Command::Serve { mcp } = &cli.command {
+ if !mcp {
+ return Err(usage_error("serve requires --mcp"));
+ }
+ if cli.json {
+ return Err(usage_error("--json cannot be combined with serve --mcp"));
+ }
+ OcrMcpServer::from_env()?.serve_stdio().await?;
+ return Ok(CommandOutput::silent());
+ }
+
+ let client = OcrClient::from_env()?;
+ match cli.command {
+ Command::Doctor => CommandOutput::data(client.diagnostic()),
+ Command::Extract {
+ path,
+ languages,
+ page_segmentation_mode,
+ provider,
+ prompt,
+ } => CommandOutput::data(
+ client
+ .extract(OcrRequest {
+ path,
+ languages,
+ page_segmentation_mode,
+ provider: provider.map(Into::into),
+ prompt,
+ })
+ .await?,
+ ),
+ Command::Serve { .. } => Err(UseError::new(
+ "use.ocr.command_invalid",
+ "OCR MCP command dispatch reached an invalid state.",
+ )),
+ }
+}
+
+fn output_error(error: serde_json::Error) -> UseError {
+ UseError::new(
+ "use.ocr.output_invalid",
+ format!("Failed to encode OCR command output: {error}"),
+ )
+}
+
+fn usage_error(message: impl Into) -> UseError {
+ UseError::new("use.ocr.usage_invalid", message).with_suggestion("Run 'a3s use ocr --help'.")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn doctor_is_versioned_even_when_no_provider_is_ready() {
+ let output = run(vec!["doctor".to_string(), "--json".to_string()])
+ .await
+ .unwrap();
+ assert_eq!(output.json["schemaVersion"], 1);
+ assert_eq!(output.json["ok"], true);
+ assert!(output.json["data"]["readiness"].is_string());
+ }
+
+ #[tokio::test]
+ async fn serve_requires_an_explicit_protocol() {
+ let error = run(vec!["serve".to_string()]).await.unwrap_err();
+ assert_eq!(error.code, "use.ocr.usage_invalid");
+ }
+}
diff --git a/crates/ocr/src/client.rs b/crates/ocr/src/client.rs
new file mode 100644
index 00000000..21fb3494
--- /dev/null
+++ b/crates/ocr/src/client.rs
@@ -0,0 +1,649 @@
+use std::collections::BTreeMap;
+use std::path::Path;
+#[cfg(all(test, unix))]
+use std::path::PathBuf;
+use std::process::Stdio;
+
+use a3s_use_core::{Artifact, UseError, UseResult};
+use base64::Engine;
+use sha2::{Digest, Sha256};
+use tokio::io::AsyncReadExt;
+use tokio::process::Command;
+
+use crate::models::{OcrBlock, OcrBoundingBox, OcrProviderKind, OcrRequest, OcrResult};
+use crate::provider::{Provider, ProviderConfig};
+use crate::OcrDiagnostic;
+
+const MAX_INPUT_BYTES: u64 = 32 * 1024 * 1024;
+const MAX_PROVIDER_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
+const DEFAULT_VISION_PROMPT: &str = "Transcribe all visible text in reading order. Preserve line breaks and meaningful spacing. Return only the transcription; do not summarize, translate, or wrap it in Markdown.";
+
+#[derive(Clone)]
+pub struct OcrClient {
+ providers: ProviderConfig,
+ http: reqwest::Client,
+}
+
+impl OcrClient {
+ pub fn from_env() -> UseResult {
+ Self::from_provider_config(ProviderConfig::from_env()?)
+ }
+
+ fn from_provider_config(providers: ProviderConfig) -> UseResult {
+ let http = reqwest::Client::builder()
+ .user_agent(concat!("a3s-use-ocr/", env!("CARGO_PKG_VERSION")))
+ .build()
+ .map_err(|error| {
+ UseError::new(
+ "use.ocr.client_failed",
+ format!("Failed to initialize the OCR HTTP client: {error}"),
+ )
+ })?;
+ Ok(Self { providers, http })
+ }
+
+ #[cfg(all(test, unix))]
+ pub(crate) fn with_tesseract(executable: PathBuf) -> UseResult {
+ Self::from_provider_config(ProviderConfig::tesseract(executable))
+ }
+
+ pub fn diagnostic(&self) -> OcrDiagnostic {
+ self.providers.diagnostic()
+ }
+
+ pub async fn extract(&self, request: OcrRequest) -> UseResult {
+ validate_request(&request)?;
+ let source = read_source(&request.path).await?;
+ let provider = self
+ .providers
+ .resolve(request.provider.unwrap_or(OcrProviderKind::Auto))?;
+ let languages = if request.languages.is_empty() {
+ vec!["eng".to_string()]
+ } else {
+ request.languages.clone()
+ };
+
+ let (text, blocks, warnings) = match &provider {
+ Provider::Tesseract {
+ executable,
+ timeout,
+ } => {
+ let output = run_tesseract(
+ executable,
+ &source.artifact.path,
+ &languages,
+ request.page_segmentation_mode,
+ *timeout,
+ )
+ .await?;
+ let (text, blocks) = parse_tesseract_tsv(&output)?;
+ (text, blocks, Vec::new())
+ }
+ Provider::Vision {
+ endpoint,
+ api_key,
+ model,
+ timeout,
+ } => {
+ let text = self
+ .run_vision(
+ endpoint,
+ api_key.as_deref(),
+ model,
+ &source,
+ request.prompt.as_deref(),
+ *timeout,
+ )
+ .await?;
+ let blocks = (!text.is_empty())
+ .then(|| OcrBlock {
+ page: 1,
+ text: text.clone(),
+ confidence: None,
+ bounding_box: None,
+ })
+ .into_iter()
+ .collect();
+ (
+ text,
+ blocks,
+ vec![
+ "The vision provider does not return calibrated OCR confidence or bounding boxes."
+ .to_string(),
+ ],
+ )
+ }
+ };
+
+ Ok(OcrResult {
+ provider: provider.kind(),
+ source: source.artifact,
+ languages,
+ text,
+ blocks,
+ warnings,
+ })
+ }
+
+ async fn run_vision(
+ &self,
+ endpoint: &url::Url,
+ api_key: Option<&str>,
+ model: &str,
+ source: &SourceImage,
+ prompt: Option<&str>,
+ timeout: std::time::Duration,
+ ) -> UseResult {
+ let encoded = base64::engine::general_purpose::STANDARD.encode(&source.bytes);
+ let data_url = format!("data:{};base64,{encoded}", source.artifact.media_type);
+ let prompt = prompt
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .unwrap_or(DEFAULT_VISION_PROMPT);
+ let body = serde_json::json!({
+ "model": model,
+ "temperature": 0,
+ "messages": [{
+ "role": "user",
+ "content": [
+ { "type": "text", "text": prompt },
+ {
+ "type": "image_url",
+ "image_url": {
+ "url": data_url,
+ "detail": "high"
+ }
+ }
+ ]
+ }]
+ });
+ let mut request = self
+ .http
+ .post(endpoint.clone())
+ .timeout(timeout)
+ .json(&body);
+ if let Some(api_key) = api_key {
+ request = request.bearer_auth(api_key);
+ }
+ let response = request.send().await.map_err(|error| {
+ UseError::new(
+ "use.ocr.vision_request_failed",
+ format!("The vision OCR request failed: {error}"),
+ )
+ .with_detail("endpoint", redacted_endpoint(endpoint))
+ })?;
+ let status = response.status();
+ let bytes = response.bytes().await.map_err(|error| {
+ UseError::new(
+ "use.ocr.vision_response_invalid",
+ format!("Failed to read the vision OCR response: {error}"),
+ )
+ })?;
+ if bytes.len() > MAX_PROVIDER_OUTPUT_BYTES {
+ return Err(UseError::new(
+ "use.ocr.output_too_large",
+ "The vision OCR provider response exceeded 8 MiB.",
+ ));
+ }
+ if !status.is_success() {
+ let message = String::from_utf8_lossy(&bytes);
+ return Err(UseError::new(
+ "use.ocr.vision_request_failed",
+ format!(
+ "The vision OCR provider returned HTTP {status}: {}",
+ bounded_text(&message, 1024)
+ ),
+ )
+ .with_detail("status", u64::from(status.as_u16())));
+ }
+ let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
+ UseError::new(
+ "use.ocr.vision_response_invalid",
+ format!("The vision OCR provider returned invalid JSON: {error}"),
+ )
+ })?;
+ let content = value.pointer("/choices/0/message/content").ok_or_else(|| {
+ UseError::new(
+ "use.ocr.vision_response_invalid",
+ "The vision OCR response did not contain choices[0].message.content.",
+ )
+ })?;
+ let text = vision_content_text(content)?;
+ Ok(text.trim().to_string())
+ }
+}
+
+struct SourceImage {
+ artifact: Artifact,
+ bytes: Vec,
+}
+
+async fn read_source(path: &Path) -> UseResult {
+ let canonical = tokio::fs::canonicalize(path).await.map_err(|error| {
+ UseError::new(
+ "use.ocr.source_unreadable",
+ format!("Failed to resolve OCR source '{}': {error}", path.display()),
+ )
+ })?;
+ let metadata = tokio::fs::metadata(&canonical).await.map_err(|error| {
+ UseError::new(
+ "use.ocr.source_unreadable",
+ format!(
+ "Failed to inspect OCR source '{}': {error}",
+ canonical.display()
+ ),
+ )
+ })?;
+ if !metadata.is_file() {
+ return Err(UseError::new(
+ "use.ocr.source_invalid",
+ format!(
+ "OCR source '{}' is not a regular file.",
+ canonical.display()
+ ),
+ ));
+ }
+ if metadata.len() == 0 || metadata.len() > MAX_INPUT_BYTES {
+ return Err(UseError::new(
+ "use.ocr.source_too_large",
+ format!(
+ "OCR source '{}' must contain between 1 byte and 32 MiB.",
+ canonical.display()
+ ),
+ )
+ .with_detail("size", metadata.len()));
+ }
+ let file = tokio::fs::File::open(&canonical).await.map_err(|error| {
+ UseError::new(
+ "use.ocr.source_unreadable",
+ format!(
+ "Failed to open OCR source '{}': {error}",
+ canonical.display()
+ ),
+ )
+ })?;
+ let mut bytes = Vec::with_capacity(metadata.len().min(MAX_INPUT_BYTES) as usize);
+ file.take(MAX_INPUT_BYTES + 1)
+ .read_to_end(&mut bytes)
+ .await
+ .map_err(|error| {
+ UseError::new(
+ "use.ocr.source_unreadable",
+ format!(
+ "Failed to read OCR source '{}': {error}",
+ canonical.display()
+ ),
+ )
+ })?;
+ if bytes.len() as u64 > MAX_INPUT_BYTES {
+ return Err(UseError::new(
+ "use.ocr.source_too_large",
+ format!(
+ "OCR source '{}' must not exceed 32 MiB.",
+ canonical.display()
+ ),
+ )
+ .with_detail("sizeAtLeast", MAX_INPUT_BYTES + 1));
+ }
+ let media_type = detect_image_type(&bytes).ok_or_else(|| {
+ UseError::new(
+ "use.ocr.source_type_unsupported",
+ "OCR accepts PNG, JPEG, WebP, GIF, BMP, and TIFF image bytes.",
+ )
+ })?;
+ let digest = Sha256::digest(&bytes);
+ Ok(SourceImage {
+ artifact: Artifact {
+ path: canonical,
+ media_type: media_type.to_string(),
+ size: bytes.len() as u64,
+ sha256: format!("{digest:x}"),
+ },
+ bytes,
+ })
+}
+
+fn validate_request(request: &OcrRequest) -> UseResult<()> {
+ if request.languages.len() > 16 {
+ return Err(UseError::new(
+ "use.ocr.languages_invalid",
+ "At most 16 OCR language identifiers may be requested.",
+ ));
+ }
+ for language in &request.languages {
+ if language.is_empty()
+ || language.len() > 32
+ || !language
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
+ {
+ return Err(UseError::new(
+ "use.ocr.languages_invalid",
+ format!("OCR language identifier '{language}' is invalid."),
+ ));
+ }
+ }
+ if request.page_segmentation_mode.is_some_and(|mode| mode > 13) {
+ return Err(UseError::new(
+ "use.ocr.page_segmentation_invalid",
+ "Tesseract page segmentation mode must be from 0 through 13.",
+ ));
+ }
+ if request
+ .prompt
+ .as_ref()
+ .is_some_and(|prompt| prompt.len() > 8 * 1024)
+ {
+ return Err(UseError::new(
+ "use.ocr.prompt_too_large",
+ "The vision OCR prompt must not exceed 8192 bytes.",
+ ));
+ }
+ Ok(())
+}
+
+async fn run_tesseract(
+ executable: &Path,
+ source: &Path,
+ languages: &[String],
+ page_segmentation_mode: Option,
+ timeout: std::time::Duration,
+) -> UseResult> {
+ let mut command = Command::new(executable);
+ command
+ .arg(source)
+ .arg("stdout")
+ .arg("-l")
+ .arg(languages.join("+"))
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .kill_on_drop(true);
+ if let Some(mode) = page_segmentation_mode {
+ command.arg("--psm").arg(mode.to_string());
+ }
+ command.arg("tsv");
+
+ let output = tokio::time::timeout(timeout, command.output())
+ .await
+ .map_err(|_| {
+ UseError::new(
+ "use.ocr.provider_timeout",
+ format!(
+ "Tesseract exceeded the {} ms OCR timeout.",
+ timeout.as_millis()
+ ),
+ )
+ })?
+ .map_err(|error| {
+ UseError::new(
+ "use.ocr.provider_failed",
+ format!(
+ "Failed to launch Tesseract executable '{}': {error}",
+ executable.display()
+ ),
+ )
+ })?;
+ if output.stdout.len() > MAX_PROVIDER_OUTPUT_BYTES
+ || output.stderr.len() > MAX_PROVIDER_OUTPUT_BYTES
+ {
+ return Err(UseError::new(
+ "use.ocr.output_too_large",
+ "Tesseract output exceeded 8 MiB.",
+ ));
+ }
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(UseError::new(
+ "use.ocr.provider_failed",
+ format!(
+ "Tesseract exited with {}: {}",
+ output.status,
+ bounded_text(&stderr, 2048)
+ ),
+ ));
+ }
+ Ok(output.stdout)
+}
+
+#[derive(Default)]
+struct LineAccumulator {
+ page: u32,
+ words: Vec,
+ confidence_sum: f32,
+ confidence_count: usize,
+ left: u32,
+ top: u32,
+ right: u32,
+ bottom: u32,
+}
+
+fn parse_tesseract_tsv(output: &[u8]) -> UseResult<(String, Vec)> {
+ let output = std::str::from_utf8(output).map_err(|error| {
+ UseError::new(
+ "use.ocr.provider_output_invalid",
+ format!("Tesseract TSV output was not UTF-8: {error}"),
+ )
+ })?;
+ let mut lines = BTreeMap::<(u32, u32, u32, u32), LineAccumulator>::new();
+ for (index, row) in output.lines().enumerate() {
+ if index == 0 && row.starts_with("level\t") {
+ continue;
+ }
+ if row.trim().is_empty() {
+ continue;
+ }
+ let columns = row.splitn(12, '\t').collect::>();
+ if columns.len() != 12 {
+ return Err(UseError::new(
+ "use.ocr.provider_output_invalid",
+ format!(
+ "Tesseract TSV row {} did not contain 12 columns.",
+ index + 1
+ ),
+ ));
+ }
+ let level = parse_u32(columns[0], index)?;
+ if level != 5 || columns[11].trim().is_empty() {
+ continue;
+ }
+ let page = parse_u32(columns[1], index)?;
+ let block = parse_u32(columns[2], index)?;
+ let paragraph = parse_u32(columns[3], index)?;
+ let line = parse_u32(columns[4], index)?;
+ let left = parse_u32(columns[6], index)?;
+ let top = parse_u32(columns[7], index)?;
+ let width = parse_u32(columns[8], index)?;
+ let height = parse_u32(columns[9], index)?;
+ let confidence = columns[10]
+ .parse::()
+ .ok()
+ .filter(|value| *value >= 0.0);
+ let entry = lines
+ .entry((page, block, paragraph, line))
+ .or_insert_with(|| LineAccumulator {
+ page,
+ left,
+ top,
+ right: left.saturating_add(width),
+ bottom: top.saturating_add(height),
+ ..LineAccumulator::default()
+ });
+ entry.words.push(columns[11].trim().to_string());
+ if let Some(confidence) = confidence {
+ entry.confidence_sum += confidence;
+ entry.confidence_count += 1;
+ }
+ entry.left = entry.left.min(left);
+ entry.top = entry.top.min(top);
+ entry.right = entry.right.max(left.saturating_add(width));
+ entry.bottom = entry.bottom.max(top.saturating_add(height));
+ }
+ let blocks = lines
+ .into_values()
+ .filter_map(|line| {
+ let text = line.words.join(" ");
+ (!text.is_empty()).then(|| OcrBlock {
+ page: line.page,
+ text,
+ confidence: (line.confidence_count > 0)
+ .then(|| line.confidence_sum / line.confidence_count as f32),
+ bounding_box: Some(OcrBoundingBox {
+ x: line.left,
+ y: line.top,
+ width: line.right.saturating_sub(line.left),
+ height: line.bottom.saturating_sub(line.top),
+ }),
+ })
+ })
+ .collect::>();
+ let text = blocks
+ .iter()
+ .map(|block| block.text.as_str())
+ .collect::>()
+ .join("\n");
+ Ok((text, blocks))
+}
+
+fn parse_u32(value: &str, row: usize) -> UseResult {
+ value.parse::().map_err(|_| {
+ UseError::new(
+ "use.ocr.provider_output_invalid",
+ format!(
+ "Tesseract TSV row {} contained an invalid integer.",
+ row + 1
+ ),
+ )
+ })
+}
+
+fn vision_content_text(content: &serde_json::Value) -> UseResult {
+ if let Some(text) = content.as_str() {
+ return Ok(text.to_string());
+ }
+ let Some(parts) = content.as_array() else {
+ return Err(UseError::new(
+ "use.ocr.vision_response_invalid",
+ "Vision OCR message content was neither text nor a text-part array.",
+ ));
+ };
+ let text = parts
+ .iter()
+ .filter_map(|part| {
+ part.get("text")
+ .and_then(serde_json::Value::as_str)
+ .or_else(|| part.as_str())
+ })
+ .collect::>()
+ .join("");
+ if text.is_empty() {
+ return Err(UseError::new(
+ "use.ocr.vision_response_invalid",
+ "Vision OCR message content did not contain text.",
+ ));
+ }
+ Ok(text)
+}
+
+fn detect_image_type(bytes: &[u8]) -> Option<&'static str> {
+ if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
+ Some("image/png")
+ } else if bytes.starts_with(b"\xff\xd8\xff") {
+ Some("image/jpeg")
+ } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
+ Some("image/gif")
+ } else if bytes.starts_with(b"BM") {
+ Some("image/bmp")
+ } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
+ Some("image/tiff")
+ } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
+ Some("image/webp")
+ } else {
+ None
+ }
+}
+
+fn bounded_text(value: &str, max: usize) -> String {
+ let mut text = value.chars().take(max).collect::();
+ if value.chars().count() > max {
+ text.push('…');
+ }
+ text
+}
+
+fn redacted_endpoint(endpoint: &url::Url) -> String {
+ let mut redacted = endpoint.clone();
+ redacted.set_query(None);
+ redacted.set_fragment(None);
+ redacted.to_string()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[cfg(unix)]
+ use std::os::unix::fs::PermissionsExt;
+
+ #[test]
+ fn parses_tesseract_words_into_ordered_lines() {
+ let tsv = b"level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext\n5\t1\t1\t1\t1\t1\t10\t20\t30\t10\t95.0\tHello\n5\t1\t1\t1\t1\t2\t45\t20\t35\t10\t85.0\tworld\n5\t1\t1\t1\t2\t1\t10\t40\t20\t10\t90.0\tNext\n";
+ let (text, blocks) = parse_tesseract_tsv(tsv).unwrap();
+ assert_eq!(text, "Hello world\nNext");
+ assert_eq!(blocks.len(), 2);
+ assert_eq!(blocks[0].confidence, Some(90.0));
+ assert_eq!(
+ blocks[0].bounding_box,
+ Some(OcrBoundingBox {
+ x: 10,
+ y: 20,
+ width: 70,
+ height: 10,
+ })
+ );
+ }
+
+ #[test]
+ fn detects_supported_image_signatures() {
+ assert_eq!(
+ detect_image_type(b"\x89PNG\r\n\x1a\nrest"),
+ Some("image/png")
+ );
+ assert_eq!(detect_image_type(b"\xff\xd8\xffrest"), Some("image/jpeg"));
+ assert_eq!(detect_image_type(b"not an image"), None);
+ }
+
+ #[cfg(unix)]
+ #[tokio::test]
+ async fn local_provider_extracts_a_real_bounded_source_through_its_process_boundary() {
+ let temp = tempfile::tempdir().unwrap();
+ let executable = temp.path().join("tesseract-fixture");
+ std::fs::write(
+ &executable,
+ "#!/bin/sh\nprintf 'level\\tpage_num\\tblock_num\\tpar_num\\tline_num\\tword_num\\tleft\\ttop\\twidth\\theight\\tconf\\ttext\\n5\\t1\\t1\\t1\\t1\\t1\\t2\\t3\\t20\\t8\\t98.0\\tA3S\\n5\\t1\\t1\\t1\\t1\\t2\\t24\\t3\\t30\\t8\\t96.0\\tUse\\n'\n",
+ )
+ .unwrap();
+ let mut permissions = std::fs::metadata(&executable).unwrap().permissions();
+ permissions.set_mode(0o755);
+ std::fs::set_permissions(&executable, permissions).unwrap();
+ let image = temp.path().join("scan.png");
+ std::fs::write(&image, b"\x89PNG\r\n\x1a\nfixture").unwrap();
+
+ let result = OcrClient::with_tesseract(executable)
+ .unwrap()
+ .extract(OcrRequest {
+ path: image,
+ languages: vec!["eng".to_string()],
+ page_segmentation_mode: Some(6),
+ provider: Some(OcrProviderKind::Tesseract),
+ prompt: None,
+ })
+ .await
+ .unwrap();
+
+ assert_eq!(result.provider, OcrProviderKind::Tesseract);
+ assert_eq!(result.text, "A3S Use");
+ assert_eq!(result.blocks.len(), 1);
+ assert_eq!(result.source.media_type, "image/png");
+ assert_eq!(result.source.sha256.len(), 64);
+ }
+}
diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs
new file mode 100644
index 00000000..a1bd1740
--- /dev/null
+++ b/crates/ocr/src/lib.rs
@@ -0,0 +1,18 @@
+//! Typed optical character recognition for A3S Use.
+//!
+//! OCR is a first-party built-in Use domain and remains process-isolated from
+//! A3S Code through its standard MCP server. The crate supports a local
+//! Tesseract executable and an explicitly configured OpenAI-compatible vision
+//! endpoint without silently installing either provider.
+
+pub mod cli;
+mod client;
+pub mod mcp;
+mod models;
+mod provider;
+
+pub use client::OcrClient;
+pub use mcp::OcrMcpServer;
+pub use models::{OcrBlock, OcrBoundingBox, OcrDiagnostic, OcrProviderKind, OcrRequest, OcrResult};
+
+pub use a3s_use_core::{Artifact, Readiness, UseError, UseResult};
diff --git a/crates/ocr/src/main.rs b/crates/ocr/src/main.rs
new file mode 100644
index 00000000..4bba3c58
--- /dev/null
+++ b/crates/ocr/src/main.rs
@@ -0,0 +1,39 @@
+use std::process::ExitCode;
+
+#[tokio::main]
+async fn main() -> ExitCode {
+ let args = std::env::args().skip(1).collect::>();
+ let json = args.iter().any(|argument| argument == "--json");
+ match a3s_use_ocr::cli::run(args).await {
+ Ok(output) => {
+ if output.should_print && json {
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&output.json).unwrap_or_default()
+ );
+ } else if output.should_print && !output.human.is_empty() {
+ println!("{}", output.human);
+ }
+ ExitCode::from(output.exit_code)
+ }
+ Err(error) => {
+ if json {
+ let output = serde_json::json!({
+ "schemaVersion": 1,
+ "ok": false,
+ "error": error,
+ });
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&output).unwrap_or_default()
+ );
+ } else {
+ eprintln!("a3s-use-ocr: {error}");
+ if let Some(suggestion) = &error.suggestion {
+ eprintln!("suggestion: {suggestion}");
+ }
+ }
+ ExitCode::from(1)
+ }
+ }
+}
diff --git a/crates/ocr/src/mcp.rs b/crates/ocr/src/mcp.rs
new file mode 100644
index 00000000..f2de4ada
--- /dev/null
+++ b/crates/ocr/src/mcp.rs
@@ -0,0 +1,170 @@
+//! Standard MCP tools for the built-in OCR domain.
+
+use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters};
+use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerInfo};
+use rmcp::{tool, tool_handler, tool_router, ServerHandler, ServiceExt};
+use serde::Serialize;
+
+use crate::{OcrClient, OcrDiagnostic, OcrRequest, OcrResult, UseError, UseResult};
+
+#[derive(Clone)]
+pub struct OcrMcpServer {
+ client: OcrClient,
+ tool_router: ToolRouter,
+}
+
+impl OcrMcpServer {
+ pub fn new(client: OcrClient) -> Self {
+ Self {
+ client,
+ tool_router: Self::tool_router(),
+ }
+ }
+
+ pub fn from_env() -> UseResult {
+ Ok(Self::new(OcrClient::from_env()?))
+ }
+
+ /// Serve standard MCP framing over stdin/stdout until the peer disconnects.
+ pub async fn serve_stdio(self) -> UseResult<()> {
+ let service = self
+ .serve(rmcp::transport::stdio())
+ .await
+ .map_err(|error| mcp_error("start", error))?;
+ service
+ .waiting()
+ .await
+ .map_err(|error| mcp_error("run", error))?;
+ Ok(())
+ }
+}
+
+#[tool_router]
+impl OcrMcpServer {
+ #[tool(
+ name = "ocr_doctor",
+ description = "Inspect OCR provider readiness without reading an image or making a network request",
+ output_schema = rmcp::handler::server::tool::cached_schema_for_type::(),
+ annotations(
+ read_only_hint = true,
+ destructive_hint = false,
+ idempotent_hint = true,
+ open_world_hint = false
+ )
+ )]
+ async fn ocr_doctor(&self) -> Result {
+ Ok(tool_result(Ok(self.client.diagnostic())))
+ }
+
+ #[tool(
+ name = "ocr_extract",
+ description = "Extract text and available layout evidence from one bounded local image; the configured vision provider may send source bytes to its disclosed endpoint",
+ output_schema = rmcp::handler::server::tool::cached_schema_for_type::(),
+ annotations(
+ read_only_hint = true,
+ destructive_hint = false,
+ idempotent_hint = true,
+ open_world_hint = true
+ )
+ )]
+ async fn ocr_extract(
+ &self,
+ Parameters(request): Parameters,
+ ) -> Result {
+ Ok(tool_result(self.client.extract(request).await))
+ }
+}
+
+#[tool_handler]
+impl ServerHandler for OcrMcpServer {
+ fn get_info(&self) -> ServerInfo {
+ ServerInfo {
+ capabilities: ServerCapabilities::builder().enable_tools().build(),
+ server_info: Implementation {
+ name: "a3s-use-ocr".to_string(),
+ title: Some("A3S Use OCR".to_string()),
+ version: env!("CARGO_PKG_VERSION").to_string(),
+ icons: None,
+ website_url: Some("https://github.com/A3S-Lab/Use".to_string()),
+ },
+ instructions: Some(
+ "Call ocr_doctor first. Use ocr_extract only for a local image path supplied by the task. A vision provider may send the complete image to its configured endpoint; do not use it without the user's authority. Preserve the source SHA-256 and distinguish OCR text from verified source text."
+ .to_string(),
+ ),
+ ..Default::default()
+ }
+ }
+}
+
+fn tool_result(result: UseResult) -> CallToolResult
+where
+ T: Serialize,
+{
+ match result {
+ Ok(output) => match serde_json::to_value(output) {
+ Ok(value) => CallToolResult::structured(value),
+ Err(error) => tool_error(UseError::new(
+ "use.ocr.output_invalid",
+ format!("Failed to encode OCR MCP output: {error}"),
+ )),
+ },
+ Err(error) => tool_error(error),
+ }
+}
+
+fn tool_error(error: UseError) -> CallToolResult {
+ CallToolResult::structured_error(serde_json::to_value(error).unwrap_or_else(|_| {
+ serde_json::json!({
+ "code": "use.error_encoding_failed",
+ "message": "Failed to encode A3S Use error."
+ })
+ }))
+}
+
+fn mcp_error(action: &str, error: impl std::fmt::Display) -> UseError {
+ UseError::new(
+ "use.ocr.mcp_failed",
+ format!("Failed to {action} the OCR MCP server: {error}"),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn server_exposes_typed_annotated_ocr_tools() {
+ let client = OcrClient::from_env().unwrap();
+ let server = OcrMcpServer::new(client);
+ let mut tools = server.tool_router.list_all();
+ tools.sort_by(|left, right| left.name.cmp(&right.name));
+ assert_eq!(
+ tools
+ .iter()
+ .map(|tool| tool.name.as_ref())
+ .collect::>(),
+ ["ocr_doctor", "ocr_extract"]
+ );
+ let doctor = tools.iter().find(|tool| tool.name == "ocr_doctor").unwrap();
+ let extract = tools
+ .iter()
+ .find(|tool| tool.name == "ocr_extract")
+ .unwrap();
+ assert!(doctor.output_schema.is_some());
+ assert!(extract.output_schema.is_some());
+ assert_eq!(
+ doctor
+ .annotations
+ .as_ref()
+ .and_then(|annotations| annotations.open_world_hint),
+ Some(false)
+ );
+ assert_eq!(
+ extract
+ .annotations
+ .as_ref()
+ .and_then(|annotations| annotations.open_world_hint),
+ Some(true)
+ );
+ }
+}
diff --git a/crates/ocr/src/models.rs b/crates/ocr/src/models.rs
new file mode 100644
index 00000000..6cbf6bae
--- /dev/null
+++ b/crates/ocr/src/models.rs
@@ -0,0 +1,105 @@
+use std::path::PathBuf;
+
+use a3s_use_core::{Artifact, Readiness};
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "kebab-case")]
+pub enum OcrProviderKind {
+ Auto,
+ Tesseract,
+ Vision,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct OcrRequest {
+ #[schemars(description = "Local PNG, JPEG, WebP, GIF, BMP, or TIFF image path")]
+ pub path: PathBuf,
+ #[serde(default)]
+ #[schemars(
+ description = "OCR language identifiers; Tesseract values are joined with '+', for example ['eng', 'chi_sim']"
+ )]
+ pub languages: Vec,
+ #[serde(default)]
+ #[schemars(description = "Optional Tesseract page segmentation mode from 0 through 13")]
+ pub page_segmentation_mode: Option,
+ #[serde(default)]
+ #[schemars(description = "Override the configured provider for this call")]
+ pub provider: Option,
+ #[serde(default)]
+ #[schemars(description = "Optional extraction instruction used only by the vision provider")]
+ pub prompt: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct OcrBoundingBox {
+ pub x: u32,
+ pub y: u32,
+ pub width: u32,
+ pub height: u32,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct OcrBlock {
+ pub page: u32,
+ pub text: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub confidence: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub bounding_box: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct OcrResult {
+ pub provider: OcrProviderKind,
+ #[schemars(with = "OcrArtifactSchema")]
+ pub source: Artifact,
+ pub languages: Vec,
+ pub text: String,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub blocks: Vec,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub warnings: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct OcrDiagnostic {
+ #[schemars(with = "OcrReadinessSchema")]
+ pub readiness: Readiness,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub provider: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub executable: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub endpoint: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub model: Option,
+ pub sends_source_off_device: bool,
+ pub message: String,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub suggestions: Vec,
+}
+
+#[derive(schemars::JsonSchema)]
+#[allow(dead_code)]
+struct OcrArtifactSchema {
+ path: PathBuf,
+ media_type: String,
+ size: u64,
+ sha256: String,
+}
+
+#[derive(schemars::JsonSchema)]
+#[serde(rename_all = "kebab-case")]
+#[allow(dead_code)]
+enum OcrReadinessSchema {
+ Ready,
+ Missing,
+ Broken,
+ Unknown,
+}
diff --git a/crates/ocr/src/provider.rs b/crates/ocr/src/provider.rs
new file mode 100644
index 00000000..eb8880e0
--- /dev/null
+++ b/crates/ocr/src/provider.rs
@@ -0,0 +1,393 @@
+use std::env;
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use a3s_use_core::{Readiness, UseError, UseResult};
+use url::Url;
+
+use crate::{OcrDiagnostic, OcrProviderKind};
+
+const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
+const DEFAULT_VISION_BASE_URL: &str = "https://api.openai.com/v1/";
+
+#[derive(Debug, Clone)]
+pub(crate) enum Provider {
+ Tesseract {
+ executable: PathBuf,
+ timeout: Duration,
+ },
+ Vision {
+ endpoint: Url,
+ api_key: Option,
+ model: String,
+ timeout: Duration,
+ },
+}
+
+impl Provider {
+ pub(crate) fn kind(&self) -> OcrProviderKind {
+ match self {
+ Self::Tesseract { .. } => OcrProviderKind::Tesseract,
+ Self::Vision { .. } => OcrProviderKind::Vision,
+ }
+ }
+
+ pub(crate) fn diagnostic(&self) -> OcrDiagnostic {
+ match self {
+ Self::Tesseract { executable, .. } => OcrDiagnostic {
+ readiness: Readiness::Ready,
+ provider: Some(OcrProviderKind::Tesseract),
+ executable: Some(executable.clone()),
+ endpoint: None,
+ model: None,
+ sends_source_off_device: false,
+ message: "The local Tesseract OCR provider is ready.".to_string(),
+ suggestions: Vec::new(),
+ },
+ Self::Vision {
+ endpoint, model, ..
+ } => OcrDiagnostic {
+ readiness: Readiness::Ready,
+ provider: Some(OcrProviderKind::Vision),
+ executable: None,
+ endpoint: Some(redacted_endpoint(endpoint)),
+ model: Some(model.clone()),
+ sends_source_off_device: !is_loopback(endpoint),
+ message: "The explicitly configured vision OCR provider is ready.".to_string(),
+ suggestions: Vec::new(),
+ },
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct ProviderConfig {
+ requested: OcrProviderKind,
+ tesseract: Option,
+ vision: Option,
+ timeout: Duration,
+}
+
+#[derive(Debug, Clone)]
+struct VisionConfig {
+ endpoint: Url,
+ api_key: Option,
+ model: String,
+}
+
+impl ProviderConfig {
+ pub(crate) fn from_env() -> UseResult {
+ let requested = match env::var("A3S_OCR_PROVIDER")
+ .unwrap_or_else(|_| "auto".to_string())
+ .trim()
+ {
+ "" | "auto" => OcrProviderKind::Auto,
+ "tesseract" => OcrProviderKind::Tesseract,
+ "vision" => OcrProviderKind::Vision,
+ value => {
+ return Err(UseError::new(
+ "use.ocr.provider_invalid",
+ format!("Unknown OCR provider '{value}'; expected auto, tesseract, or vision."),
+ ))
+ }
+ };
+
+ let timeout = timeout_from_env()?;
+ let tesseract = env::var_os("A3S_OCR_TESSERACT_EXECUTABLE")
+ .filter(|value| !value.is_empty())
+ .map(PathBuf::from)
+ .or_else(|| find_on_path("tesseract"));
+ let vision = vision_config_from_env()?;
+ Ok(Self {
+ requested,
+ tesseract,
+ vision,
+ timeout,
+ })
+ }
+
+ #[cfg(all(test, unix))]
+ pub(crate) fn tesseract(executable: PathBuf) -> Self {
+ Self {
+ requested: OcrProviderKind::Tesseract,
+ tesseract: Some(executable),
+ vision: None,
+ timeout: DEFAULT_TIMEOUT,
+ }
+ }
+
+ pub(crate) fn diagnostic(&self) -> OcrDiagnostic {
+ match self.resolve(self.requested) {
+ Ok(provider) => provider.diagnostic(),
+ Err(error) => OcrDiagnostic {
+ readiness: Readiness::Missing,
+ provider: match self.requested {
+ OcrProviderKind::Auto => None,
+ provider => Some(provider),
+ },
+ executable: self.tesseract.clone(),
+ endpoint: self
+ .vision
+ .as_ref()
+ .map(|vision| redacted_endpoint(&vision.endpoint)),
+ model: self.vision.as_ref().map(|vision| vision.model.clone()),
+ sends_source_off_device: self
+ .vision
+ .as_ref()
+ .is_some_and(|vision| !is_loopback(&vision.endpoint)),
+ message: error.message,
+ suggestions: error.suggestion.into_iter().collect(),
+ },
+ }
+ }
+
+ pub(crate) fn resolve(&self, requested: OcrProviderKind) -> UseResult {
+ let requested = if requested == OcrProviderKind::Auto {
+ self.requested
+ } else {
+ requested
+ };
+ match requested {
+ OcrProviderKind::Auto => {
+ if let Some(executable) = &self.tesseract {
+ return tesseract_provider(executable, self.timeout);
+ }
+ if let Some(vision) = &self.vision {
+ return Ok(vision_provider(vision, self.timeout));
+ }
+ Err(missing_provider())
+ }
+ OcrProviderKind::Tesseract => self
+ .tesseract
+ .as_ref()
+ .ok_or_else(missing_tesseract)
+ .and_then(|path| tesseract_provider(path, self.timeout)),
+ OcrProviderKind::Vision => self
+ .vision
+ .as_ref()
+ .map(|vision| vision_provider(vision, self.timeout))
+ .ok_or_else(missing_vision),
+ }
+ }
+}
+
+fn tesseract_provider(path: &Path, timeout: Duration) -> UseResult {
+ let path = std::fs::canonicalize(path).map_err(|error| {
+ UseError::new(
+ "use.ocr.provider_missing",
+ format!(
+ "Configured Tesseract executable '{}' is not readable: {error}",
+ path.display()
+ ),
+ )
+ .with_suggestion(
+ "Install Tesseract explicitly or configure the vision provider; A3S Use will not install an OCR provider automatically.",
+ )
+ })?;
+ let metadata = std::fs::metadata(&path).map_err(|error| {
+ UseError::new(
+ "use.ocr.provider_missing",
+ format!(
+ "Configured Tesseract executable '{}' is not readable: {error}",
+ path.display()
+ ),
+ )
+ })?;
+ if !metadata.is_file() {
+ return Err(UseError::new(
+ "use.ocr.provider_invalid",
+ format!(
+ "Configured Tesseract path '{}' is not a regular file.",
+ path.display()
+ ),
+ ));
+ }
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ if metadata.permissions().mode() & 0o111 == 0 {
+ return Err(UseError::new(
+ "use.ocr.provider_invalid",
+ format!(
+ "Configured Tesseract path '{}' is not executable.",
+ path.display()
+ ),
+ ));
+ }
+ }
+ Ok(Provider::Tesseract {
+ executable: path,
+ timeout,
+ })
+}
+
+fn vision_provider(config: &VisionConfig, timeout: Duration) -> Provider {
+ Provider::Vision {
+ endpoint: config.endpoint.clone(),
+ api_key: config.api_key.clone(),
+ model: config.model.clone(),
+ timeout,
+ }
+}
+
+fn vision_config_from_env() -> UseResult> {
+ let model = env::var("A3S_OCR_VISION_MODEL")
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty());
+ let base_url = env::var("A3S_OCR_VISION_BASE_URL")
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty());
+ let api_key = env::var("A3S_OCR_VISION_API_KEY")
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty());
+
+ if model.is_none() && base_url.is_none() && api_key.is_none() {
+ return Ok(None);
+ }
+ let model = model.ok_or_else(|| {
+ UseError::new(
+ "use.ocr.vision_config_invalid",
+ "A3S_OCR_VISION_MODEL is required when the vision OCR provider is configured.",
+ )
+ })?;
+ let mut base = base_url.unwrap_or_else(|| DEFAULT_VISION_BASE_URL.to_string());
+ if !base.ends_with('/') {
+ base.push('/');
+ }
+ let base = Url::parse(&base).map_err(|error| {
+ UseError::new(
+ "use.ocr.vision_config_invalid",
+ format!("A3S_OCR_VISION_BASE_URL is invalid: {error}"),
+ )
+ })?;
+ validate_endpoint(&base, api_key.as_deref())?;
+ let endpoint = base.join("chat/completions").map_err(|error| {
+ UseError::new(
+ "use.ocr.vision_config_invalid",
+ format!("Failed to resolve the vision OCR endpoint: {error}"),
+ )
+ })?;
+ Ok(Some(VisionConfig {
+ endpoint,
+ api_key,
+ model,
+ }))
+}
+
+fn validate_endpoint(endpoint: &Url, api_key: Option<&str>) -> UseResult<()> {
+ if !endpoint.username().is_empty() || endpoint.password().is_some() {
+ return Err(UseError::new(
+ "use.ocr.vision_config_invalid",
+ "The vision OCR endpoint must not contain embedded credentials.",
+ ));
+ }
+ if endpoint.scheme() != "https" && !(endpoint.scheme() == "http" && is_loopback(endpoint)) {
+ return Err(UseError::new(
+ "use.ocr.vision_config_invalid",
+ "The vision OCR endpoint must use HTTPS; loopback HTTP is allowed for local providers.",
+ ));
+ }
+ if !is_loopback(endpoint) && api_key.is_none() {
+ return Err(UseError::new(
+ "use.ocr.vision_config_invalid",
+ "A3S_OCR_VISION_API_KEY is required for a non-loopback vision endpoint.",
+ ));
+ }
+ Ok(())
+}
+
+fn timeout_from_env() -> UseResult {
+ let Some(value) = env::var("A3S_OCR_TIMEOUT_MS").ok() else {
+ return Ok(DEFAULT_TIMEOUT);
+ };
+ let millis = value.parse::().map_err(|_| {
+ UseError::new(
+ "use.ocr.timeout_invalid",
+ "A3S_OCR_TIMEOUT_MS must be an integer from 1 through 300000.",
+ )
+ })?;
+ if !(1..=300_000).contains(&millis) {
+ return Err(UseError::new(
+ "use.ocr.timeout_invalid",
+ "A3S_OCR_TIMEOUT_MS must be an integer from 1 through 300000.",
+ ));
+ }
+ Ok(Duration::from_millis(millis))
+}
+
+fn find_on_path(name: &str) -> Option {
+ let path = env::var_os("PATH")?;
+ env::split_paths(&path)
+ .map(|directory| directory.join(executable_name(name)))
+ .find(|candidate| candidate.is_file())
+}
+
+fn executable_name(name: &str) -> String {
+ if cfg!(windows) {
+ format!("{name}.exe")
+ } else {
+ name.to_string()
+ }
+}
+
+fn missing_provider() -> UseError {
+ UseError::new(
+ "use.ocr.provider_missing",
+ "No OCR provider is configured or discoverable.",
+ )
+ .with_suggestion(
+ "Install Tesseract explicitly, set A3S_OCR_TESSERACT_EXECUTABLE, or configure A3S_OCR_VISION_MODEL, A3S_OCR_VISION_BASE_URL, and A3S_OCR_VISION_API_KEY.",
+ )
+}
+
+fn missing_tesseract() -> UseError {
+ UseError::new(
+ "use.ocr.provider_missing",
+ "The Tesseract OCR provider is not installed or configured.",
+ )
+ .with_suggestion(
+ "Install Tesseract explicitly or set A3S_OCR_TESSERACT_EXECUTABLE; A3S Use will not install it automatically.",
+ )
+}
+
+fn missing_vision() -> UseError {
+ UseError::new(
+ "use.ocr.provider_missing",
+ "The vision OCR provider is not configured.",
+ )
+ .with_suggestion(
+ "Set A3S_OCR_VISION_MODEL and an approved HTTPS endpoint/API key before sending source images to a vision provider.",
+ )
+}
+
+fn is_loopback(url: &Url) -> bool {
+ matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1"))
+}
+
+fn redacted_endpoint(url: &Url) -> String {
+ let mut redacted = url.clone();
+ redacted.set_query(None);
+ redacted.set_fragment(None);
+ redacted.to_string()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn rejects_insecure_remote_vision_endpoint() {
+ let endpoint = Url::parse("http://ocr.example.com/v1/").unwrap();
+ let error = validate_endpoint(&endpoint, Some("secret")).unwrap_err();
+ assert_eq!(error.code, "use.ocr.vision_config_invalid");
+ }
+
+ #[test]
+ fn permits_loopback_http_without_an_api_key() {
+ let endpoint = Url::parse("http://127.0.0.1:8080/v1/").unwrap();
+ validate_endpoint(&endpoint, None).unwrap();
+ }
+}
diff --git a/crates/office/skills/a3s-use-office/SKILL.md b/crates/office/skills/a3s-use-office/SKILL.md
index 6fe1056f..a608ae12 100644
--- a/crates/office/skills/a3s-use-office/SKILL.md
+++ b/crates/office/skills/a3s-use-office/SKILL.md
@@ -9,10 +9,27 @@ Use A3S Use as the application boundary for Office documents. Prefer the
in-process native engine and its typed operations. Use the compatibility route
only when the requested operation is not yet native.
+Use the host surface that is already available:
+
+- In an A3S Code `use` worker, call the available
+ `mcp__use_office__*` tools directly. The host has already started the native
+ MCP server and owns its lifecycle; do not run shell commands or install a
+ provider.
+- If a requested operation is absent from the native tools, use an available
+ `mcp__use_office_compat__*` tool only as an explicit compatibility fallback.
+ If that route is absent, report the missing capability instead of installing,
+ repairing, or falling back to a shell.
+- In a CLI-only agent host, use the `a3s use office native ...` commands below.
+
## Workflow
1. Identify and inspect the document before changing it.
+ In an A3S Code `use` worker, begin with
+ `mcp__use_office__office_validate`, then open a session and use
+ `mcp__use_office__office_view`, `office_get`, or `office_query` as needed.
+ In a CLI-only host, use:
+
```bash
a3s use office native validate "$FILE" --json
a3s use office native view "$FILE" annotated --limit 200 --json
@@ -20,7 +37,9 @@ only when the requested operation is not yet native.
a3s use office native view "$FILE" issues --json
```
-2. Load the format reference relevant to the task:
+2. In a CLI-only agent host, load the format reference relevant to the task.
+ An A3S Code `use` worker cannot read Skill reference files; rely on this
+ guidance and the available MCP tool schemas instead.
- Read [references/word.md](references/word.md) for `.docx`.
- Read [references/spreadsheet.md](references/spreadsheet.md) for `.xlsx`.
@@ -52,6 +71,10 @@ when an agent must bound its lifetime.
## Choose the Surface
+- In an A3S Code `use` worker, use `mcp__use_office__*` and keep the returned
+ Office session ID stable until the document is saved and closed.
+- Use `mcp__use_office_compat__*` only when the native route lacks the requested
+ operation and the compatibility tools are actually present.
- Use `a3s use office native ... --json` for local automation and scripts.
- Use `a3s use mcp serve office-native` for typed, stateful agent sessions.
Read [references/mcp.md](references/mcp.md) before using its session tools.
diff --git a/crates/office/skills/a3s-use-office/references/mcp.md b/crates/office/skills/a3s-use-office/references/mcp.md
index 5d1da61b..cb78ce1e 100644
--- a/crates/office/skills/a3s-use-office/references/mcp.md
+++ b/crates/office/skills/a3s-use-office/references/mcp.md
@@ -8,7 +8,11 @@
## Session Workflow
-Start the explicit native standard MCP server:
+In an A3S Code `use` worker, the host has already started the native server.
+Call the available `mcp__use_office__office_*` tools; do not start a process or
+run a shell command. Tool names below omit the host prefix for readability.
+
+In a CLI-only MCP host, start the explicit native standard MCP server:
```bash
a3s use mcp serve office-native
@@ -504,6 +508,9 @@ Annotated reads include unsaved mutations in the current typed session.
Screenshot output requires a no-clobber `.png` path and a ready A3S Browser
provider; other native Office tools do not require Browser or OfficeCLI.
-Use `a3s use mcp serve office` only for the pinned OfficeCLI compatibility
-server. It is a separate standard MCP target and is not the native session
-engine.
+In an A3S Code `use` worker, use an available
+`mcp__use_office_compat__*` tool only when the native vocabulary lacks the
+requested operation. In a CLI-only MCP host, `a3s use mcp serve office-compat`
+starts the pinned OfficeCLI compatibility server; the legacy
+`a3s use mcp serve office` alias remains supported. It is a separate standard
+MCP target and is not the native session engine.
diff --git a/docs/architecture.md b/docs/architecture.md
index c152127b..c165d7b5 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -37,6 +37,15 @@ The package manifest is a3s-use-extension.acl and is parsed by a3s-acl. A3S Use
owns identity, routes, trust, activation, and lifecycle around the surfaces. It
does not define JSON-RPC methods or convert surfaces implicitly.
+`a3s-use-ocr` implements the reserved first-party `ocr` route in the default
+Use build. The release packages its content-bound Skill and exposes the native
+CLI plus standard stdio MCP without a separate extension install. The process
+accepts bounded local image files and binds every result to the canonical
+source digest. It uses only an explicitly present Tesseract executable or
+configured vision endpoint; it never installs a provider silently. The vision
+diagnostic discloses off-device image transfer, and its MCP extraction tool
+carries conservative open-world annotations.
+
## Hot-plug registry
Extension code remains behind native process boundaries. The registry is a
@@ -64,8 +73,8 @@ custom RPC protocol, `dlopen`, or restart is required.
### Unified capability projection
Resident Code hosts do not need separate discovery paths for built-in and
-external domains. `capability snapshot` projects Browser, Office, Box, and
-enabled extensions through one schema while preserving each binding's
+external domains. `capability snapshot` projects Browser, native Office, OCR,
+Box, and enabled extensions through one schema while preserving each binding's
`built-in` or `extension` origin. `capability watch` accepts both the extension
generation and a content revision. The generation advances for extension
lifecycle commits; the SHA-256 revision also detects built-in provider
@@ -73,10 +82,15 @@ readiness and packaged Skill changes when the extension generation remains
unchanged. Each Skill projection includes an absolute package path and its own
lowercase SHA-256, allowing a resident host to reject raced or modified bytes
before replacing its live Skill.
-The default distribution projects both the Browser Skill and the first-party
-`a3s-use-office` Skill. `office skills list|get|path` exposes the latter as
-bounded local CLI reads; it never launches the OfficeCLI compatibility
-provider.
+The default distribution projects the Browser, first-party `a3s-use-office`,
+and first-party `a3s-use-ocr` Skills. `office skills list|get|path` exposes the
+Office Skill as bounded local CLI reads; it never launches the OfficeCLI
+compatibility provider. For resident hosts, `use/office` targets the built-in
+`office-native` MCP server and is ready independently of OfficeCLI. A discovered
+OfficeCLI provider is projected separately as `use/office-compat`, targeting
+the standard compatibility server without carrying the native Skill. The
+`use/ocr` route targets `ocr-native`; provider readiness remains explicit and
+never triggers a silent Tesseract or vision-provider install.
The projection contains content-bound Skill references and an MCP launch target,
never executable extension code or a generic action payload. Consumers still
@@ -361,11 +375,13 @@ merged-span rewriting fail before save; Presentation table merge editing
remains outside this bounded milestone. These mutations use the existing typed
batch transaction and do not introduce another protocol or runtime.
-Unpromoted commands are delegated to OfficeCLI and `mcp serve office` launches
-its standard MCP server. That compatibility process remains isolated from the
+Unpromoted commands are delegated to OfficeCLI and
+`mcp serve office-compat` launches its standard MCP server; `mcp serve office`
+remains a legacy alias. That compatibility process remains isolated from the
native engine. `mcp serve office-native` instead runs the A3S-owned server in
-process, never discovers or starts OfficeCLI, and keeps the compatibility target
-unchanged until the native product gates pass.
+process and never discovers or starts OfficeCLI. Resident capability projection
+uses the native target canonically and advertises compatibility as a distinct
+optional route.
The preview MCP adapter has an explicit typed vocabulary rather than a command
string passthrough. It supports validate, create/open/list, semantic get/query,
@@ -417,7 +433,7 @@ component for one deprecation cycle before removal.
Implemented:
-1. Core, Browser, Office, extension, and component contracts.
+1. Core, Browser, Office, OCR, extension, and component contracts.
2. Chrome and Lightpanda extraction from Search.
3. Search injection through `Arc`.
4. Typed Browser rendering and session tools over standard MCP stdio.
@@ -479,6 +495,11 @@ Implemented:
15. A packaged first-party `a3s-use-office` Skill with progressive
Word/Spreadsheet/Presentation/MCP references, bounded local discovery,
release-archive smoke checks, and content-bound capability projection.
+16. A first-party built-in OCR route with typed provider diagnostics, bounded
+ image admission, source SHA-256 evidence, local Tesseract and explicit
+ vision adapters, standard MCP annotations/output schemas, and a
+ release-packaged content-bound Skill that projects to `mcp__use_ocr__*` in
+ A3S Code.
Next:
diff --git a/src/capability_registry.rs b/src/capability_registry.rs
index 92b57573..b8b02f27 100644
--- a/src/capability_registry.rs
+++ b/src/capability_registry.rs
@@ -76,6 +76,8 @@ pub(crate) async fn snapshot() -> UseResult {
let mut capabilities = vec![
browser_capability().await?,
office_capability().await?,
+ office_compatibility_capability(),
+ ocr_capability().await?,
box_capability(),
];
capabilities.extend(extensions);
@@ -167,26 +169,30 @@ async fn browser_capability() -> UseResult {
async fn office_capability() -> UseResult {
#[cfg(feature = "office")]
{
- let diagnostic = a3s_use_office::doctor();
- let ready = diagnostic.readiness == Readiness::Ready;
let skill = crate::office_skills::primary_skill_surface().await;
let (package_root, skills) = match skill {
Some((root, path)) => (Some(root), vec![skill_surface(path).await?]),
None => (None, Vec::new()),
};
+ let mut surfaces = vec!["cli".to_string(), "skill".to_string()];
+ #[cfg(feature = "mcp")]
+ surfaces.push("mcp".to_string());
Ok(CapabilityBinding {
id: "use/office".to_string(),
route: "office".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
origin: CapabilityOrigin::BuiltIn,
enabled: true,
- readiness: diagnostic.readiness,
+ readiness: Readiness::Ready,
package_root,
- surfaces: vec!["cli".to_string(), "mcp".to_string(), "skill".to_string()],
- mcp: ready.then(|| McpSurface {
- target: "office".to_string(),
+ surfaces,
+ #[cfg(feature = "mcp")]
+ mcp: Some(McpSurface {
+ target: "office-native".to_string(),
transport: McpTransport::Stdio,
}),
+ #[cfg(not(feature = "mcp"))]
+ mcp: None,
skills,
})
}
@@ -207,6 +213,95 @@ async fn office_capability() -> UseResult {
}
}
+fn office_compatibility_capability() -> CapabilityBinding {
+ #[cfg(feature = "office")]
+ {
+ let diagnostic = a3s_use_office::doctor();
+ let ready = diagnostic.readiness == Readiness::Ready;
+ CapabilityBinding {
+ id: "use/office-compat".to_string(),
+ route: "office-compat".to_string(),
+ version: env!("CARGO_PKG_VERSION").to_string(),
+ origin: CapabilityOrigin::BuiltIn,
+ enabled: true,
+ readiness: diagnostic.readiness,
+ package_root: None,
+ surfaces: vec!["mcp".to_string()],
+ mcp: ready.then(|| McpSurface {
+ target: "office-compat".to_string(),
+ transport: McpTransport::Stdio,
+ }),
+ skills: Vec::new(),
+ }
+ }
+ #[cfg(not(feature = "office"))]
+ {
+ CapabilityBinding {
+ id: "use/office-compat".to_string(),
+ route: "office-compat".to_string(),
+ version: env!("CARGO_PKG_VERSION").to_string(),
+ origin: CapabilityOrigin::BuiltIn,
+ enabled: false,
+ readiness: Readiness::Missing,
+ package_root: None,
+ surfaces: Vec::new(),
+ mcp: None,
+ skills: Vec::new(),
+ }
+ }
+}
+
+async fn ocr_capability() -> UseResult {
+ #[cfg(feature = "ocr")]
+ {
+ let diagnostic = crate::ocr_builtin::diagnostic();
+ let skill = crate::ocr_builtin::primary_skill_surface().await;
+ let (package_root, skills) = match skill {
+ Some((root, path)) => (Some(root), vec![skill_surface(path).await?]),
+ None => (None, Vec::new()),
+ };
+ let mut surfaces = vec!["cli".to_string()];
+ if !skills.is_empty() {
+ surfaces.push("skill".to_string());
+ }
+ #[cfg(feature = "mcp")]
+ surfaces.push("mcp".to_string());
+ Ok(CapabilityBinding {
+ id: "use/ocr".to_string(),
+ route: "ocr".to_string(),
+ version: env!("CARGO_PKG_VERSION").to_string(),
+ origin: CapabilityOrigin::BuiltIn,
+ enabled: true,
+ readiness: diagnostic.readiness,
+ package_root,
+ surfaces,
+ #[cfg(feature = "mcp")]
+ mcp: Some(McpSurface {
+ target: "ocr-native".to_string(),
+ transport: McpTransport::Stdio,
+ }),
+ #[cfg(not(feature = "mcp"))]
+ mcp: None,
+ skills,
+ })
+ }
+ #[cfg(not(feature = "ocr"))]
+ {
+ Ok(CapabilityBinding {
+ id: "use/ocr".to_string(),
+ route: "ocr".to_string(),
+ version: env!("CARGO_PKG_VERSION").to_string(),
+ origin: CapabilityOrigin::BuiltIn,
+ enabled: false,
+ readiness: Readiness::Missing,
+ package_root: None,
+ surfaces: Vec::new(),
+ mcp: None,
+ skills: Vec::new(),
+ })
+ }
+}
+
fn box_capability() -> CapabilityBinding {
let diagnostic = crate::component_route::box_diagnostic();
CapabilityBinding {
@@ -310,6 +405,13 @@ async fn project_extensions(
) -> UseResult>> {
let mut capabilities = Vec::with_capacity(snapshot.routes.len());
for route in &snapshot.routes {
+ #[cfg(feature = "ocr")]
+ if route.route == "ocr" {
+ // OCR became a first-party built-in route. Ignore a legacy OCR
+ // extension receipt so an older installation cannot shadow or
+ // duplicate the release-matched built-in MCP/Skill projection.
+ continue;
+ }
let Some(extension) = crate::extension_host::get(&route.package_id).await? else {
return Ok(None);
};
@@ -379,9 +481,21 @@ mod tests {
.iter()
.find(|capability| capability.id == "use/office")
.unwrap();
+ let office_compat = snapshot
+ .capabilities
+ .iter()
+ .find(|capability| capability.id == "use/office-compat")
+ .unwrap();
+ let ocr = snapshot
+ .capabilities
+ .iter()
+ .find(|capability| capability.id == "use/ocr")
+ .unwrap();
assert_eq!(browser.origin, CapabilityOrigin::BuiltIn);
assert_eq!(office.origin, CapabilityOrigin::BuiltIn);
+ assert_eq!(office_compat.origin, CapabilityOrigin::BuiltIn);
+ assert_eq!(ocr.origin, CapabilityOrigin::BuiltIn);
#[cfg(feature = "browser")]
{
assert!(browser.surfaces.iter().any(|surface| surface == "skill"));
@@ -405,6 +519,13 @@ mod tests {
.iter()
.any(|skill| skill.path.ends_with("a3s-use-office/SKILL.md")));
assert!(office.skills.iter().all(|skill| skill.sha256.len() == 64));
+ #[cfg(feature = "mcp")]
+ assert_eq!(
+ office.mcp.as_ref().map(|surface| surface.target.as_str()),
+ Some("office-native")
+ );
+ assert!(office_compat.skills.is_empty());
+ assert_eq!(office_compat.route, "office-compat");
}
#[cfg(not(feature = "office"))]
{
@@ -412,6 +533,27 @@ mod tests {
assert!(office.surfaces.is_empty());
assert!(office.skills.is_empty());
}
+ #[cfg(feature = "ocr")]
+ {
+ assert!(ocr.enabled);
+ assert!(ocr.surfaces.iter().any(|surface| surface == "skill"));
+ assert!(ocr
+ .skills
+ .iter()
+ .any(|skill| skill.path.ends_with("a3s-use-ocr/SKILL.md")));
+ assert!(ocr.skills.iter().all(|skill| skill.sha256.len() == 64));
+ #[cfg(feature = "mcp")]
+ assert_eq!(
+ ocr.mcp.as_ref().map(|surface| surface.target.as_str()),
+ Some("ocr-native")
+ );
+ }
+ #[cfg(not(feature = "ocr"))]
+ {
+ assert!(!ocr.enabled);
+ assert!(ocr.surfaces.is_empty());
+ assert!(ocr.skills.is_empty());
+ }
assert_eq!(snapshot.revision.len(), 64);
}
diff --git a/src/cli.rs b/src/cli.rs
index 07c6ad2a..23bad00f 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -63,6 +63,7 @@ pub async fn run(args: Vec) -> UseResult {
"doctor" => doctor(args.get(1).map(String::as_str)),
"component" => component(&args[1..]).await,
"browser" => browser(&args[1..]).await,
+ "ocr" => ocr(&args[1..]).await,
"box" => {
let exit_code = crate::component_route::run_box(&args[1..]).await?;
Ok(CommandOutput::delegated(exit_code))
@@ -90,6 +91,9 @@ fn version() -> CommandOutput {
"schemaVersion": 1,
"ok": true,
"version": env!("CARGO_PKG_VERSION"),
+ "data": {
+ "version": env!("CARGO_PKG_VERSION"),
+ },
}),
exit_code: 0,
should_print: true,
@@ -104,7 +108,7 @@ fn help() -> CommandOutput {
" a3s-use capabilities [--json]\n",
" a3s-use capability snapshot [--json]\n",
" a3s-use capability watch [--after-generation ] [--after-revision ] [--timeout-ms ] [--json]\n",
- " a3s-use doctor [browser|box|office] [--json]\n",
+ " a3s-use doctor [browser|box|office|ocr] [--json]\n",
" a3s-use component list|status|install|uninstall [args] [--json]\n",
" a3s-use browser doctor [--json]\n",
" a3s-use browser render [--output ] [--screenshot ] [--json]\n",
@@ -114,12 +118,14 @@ fn help() -> CommandOutput {
" a3s-use office skills list|get|path [args] [--json]\n",
" a3s-use office native get|query|view|watch|raw|raw-set|dump|merge|validate|create|add|add-part|set|sort|remove|move|copy|swap|insert-rows|delete-rows|insert-columns|delete-columns|rename-sheet|move-sheet|copy-sheet|batch [args] [--json]\n",
" a3s-use office \n",
+ " a3s-use ocr doctor [--json]\n",
+ " a3s-use ocr extract [--language ] [--provider ] [--json]\n",
" a3s-use extension list|inspect|doctor [args] [--json]\n",
" a3s-use extension enable [--json]\n",
" a3s-use extension disable [--timeout-ms ] [--json]\n",
" a3s-use extension snapshot|watch [--after-generation ] [--timeout-ms ] [--json]\n",
" a3s-use mcp serve browser [--tools ]\n",
- " a3s-use mcp serve office|office-native|\n",
+ " a3s-use mcp serve office|office-native|office-compat|ocr|\n",
" a3s-use mcp start|status|stop [browser] [--json]"
),
serde_json::json!({
@@ -131,6 +137,7 @@ fn help() -> CommandOutput {
"browser",
"box",
"office",
+ "ocr",
"extension",
"mcp"
]
@@ -142,9 +149,10 @@ async fn capabilities() -> UseResult {
let browser = browser_diagnostic();
let box_domain = crate::component_route::box_diagnostic();
let office = office_diagnostic();
+ let ocr = ocr_diagnostic();
let (extension_generation, extensions) = extension_capabilities().await?;
Ok(CommandOutput::success(
- "Built-in routes: browser, box, office",
+ "Built-in routes: browser, box, office, ocr",
serde_json::json!({
"domains": [
{
@@ -159,6 +167,12 @@ async fn capabilities() -> UseResult {
"readiness": office.readiness,
"surfaces": ["cli", "mcp", "skill"]
},
+ {
+ "id": "ocr",
+ "builtIn": true,
+ "readiness": ocr.readiness,
+ "surfaces": ["cli", "mcp", "skill"]
+ },
{
"id": "box",
"builtIn": true,
@@ -221,11 +235,13 @@ fn doctor(domain: Option<&str>) -> UseResult {
None | Some("--json") => vec![
browser_diagnostic(),
office_diagnostic(),
+ ocr_diagnostic(),
crate::component_route::box_diagnostic(),
],
Some("browser") => vec![browser_diagnostic()],
Some("box") => vec![crate::component_route::box_diagnostic()],
Some("office") => vec![office_diagnostic()],
+ Some("ocr") => vec![ocr_diagnostic()],
Some(value) => {
return Err(UseError::new(
"use.domain_unknown",
@@ -267,8 +283,9 @@ async fn component_list() -> UseResult {
let browser = component_value("browser", &browser_diagnostic());
let box_component = component_value("box", &crate::component_route::box_diagnostic());
let office = component_value("office", &office_diagnostic());
+ let ocr = component_value("ocr", &ocr_diagnostic());
let extensions = installed_extensions().await?;
- let mut components = vec![browser, box_component, office];
+ let mut components = vec![browser, box_component, office, ocr];
components.extend(
extensions
.iter()
@@ -278,6 +295,7 @@ async fn component_list() -> UseResult {
"browser".to_string(),
"box".to_string(),
"office".to_string(),
+ "ocr".to_string(),
];
human.extend(
extensions
@@ -495,7 +513,10 @@ async fn component_uninstall(id: &str) -> UseResult {
));
}
}
- if matches!(id, "browser" | "use/browser" | "office" | "use/office") {
+ if matches!(
+ id,
+ "browser" | "use/browser" | "office" | "use/office" | "ocr" | "use/ocr"
+ ) {
return Ok(CommandOutput::success(
format!("No managed runtime files are owned for '{id}'."),
serde_json::json!({
@@ -663,9 +684,11 @@ async fn mcp(args: &[String]) -> UseResult {
"Standard Browser MCP support is disabled in this custom build.",
))
}
- "office" | "use/office" => {
+ "office" | "use/office" | "office-compat" | "use/office-compat" => {
if args.len() != 2 {
- return Err(usage_error("mcp serve office accepts exactly one target"));
+ return Err(usage_error(
+ "mcp serve office compatibility targets accept exactly one target",
+ ));
}
#[cfg(feature = "office")]
{
@@ -696,6 +719,21 @@ async fn mcp(args: &[String]) -> UseResult {
"Native Office MCP support is disabled in this custom build.",
))
}
+ "ocr" | "use/ocr" | "ocr-native" | "use/ocr-native" => {
+ if args.len() != 2 {
+ return Err(usage_error("mcp serve ocr accepts exactly one target"));
+ }
+ #[cfg(all(feature = "ocr", feature = "mcp"))]
+ {
+ a3s_use_ocr::OcrMcpServer::from_env()?.serve_stdio().await?;
+ Ok(CommandOutput::delegated(0))
+ }
+ #[cfg(not(all(feature = "ocr", feature = "mcp")))]
+ Err(UseError::new(
+ "use.mcp.disabled",
+ "OCR MCP support is disabled in this custom build.",
+ ))
+ }
package_id if external_package_id(package_id).is_some() => {
if args.len() != 2 {
return Err(usage_error(
@@ -888,6 +926,7 @@ fn builtin_diagnostic(id: &str) -> Option {
"browser" | "use/browser" => Some(browser_diagnostic()),
"box" | "use/box" => Some(crate::component_route::box_diagnostic()),
"office" | "use/office" => Some(office_diagnostic()),
+ "ocr" | "use/ocr" => Some(ocr_diagnostic()),
_ => None,
}
}
@@ -1031,7 +1070,21 @@ fn office_diagnostic() -> DomainDiagnostic {
disabled_diagnostic("office")
}
-#[cfg(any(not(feature = "browser"), not(feature = "office")))]
+#[cfg(feature = "ocr")]
+fn ocr_diagnostic() -> DomainDiagnostic {
+ crate::ocr_builtin::diagnostic()
+}
+
+#[cfg(not(feature = "ocr"))]
+fn ocr_diagnostic() -> DomainDiagnostic {
+ disabled_diagnostic("ocr")
+}
+
+#[cfg(any(
+ not(feature = "browser"),
+ not(feature = "office"),
+ not(feature = "ocr")
+))]
fn disabled_diagnostic(domain: &str) -> DomainDiagnostic {
DomainDiagnostic {
domain: domain.to_string(),
@@ -1044,6 +1097,25 @@ fn disabled_diagnostic(domain: &str) -> DomainDiagnostic {
}
}
+#[cfg(feature = "ocr")]
+async fn ocr(args: &[String]) -> UseResult {
+ let output = a3s_use_ocr::cli::run(args.to_vec()).await?;
+ Ok(CommandOutput {
+ human: output.human,
+ json: output.json,
+ exit_code: output.exit_code,
+ should_print: output.should_print,
+ })
+}
+
+#[cfg(not(feature = "ocr"))]
+async fn ocr(_args: &[String]) -> UseResult {
+ Err(UseError::new(
+ "use.ocr.disabled",
+ "OCR support is disabled in this custom build.",
+ ))
+}
+
fn value_argument<'a>(args: &'a [String], index: usize, message: &str) -> UseResult<&'a str> {
args.get(index)
.map(String::as_str)
diff --git a/src/cli_tests.rs b/src/cli_tests.rs
index 6aa99587..e60610cd 100644
--- a/src/cli_tests.rs
+++ b/src/cli_tests.rs
@@ -1,13 +1,25 @@
use super::*;
#[tokio::test]
-async fn capabilities_always_include_browser_and_office() {
+async fn version_json_exposes_a_typed_data_payload_for_consumers() {
+ let output = run(vec!["--version".to_string(), "--json".to_string()])
+ .await
+ .unwrap();
+
+ assert_eq!(output.json["schemaVersion"], 1);
+ assert_eq!(output.json["ok"], true);
+ assert_eq!(output.json["data"]["version"], env!("CARGO_PKG_VERSION"));
+}
+
+#[tokio::test]
+async fn capabilities_always_include_browser_office_and_ocr() {
let output = run(vec!["capabilities".to_string(), "--json".to_string()])
.await
.unwrap();
let domains = output.json["data"]["domains"].as_array().unwrap();
assert_eq!(domains[0]["id"], "browser");
assert_eq!(domains[1]["id"], "office");
+ assert_eq!(domains[2]["id"], "ocr");
assert!(domains[0]["surfaces"]
.as_array()
.unwrap()
@@ -35,9 +47,14 @@ async fn capability_snapshot_unifies_built_ins_without_rpc_envelopes() {
.iter()
.find(|capability| capability["id"] == "use/office")
.unwrap();
+ let ocr = capabilities
+ .iter()
+ .find(|capability| capability["id"] == "use/ocr")
+ .unwrap();
assert_eq!(browser["origin"], "built-in");
assert_eq!(office["origin"], "built-in");
+ assert_eq!(ocr["origin"], "built-in");
#[cfg(feature = "office")]
{
assert!(office["surfaces"]
@@ -60,10 +77,41 @@ async fn capability_snapshot_unifies_built_ins_without_rpc_envelopes() {
assert_eq!(office["surfaces"], serde_json::json!([]));
assert!(office.get("skills").is_none());
}
+ #[cfg(feature = "ocr")]
+ {
+ assert_eq!(ocr["enabled"], true);
+ assert_eq!(ocr["mcp"]["target"], "ocr-native");
+ assert!(ocr["skills"][0]["path"].as_str().is_some_and(
+ |path| std::path::Path::new(path).ends_with("skills/a3s-use-ocr/SKILL.md")
+ ));
+ assert_eq!(ocr["skills"][0]["sha256"].as_str().unwrap().len(), 64);
+ }
+ #[cfg(not(feature = "ocr"))]
+ {
+ assert_eq!(ocr["enabled"], false);
+ assert_eq!(ocr["surfaces"], serde_json::json!([]));
+ assert!(ocr.get("skills").is_none());
+ }
assert_eq!(registry["revision"].as_str().unwrap().len(), 64);
assert!(output.json.get("jsonrpc").is_none());
}
+#[cfg(feature = "ocr")]
+#[tokio::test]
+async fn built_in_ocr_doctor_uses_the_root_cli_contract() {
+ let output = run(vec![
+ "ocr".to_string(),
+ "doctor".to_string(),
+ "--json".to_string(),
+ ])
+ .await
+ .unwrap();
+
+ assert_eq!(output.json["schemaVersion"], 1);
+ assert_eq!(output.json["ok"], true);
+ assert!(output.json["data"]["readiness"].is_string());
+}
+
#[tokio::test]
async fn component_status_uses_cli_json_contract() {
let output = run(vec![
diff --git a/src/lib.rs b/src/lib.rs
index 2c27ad51..fe1d9d97 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -11,6 +11,9 @@ pub mod cli;
mod component_route;
mod extension_cli;
+#[cfg(feature = "ocr")]
+mod ocr_builtin;
+
#[cfg(feature = "office")]
mod office_artifact;
#[cfg(feature = "office")]
@@ -37,5 +40,8 @@ pub use a3s_use_browser as browser;
#[cfg(feature = "office")]
pub use a3s_use_office as office;
+#[cfg(feature = "ocr")]
+pub use a3s_use_ocr as ocr;
+
#[cfg(feature = "extensions")]
pub use a3s_use_extension as extension;
diff --git a/src/mcp.rs b/src/mcp.rs
index 5c4c090c..68604713 100644
--- a/src/mcp.rs
+++ b/src/mcp.rs
@@ -210,7 +210,13 @@ mod browser {
impl BrowserMcpServer {
#[tool(
name = "browser_doctor",
- description = "Inspect the locally available A3S Use Browser provider without installing software"
+ description = "Inspect the locally available A3S Use Browser provider without installing software",
+ annotations(
+ read_only_hint = true,
+ destructive_hint = false,
+ idempotent_hint = true,
+ open_world_hint = false
+ )
)]
async fn browser_doctor(&self) -> Result {
Ok(match serde_json::to_value(a3s_use_browser::doctor()) {
@@ -224,7 +230,13 @@ mod browser {
#[tool(
name = "browser_render",
- description = "Render one web page with the configured local Browser provider"
+ description = "Render one web page with the configured local Browser provider",
+ annotations(
+ read_only_hint = true,
+ destructive_hint = false,
+ idempotent_hint = true,
+ open_world_hint = true
+ )
)]
async fn browser_render(
&self,
@@ -249,7 +261,13 @@ mod browser {
#[tool(
name = "browser_open",
- description = "Open an isolated stateful Browser session and return its first semantic snapshot"
+ description = "Open an isolated stateful Browser session and return its first semantic snapshot",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = false,
+ idempotent_hint = false,
+ open_world_hint = true
+ )
)]
async fn browser_open(
&self,
@@ -281,7 +299,13 @@ mod browser {
#[tool(
name = "browser_list",
- description = "List open Browser sessions and their current URLs"
+ description = "List open Browser sessions and their current URLs",
+ annotations(
+ read_only_hint = true,
+ destructive_hint = false,
+ idempotent_hint = true,
+ open_world_hint = false
+ )
)]
async fn browser_list(&self) -> Result {
Ok(tool_result(self.sessions.list().await))
@@ -289,7 +313,13 @@ mod browser {
#[tool(
name = "browser_navigate",
- description = "Navigate an open Browser session and return a fresh semantic snapshot"
+ description = "Navigate an open Browser session and return a fresh semantic snapshot",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = false,
+ idempotent_hint = false,
+ open_world_hint = true
+ )
)]
async fn browser_navigate(
&self,
@@ -320,7 +350,13 @@ mod browser {
#[tool(
name = "browser_snapshot",
- description = "Return a compact semantic snapshot and fresh @e element references"
+ description = "Return a compact semantic snapshot and fresh @e element references",
+ annotations(
+ read_only_hint = true,
+ destructive_hint = false,
+ idempotent_hint = true,
+ open_world_hint = false
+ )
)]
async fn browser_snapshot(
&self,
@@ -335,7 +371,13 @@ mod browser {
#[tool(
name = "browser_click",
- description = "Click an element reference from the latest semantic snapshot"
+ description = "Click an element reference from the latest semantic snapshot",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = false,
+ idempotent_hint = false,
+ open_world_hint = true
+ )
)]
async fn browser_click(
&self,
@@ -352,7 +394,13 @@ mod browser {
#[tool(
name = "browser_type",
- description = "Focus an element reference and type text into it"
+ description = "Focus an element reference and type text into it",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = false,
+ idempotent_hint = false,
+ open_world_hint = true
+ )
)]
async fn browser_type(
&self,
@@ -371,7 +419,13 @@ mod browser {
#[tool(
name = "browser_press",
- description = "Focus an element reference and press one keyboard key"
+ description = "Focus an element reference and press one keyboard key",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = false,
+ idempotent_hint = false,
+ open_world_hint = true
+ )
)]
async fn browser_press(
&self,
@@ -390,7 +444,13 @@ mod browser {
#[tool(
name = "browser_select",
- description = "Select an option value on a referenced select element"
+ description = "Select an option value on a referenced select element",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = false,
+ idempotent_hint = false,
+ open_world_hint = true
+ )
)]
async fn browser_select(
&self,
@@ -409,7 +469,13 @@ mod browser {
#[tool(
name = "browser_scroll",
- description = "Scroll the current page by explicit horizontal and vertical deltas"
+ description = "Scroll the current page by explicit horizontal and vertical deltas",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = false,
+ idempotent_hint = false,
+ open_world_hint = false
+ )
)]
async fn browser_scroll(
&self,
@@ -426,7 +492,13 @@ mod browser {
#[tool(
name = "browser_screenshot",
- description = "Capture a full-page PNG from an open Browser session to an explicit local path"
+ description = "Capture a full-page PNG from an open Browser session to an explicit local path",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = false,
+ idempotent_hint = false,
+ open_world_hint = false
+ )
)]
async fn browser_screenshot(
&self,
@@ -449,7 +521,13 @@ mod browser {
#[tool(
name = "browser_close",
- description = "Close one Browser session and release its tab resources"
+ description = "Close one Browser session and release its tab resources",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = false,
+ idempotent_hint = true,
+ open_world_hint = false
+ )
)]
async fn browser_close(
&self,
@@ -470,7 +548,13 @@ mod browser {
#[tool(
name = "browser_service_stop",
- description = "Stop the authenticated persistent A3S Use Browser MCP deployment"
+ description = "Stop the authenticated persistent A3S Use Browser MCP deployment",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = true,
+ idempotent_hint = true,
+ open_world_hint = false
+ )
)]
async fn browser_service_stop(&self) -> Result {
let Some(shutdown) = self.shutdown.clone() else {
@@ -564,7 +648,7 @@ mod browser {
None,
);
let tools = server.tool_router.list_all();
- let mut names = tools
+ let mut names: Vec<&str> = tools
.iter()
.map(|tool| tool.name.as_ref())
.collect::>();
@@ -587,6 +671,23 @@ mod browser {
"browser_type"
]
);
+
+ let annotations = |name: &str| {
+ tools
+ .iter()
+ .find(|tool| tool.name == name)
+ .and_then(|tool| tool.annotations.as_ref())
+ .unwrap_or_else(|| panic!("{name} must declare MCP annotations"))
+ };
+ let list = annotations("browser_list");
+ assert_eq!(list.read_only_hint, Some(true));
+ assert_eq!(list.open_world_hint, Some(false));
+ let render = annotations("browser_render");
+ assert_eq!(render.read_only_hint, Some(true));
+ assert_eq!(render.open_world_hint, Some(true));
+ let click = annotations("browser_click");
+ assert_eq!(click.read_only_hint, Some(false));
+ assert_eq!(click.open_world_hint, Some(true));
}
#[test]
diff --git a/src/mcp/office.rs b/src/mcp/office.rs
index 1b84ff54..3305ff59 100644
--- a/src/mcp/office.rs
+++ b/src/mcp/office.rs
@@ -86,7 +86,13 @@ impl NativeOfficeMcpServer {
impl NativeOfficeMcpServer {
#[tool(
name = "office_validate",
- description = "Validate and identify one local OOXML document without opening a session"
+ description = "Validate and identify one local OOXML document without opening a session",
+ annotations(
+ read_only_hint = true,
+ destructive_hint = false,
+ idempotent_hint = true,
+ open_world_hint = false
+ )
)]
async fn office_validate(
&self,
@@ -108,7 +114,13 @@ impl NativeOfficeMcpServer {
#[tool(
name = "office_create",
- description = "Create a blank native OOXML document and register a mutable in-memory session"
+ description = "Create a blank native OOXML document and register a mutable in-memory session",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = false,
+ idempotent_hint = false,
+ open_world_hint = false
+ )
)]
async fn office_create(
&self,
@@ -125,7 +137,13 @@ impl NativeOfficeMcpServer {
#[tool(
name = "office_open",
- description = "Open a local OOXML document in a bounded native in-memory session"
+ description = "Open a local OOXML document in a bounded native in-memory session",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = false,
+ idempotent_hint = false,
+ open_world_hint = false
+ )
)]
async fn office_open(
&self,
@@ -145,7 +163,13 @@ impl NativeOfficeMcpServer {
#[tool(
name = "office_list",
- description = "List native Office sessions owned by this MCP server process"
+ description = "List native Office sessions owned by this MCP server process",
+ annotations(
+ read_only_hint = true,
+ destructive_hint = false,
+ idempotent_hint = true,
+ open_world_hint = false
+ )
)]
async fn office_list(&self) -> Result {
let mut entries = self.sessions.list().await;
@@ -165,7 +189,13 @@ impl NativeOfficeMcpServer {
#[tool(
name = "office_get",
- description = "Read one stable semantic path from an open native Office session"
+ description = "Read one stable semantic path from an open native Office session",
+ annotations(
+ read_only_hint = true,
+ destructive_hint = false,
+ idempotent_hint = true,
+ open_world_hint = false
+ )
)]
async fn office_get(
&self,
@@ -192,7 +222,13 @@ impl NativeOfficeMcpServer {
#[tool(
name = "office_query",
- description = "Run a native semantic selector with a bounded result count"
+ description = "Run a native semantic selector with a bounded result count",
+ annotations(
+ read_only_hint = true,
+ destructive_hint = false,
+ idempotent_hint = true,
+ open_world_hint = false
+ )
)]
async fn office_query(
&self,
@@ -228,7 +264,13 @@ impl NativeOfficeMcpServer {
#[tool(
name = "office_view",
- description = "Produce a native text, bounded annotated, outline, statistics, bounded issues, standalone all-format HTML or SVG, or Browser-injected PNG screenshot view for an open session"
+ description = "Produce a native text, bounded annotated, outline, statistics, bounded issues, standalone all-format HTML or SVG, or Browser-injected PNG screenshot view for an open session",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = false,
+ idempotent_hint = false,
+ open_world_hint = false
+ )
)]
async fn office_view(
&self,
@@ -303,7 +345,13 @@ impl NativeOfficeMcpServer {
#[tool(
name = "office_raw_xml",
- description = "Inspect one existing OOXML XML part, limited to 1 MiB of original bytes"
+ description = "Inspect one existing OOXML XML part, limited to 1 MiB of original bytes",
+ annotations(
+ read_only_hint = true,
+ destructive_hint = false,
+ idempotent_hint = true,
+ open_world_hint = false
+ )
)]
async fn office_raw_xml(
&self,
@@ -332,7 +380,13 @@ impl NativeOfficeMcpServer {
#[tool(
name = "office_apply_batch",
- description = "Apply a bounded typed mutation batch atomically in memory; call office_save to persist it"
+ description = "Apply a bounded typed mutation batch atomically in memory; call office_save to persist it",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = false,
+ idempotent_hint = false,
+ open_world_hint = false
+ )
)]
async fn office_apply_batch(
&self,
@@ -363,7 +417,13 @@ impl NativeOfficeMcpServer {
#[tool(
name = "office_merge_template",
- description = "Merge bounded JSON data into a cloned session document and atomically save a distinct output"
+ description = "Merge bounded JSON data into a cloned session document and atomically save a distinct output",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = false,
+ idempotent_hint = false,
+ open_world_hint = false
+ )
)]
async fn office_merge_template(
&self,
@@ -403,7 +463,13 @@ impl NativeOfficeMcpServer {
#[tool(
name = "office_save",
- description = "Atomically persist one mutable native Office session, optionally to a new path"
+ description = "Atomically persist one mutable native Office session, optionally to a new path",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = true,
+ idempotent_hint = false,
+ open_world_hint = false
+ )
)]
async fn office_save(
&self,
@@ -430,7 +496,13 @@ impl NativeOfficeMcpServer {
#[tool(
name = "office_close",
- description = "Close a native Office session, refusing unsaved changes unless discard is explicit"
+ description = "Close a native Office session, refusing unsaved changes unless discard is explicit",
+ annotations(
+ read_only_hint = false,
+ destructive_hint = true,
+ idempotent_hint = true,
+ open_world_hint = false
+ )
)]
async fn office_close(
&self,
diff --git a/src/mcp/office/tests.rs b/src/mcp/office/tests.rs
index 10827174..8e70566a 100644
--- a/src/mcp/office/tests.rs
+++ b/src/mcp/office/tests.rs
@@ -14,7 +14,7 @@ use a3s_use_office::{
fn native_office_server_exposes_only_bounded_typed_tools() {
let server = NativeOfficeMcpServer::new();
let tools = server.tool_router.list_all();
- let mut names = tools
+ let mut names: Vec<&str> = tools
.iter()
.map(|tool| tool.name.as_ref())
.collect::>();
@@ -36,6 +36,30 @@ fn native_office_server_exposes_only_bounded_typed_tools() {
"office_view",
]
);
+
+ let annotations = |name: &str| {
+ tools
+ .iter()
+ .find(|tool| tool.name == name)
+ .and_then(|tool| tool.annotations.as_ref())
+ .unwrap_or_else(|| panic!("{name} must declare MCP annotations"))
+ };
+ for name in [
+ "office_validate",
+ "office_list",
+ "office_get",
+ "office_query",
+ "office_raw_xml",
+ ] {
+ let annotation = annotations(name);
+ assert_eq!(annotation.read_only_hint, Some(true), "{name}");
+ assert_eq!(annotation.open_world_hint, Some(false), "{name}");
+ }
+ assert_eq!(
+ annotations("office_apply_batch").read_only_hint,
+ Some(false)
+ );
+ assert_eq!(annotations("office_save").destructive_hint, Some(true));
}
#[test]
diff --git a/src/ocr_builtin.rs b/src/ocr_builtin.rs
new file mode 100644
index 00000000..127004c2
--- /dev/null
+++ b/src/ocr_builtin.rs
@@ -0,0 +1,95 @@
+//! Built-in projection glue for the first-party OCR domain.
+
+use std::path::{Path, PathBuf};
+
+use a3s_use_core::{DomainDiagnostic, Readiness};
+use a3s_use_ocr::{OcrClient, OcrProviderKind};
+
+pub(crate) fn diagnostic() -> DomainDiagnostic {
+ match OcrClient::from_env() {
+ Ok(client) => {
+ let diagnostic = client.diagnostic();
+ DomainDiagnostic {
+ domain: "ocr".to_string(),
+ readiness: diagnostic.readiness,
+ provider: diagnostic.provider.map(provider_name).map(str::to_string),
+ version: None,
+ path: diagnostic.executable,
+ message: diagnostic.message,
+ suggestions: diagnostic.suggestions,
+ }
+ }
+ Err(error) => DomainDiagnostic {
+ domain: "ocr".to_string(),
+ readiness: Readiness::Broken,
+ provider: None,
+ version: None,
+ path: None,
+ message: error.message,
+ suggestions: error.suggestion.into_iter().collect(),
+ },
+ }
+}
+
+pub(crate) async fn primary_skill_surface() -> Option<(PathBuf, PathBuf)> {
+ let mut roots = Vec::new();
+ if let Some(root) = std::env::var_os("A3S_USE_OCR_SKILLS_DIR").map(PathBuf::from) {
+ roots.push(root);
+ }
+ if let Ok(executable) = std::env::current_exe() {
+ if let Some(parent) = executable.parent() {
+ roots.push(parent.join("ocr-skills"));
+ }
+ }
+ roots.push(
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("crates")
+ .join("ocr")
+ .join("skills"),
+ );
+
+ for root in roots {
+ let skill = root.join("a3s-use-ocr/SKILL.md");
+ let Ok(root) = tokio::fs::canonicalize(root).await else {
+ continue;
+ };
+ let Ok(skill) = tokio::fs::canonicalize(skill).await else {
+ continue;
+ };
+ if skill.starts_with(&root) {
+ return Some((root, skill));
+ }
+ }
+ None
+}
+
+fn provider_name(provider: OcrProviderKind) -> &'static str {
+ match provider {
+ OcrProviderKind::Auto => "auto",
+ OcrProviderKind::Tesseract => "tesseract",
+ OcrProviderKind::Vision => "vision",
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn source_skill_is_available_to_development_builds() {
+ let (root, skill) = primary_skill_surface().await.unwrap();
+ assert!(root.is_absolute());
+ assert!(skill.starts_with(root));
+ assert!(skill.ends_with("a3s-use-ocr/SKILL.md"));
+ }
+
+ #[test]
+ fn diagnostic_is_typed_even_without_a_provider() {
+ let diagnostic = diagnostic();
+ assert_eq!(diagnostic.domain, "ocr");
+ assert!(matches!(
+ diagnostic.readiness,
+ Readiness::Ready | Readiness::Missing | Readiness::Broken
+ ));
+ }
+}
diff --git a/tests/cli.rs b/tests/cli.rs
index 3bdb785f..f502594b 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -21,9 +21,13 @@ fn capabilities_are_available_as_versioned_json() {
assert!(output.status.success());
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(value["schemaVersion"], 1);
- assert_eq!(value["data"]["domains"][0]["id"], "browser");
- assert_eq!(value["data"]["domains"][1]["id"], "office");
- assert_eq!(value["data"]["domains"][2]["id"], "box");
+ let domains = value["data"]["domains"].as_array().unwrap();
+ for id in ["browser", "office", "ocr", "box"] {
+ assert!(
+ domains.iter().any(|domain| domain["id"] == id),
+ "missing built-in domain {id}: {domains:?}"
+ );
+ }
assert!(value["data"].get("customJsonRpc").is_none());
assert!(value.get("jsonrpc").is_none());
}
@@ -58,6 +62,10 @@ fn unified_capability_snapshot_projects_builtin_skills() {
.iter()
.find(|capability| capability["id"] == "use/office")
.unwrap();
+ let office_compat = capabilities
+ .iter()
+ .find(|capability| capability["id"] == "use/office-compat")
+ .unwrap();
assert_eq!(browser["origin"], "built-in");
#[cfg(feature = "browser")]
{
@@ -100,6 +108,15 @@ fn unified_capability_snapshot_projects_builtin_skills() {
assert!(office_skill_digest
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()));
+ #[cfg(feature = "mcp")]
+ {
+ assert_eq!(office["readiness"], "ready");
+ assert_eq!(office["mcp"]["target"], "office-native");
+ }
+ assert_eq!(office_compat["route"], "office-compat");
+ assert_eq!(office_compat["readiness"], "missing");
+ assert!(office_compat.get("mcp").is_none());
+ assert!(office_compat.get("skills").is_none());
}
#[cfg(not(feature = "office"))]
{
@@ -152,6 +169,9 @@ fn office_skill_commands_are_packaged_and_provider_independent() {
.as_str()
.unwrap()
.contains("## Bundled reference: references/mcp.md"));
+ let office_skill = get["data"]["content"].as_str().unwrap();
+ assert!(office_skill.contains("mcp__use_office__*"));
+ assert!(office_skill.contains("mcp__use_office_compat__*"));
let path = Command::new(binary())
.args(["office", "skills", "path", "a3s-use-office", "--json"])
@@ -1811,6 +1831,27 @@ fn office_mcp_target_delegates_to_officeclis_standard_server() {
assert!(output.stderr.is_empty());
}
+#[cfg(all(unix, feature = "office"))]
+#[test]
+fn office_compat_mcp_target_delegates_to_officeclis_standard_server() {
+ let temp = tempfile::tempdir().unwrap();
+ let executable = temp.path().join("officecli-fixture");
+ std::fs::write(&executable, "#!/bin/sh\nprintf '%s\\n' \"$*\"\nexit 5\n").unwrap();
+ let mut permissions = std::fs::metadata(&executable).unwrap().permissions();
+ permissions.set_mode(0o755);
+ std::fs::set_permissions(&executable, permissions).unwrap();
+
+ let output = Command::new(binary())
+ .args(["mcp", "serve", "office-compat"])
+ .env("A3S_OFFICECLI_EXECUTABLE", &executable)
+ .output()
+ .unwrap();
+
+ assert_eq!(output.status.code(), Some(5));
+ assert_eq!(String::from_utf8(output.stdout).unwrap(), "mcp\n");
+ assert!(output.stderr.is_empty());
+}
+
#[cfg(all(feature = "office", feature = "mcp"))]
#[tokio::test]
async fn native_office_mcp_is_standard_typed_and_independent_of_officecli() {
@@ -2100,6 +2141,36 @@ async fn standard_mcp_request(
serde_json::from_str(&line).unwrap()
}
+#[cfg(feature = "ocr")]
+#[test]
+fn built_in_ocr_projects_the_canonical_code_route_and_skill() {
+ let temp = tempfile::tempdir().unwrap();
+ let home = temp.path().join("home");
+
+ let snapshot = Command::new(binary())
+ .args(["capability", "snapshot", "--json"])
+ .env("A3S_USE_HOME", &home)
+ .output()
+ .unwrap();
+ assert!(snapshot.status.success(), "{snapshot:?}");
+ let snapshot: serde_json::Value = serde_json::from_slice(&snapshot.stdout).unwrap();
+ let ocr = snapshot["data"]["registry"]["capabilities"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .find(|capability| capability["id"] == "use/ocr")
+ .unwrap();
+ assert_eq!(ocr["route"], "ocr");
+ assert_eq!(ocr["origin"], "built-in");
+ assert_eq!(ocr["enabled"], true);
+ assert_eq!(ocr["mcp"]["target"], "ocr-native");
+ assert!(ocr["skills"][0]["path"]
+ .as_str()
+ .is_some_and(|path| Path::new(path).ends_with("skills/a3s-use-ocr/SKILL.md")));
+ let digest = ocr["skills"][0]["sha256"].as_str().unwrap();
+ assert_eq!(digest.len(), 64);
+}
+
#[cfg(all(unix, feature = "extensions"))]
#[test]
fn explicit_extension_install_delegates_native_cli_and_preserves_status() {
From 1b2a60d9c8930dcc9935b6841ed4a7778111f884 Mon Sep 17 00:00:00 2001
From: RoyLin
Date: Sun, 19 Jul 2026 08:53:57 +0800
Subject: [PATCH 2/9] feat(extension): add signed remote registries
---
Cargo.lock | 258 +++++-
Cargo.toml | 7 +
README.md | 74 +-
crates/extension/Cargo.toml | 10 +
crates/extension/src/digest.rs | 194 +++++
crates/extension/src/lib.rs | 7 +
crates/extension/src/package.rs | 6 +-
crates/extension/src/paths.rs | 10 +
crates/extension/src/registry.rs | 251 +++++-
crates/extension/src/registry_tests.rs | 252 +++++-
crates/extension/src/remote.rs | 970 +++++++++++++++++++++++
crates/extension/src/remote_tests.rs | 310 ++++++++
crates/extension/src/source.rs | 708 +++++++++++++++++
crates/extension/src/tuf_test_support.rs | 324 ++++++++
docs/architecture.md | 18 +-
src/cli.rs | 95 ++-
src/extension_cli.rs | 78 +-
src/extension_host.rs | 22 +-
tests/extension_archives.rs | 102 +++
tests/remote_extension_cli.rs | 183 +++++
20 files changed, 3829 insertions(+), 50 deletions(-)
create mode 100644 crates/extension/src/digest.rs
create mode 100644 crates/extension/src/remote.rs
create mode 100644 crates/extension/src/remote_tests.rs
create mode 100644 crates/extension/src/source.rs
create mode 100644 crates/extension/src/tuf_test_support.rs
create mode 100644 tests/extension_archives.rs
create mode 100644 tests/remote_extension_cli.rs
diff --git a/Cargo.lock b/Cargo.lock
index 80e0f846..07033fd1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -21,15 +21,19 @@ dependencies = [
"axum",
"base64",
"clap",
+ "flate2",
"fs2",
"futures-util",
"getrandom 0.3.4",
+ "olpc-cjson",
"reqwest",
+ "ring",
"rmcp",
"schemars",
"serde",
"serde_json",
"sha2 0.10.9",
+ "tar",
"tempfile",
"tokio",
"tokio-util",
@@ -107,13 +111,21 @@ version = "0.1.1"
dependencies = [
"a3s-acl",
"a3s-use-core",
+ "flate2",
"fs2",
+ "olpc-cjson",
+ "reqwest",
+ "ring",
"semver",
"serde",
"serde_json",
"sha2 0.10.9",
+ "tar",
"tempfile",
"tokio",
+ "tough",
+ "url",
+ "zip",
]
[[package]]
@@ -432,6 +444,17 @@ dependencies = [
"rustix 1.1.4",
]
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
[[package]]
name = "async-signal"
version = "0.2.14"
@@ -565,6 +588,30 @@ dependencies = [
"arrayvec",
]
+[[package]]
+name = "aws-lc-rs"
+version = "1.17.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
+dependencies = [
+ "aws-lc-sys",
+ "untrusted 0.7.1",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-lc-sys"
+version = "0.43.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c"
+dependencies = [
+ "cc",
+ "cmake",
+ "dunce",
+ "fs_extra",
+ "pkg-config",
+]
+
[[package]]
name = "axum"
version = "0.8.9"
@@ -675,6 +722,16 @@ dependencies = [
"piper",
]
+[[package]]
+name = "bstr"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530"
+dependencies = [
+ "memchr",
+ "serde_core",
+]
+
[[package]]
name = "built"
version = "0.8.1"
@@ -881,6 +938,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+[[package]]
+name = "cmake"
+version = "0.1.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
+dependencies = [
+ "cc",
+]
+
[[package]]
name = "color_quant"
version = "1.1.0"
@@ -1238,6 +1304,16 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1279,6 +1355,12 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "fs_extra"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+
[[package]]
name = "futures"
version = "0.3.32"
@@ -1455,6 +1537,19 @@ dependencies = [
"weezl",
]
+[[package]]
+name = "globset"
+version = "0.4.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata",
+ "regex-syntax",
+]
+
[[package]]
name = "gloo-timers"
version = "0.3.0"
@@ -2144,6 +2239,17 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "olpc-cjson"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "696183c9b5fe81a7715d074fd632e8bd46f4ccc0231a3ed7fc580a80de5f7083"
+dependencies = [
+ "serde",
+ "serde_json",
+ "unicode-normalization",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2186,12 +2292,42 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64",
+ "serde_core",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -2215,6 +2351,12 @@ dependencies = [
"futures-io",
]
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
[[package]]
name = "png"
version = "0.18.1"
@@ -2723,7 +2865,7 @@ dependencies = [
"cfg-if",
"getrandom 0.2.17",
"libc",
- "untrusted",
+ "untrusted 0.9.0",
"windows-sys 0.52.0",
]
@@ -2844,6 +2986,8 @@ version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [
+ "aws-lc-rs",
+ "log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -2868,9 +3012,10 @@ version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
+ "aws-lc-rs",
"ring",
"rustls-pki-types",
- "untrusted",
+ "untrusted 0.9.0",
]
[[package]]
@@ -2991,6 +3136,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "serde_plain"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -3085,6 +3239,29 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+[[package]]
+name = "snafu"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2"
+dependencies = [
+ "futures-core",
+ "pin-project",
+ "snafu-derive",
+]
+
+[[package]]
+name = "snafu-derive"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
[[package]]
name = "socket2"
version = "0.6.5"
@@ -3168,6 +3345,17 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -3367,6 +3555,41 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tough"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8031cff0872dd1c6312370515a6be8098f6ea5512f1bad725016046fc725f272"
+dependencies = [
+ "async-recursion",
+ "async-trait",
+ "aws-lc-rs",
+ "bytes",
+ "chrono",
+ "dyn-clone",
+ "futures",
+ "futures-core",
+ "globset",
+ "hex",
+ "log",
+ "olpc-cjson",
+ "pem",
+ "percent-encoding",
+ "reqwest",
+ "rustls",
+ "serde",
+ "serde_json",
+ "serde_plain",
+ "snafu",
+ "tempfile",
+ "tokio",
+ "tokio-util",
+ "typed-path",
+ "untrusted 0.7.1",
+ "url",
+ "walkdir",
+]
+
[[package]]
name = "tower"
version = "0.5.3"
@@ -3489,6 +3712,12 @@ dependencies = [
"utf-8",
]
+[[package]]
+name = "typed-path"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82205ffd44a9697e34fc145491aa47310f9871540bb7909eaa9365e0a9a46607"
+
[[package]]
name = "typenum"
version = "1.20.1"
@@ -3507,6 +3736,15 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
[[package]]
name = "universal-hash"
version = "0.5.1"
@@ -3517,6 +3755,12 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "untrusted"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
+
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -4039,6 +4283,16 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix 1.1.4",
+]
+
[[package]]
name = "y4m"
version = "0.8.0"
diff --git a/Cargo.toml b/Cargo.toml
index 8c17bb9f..cb782059 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -25,6 +25,7 @@ base64 = "0.22"
clap = { version = "4", features = ["derive"] }
fs2 = "0.4"
futures-util = "0.3"
+flate2 = "1"
getrandom = "0.3"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
quick-xml = "0.38"
@@ -34,10 +35,12 @@ schemars = "1.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
+tar = "0.4"
thiserror = "2"
tempfile = "3"
tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "process", "sync", "time"] }
tokio-util = "0.7"
+tough = { version = "0.22", default-features = false, features = ["http"] }
url = "2"
zip = { version = "2", default-features = false, features = ["deflate"] }
@@ -111,5 +114,9 @@ windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_System_
[dev-dependencies]
async-trait.workspace = true
reqwest.workspace = true
+flate2.workspace = true
+olpc-cjson = "0.1"
+ring = "0.17"
+tar.workspace = true
tempfile.workspace = true
zip.workspace = true
diff --git a/README.md b/README.md
index 5b4916fa..9304819c 100644
--- a/README.md
+++ b/README.md
@@ -1631,6 +1631,7 @@ the parent TUI before source bytes leave the device.
See the [OCR crate](crates/ocr/README.md) for configuration and provider
boundaries.
+
## External Extensions
External Use domains stay behind process boundaries. A package contains an
@@ -1674,11 +1675,74 @@ a3s use extension enable acme/slack --json
a3s uninstall use/acme/slack
```
-The current extension source is an explicit local directory. It must pass
-manifest, route, path, package-size, and executable validation, and unsigned
-content requires `--allow-unsigned`. A signed remote publisher channel is
-roadmap work; Use does not silently install arbitrary Homebrew, npm, Cargo,
-system, or `PATH` packages.
+The current extension source is an explicit local directory or a `.tar.gz`,
+`.tgz`, or `.zip` archive. Archives must contain exactly one package manifest;
+every entry must belong to that manifest's package root. Installation rejects
+links, traversal, duplicate paths, unsupported entries, excessive expansion,
+and non-portable paths before validating the manifest, route, executable, and
+Skill surfaces. Unsigned content requires `--allow-unsigned`. Use does not
+silently install arbitrary Homebrew, npm, Cargo, system, or `PATH` packages.
+
+### Signed extension registries
+
+Remote extensions use TUF metadata and a separately established bootstrap-root
+digest. Enroll a registry with either a root file or its SHA-256, verify it,
+review the immutable component plan, and apply that exact plan:
+
+```bash
+a3s registry add https://packages.example.org/a3s/ \
+ --trust-root ./root.json \
+ --yes
+a3s registry refresh packages
+
+a3s --output json install use/acme/slack --dry-run
+a3s --output json install use/acme/slack \
+ --plan-digest
+
+a3s --output json upgrade use/acme/slack --dry-run
+a3s --output json upgrade use/acme/slack \
+ --plan-digest
+```
+
+When a root file is supplied, the umbrella CLI copies it into registry-owned
+configuration and records its digest. With a digest-only enrollment, Use may
+fetch `/metadata/root.json`, but it caches the file only after the
+bytes match the pinned SHA-256. Subsequent root rotation, timestamp, snapshot,
+and targets metadata are verified by TUF with expiration and rollback
+enforcement. Registry URLs require HTTPS; loopback HTTP is accepted only for
+tests and local development.
+
+A dry-run verifies metadata but does not download the target archive. Its outer
+component digest includes the exact `ResolvedRemotePackage`: registry identity,
+bootstrap root, every TUF metadata version, package version and channel,
+platform target, archive path, length, and SHA-256. Apply resolves again and
+fails before target download if that plan changed. It then passes the resolved
+package's own digest to `a3s-use`, which repeats TUF verification immediately
+before downloading and activating the archive. The installed receipt records
+`registry-tuf` trust and the complete signed provenance. Registry installs
+reject `--allow-unsigned`; local `--from` installs cannot provide registry
+options.
+
+Registry upgrades reuse the registry identity and channel recorded in that
+signed provenance instead of searching every configured source again. A
+missing registry, changed URL or bootstrap root, and semantic-version downgrade
+are rejected before payload download. Plain `a3s upgrade` reports newer signed
+targets, while `a3s upgrade --all` includes them in the selected batch. If the
+verified target is identical to the installed target, `a3s-use` validates and
+reconciles the receipt and registry snapshot without downloading or
+reactivating the package.
+
+Publish metadata below `/metadata/` and payloads below
+`/targets/`. An extension target uses this canonical path:
+
+```text
+extensions//////
+```
+
+Its TUF target `custom.a3s` object must contain `schemaVersion`, `packageId`,
+`version`, `channel` (`stable`, `beta`, or `nightly`), and `target` (an A3S host
+target or `any`). Duplicate identities, mismatched paths, unsupported archives,
+and oversized targets are rejected before payload download.
Built-in and management routes are reserved. Extensions cannot shadow
`browser`, `office`, `ocr`, `box`, `component`, `capability`, or other host
diff --git a/crates/extension/Cargo.toml b/crates/extension/Cargo.toml
index 93f63891..d1ea839f 100644
--- a/crates/extension/Cargo.toml
+++ b/crates/extension/Cargo.toml
@@ -12,9 +12,19 @@ description = "ACL manifest and native surface contracts for A3S Use extensions"
a3s-acl = { git = "https://github.com/A3S-Lab/ACL", rev = "6e2a6469edc0f4c61b1e588d0ace873aaf15ce22" }
a3s-use-core = { version = "0.1.1", path = "../core" }
fs2.workspace = true
+flate2.workspace = true
+reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
semver = "1"
sha2.workspace = true
+tar.workspace = true
tempfile.workspace = true
tokio.workspace = true
+tough.workspace = true
+url.workspace = true
+zip.workspace = true
+
+[dev-dependencies]
+olpc-cjson = "0.1"
+ring = "0.17"
diff --git a/crates/extension/src/digest.rs b/crates/extension/src/digest.rs
new file mode 100644
index 00000000..7f8fb87c
--- /dev/null
+++ b/crates/extension/src/digest.rs
@@ -0,0 +1,194 @@
+use std::fs::File;
+use std::io::{BufReader, Read};
+use std::path::{Path, PathBuf};
+
+use a3s_use_core::{UseError, UseResult};
+use sha2::{Digest, Sha256};
+
+use super::package::{io_error, MAX_PACKAGE_BYTES, MAX_PACKAGE_FILES};
+use super::source::sanitized_relative_path;
+
+struct PackageFile {
+ normalized: String,
+ path: PathBuf,
+ size: u64,
+}
+
+pub(crate) async fn package_sha256(root: &Path) -> UseResult {
+ let root = root.to_path_buf();
+ tokio::task::spawn_blocking(move || hash_package(&root))
+ .await
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.io",
+ format!("Failed to hash extension package: blocking task failed: {error}"),
+ )
+ })?
+}
+
+fn hash_package(root: &Path) -> UseResult {
+ let mut files = Vec::new();
+ let mut entries = 0_usize;
+ let mut bytes = 0_u64;
+ collect_files(root, root, &mut files, &mut entries, &mut bytes)?;
+ files.sort_by(|left, right| left.normalized.cmp(&right.normalized));
+
+ let mut digest = Sha256::new();
+ digest.update(b"a3s-use-expanded-package-v1\0");
+ for package_file in files {
+ let path_bytes = package_file.normalized.as_bytes();
+ digest.update((path_bytes.len() as u64).to_be_bytes());
+ digest.update(path_bytes);
+ digest.update(package_file.size.to_be_bytes());
+
+ let file = File::open(&package_file.path)
+ .map_err(|error| io_error("open extension package file", &package_file.path, error))?;
+ let mut reader = BufReader::new(file);
+ let mut buffer = [0_u8; 64 * 1024];
+ let mut read_bytes = 0_u64;
+ loop {
+ let count = reader.read(&mut buffer).map_err(|error| {
+ io_error("hash extension package file", &package_file.path, error)
+ })?;
+ if count == 0 {
+ break;
+ }
+ read_bytes = read_bytes.saturating_add(count as u64);
+ if read_bytes > package_file.size {
+ return Err(package_changed(&package_file.path));
+ }
+ digest.update(&buffer[..count]);
+ }
+ if read_bytes != package_file.size {
+ return Err(package_changed(&package_file.path));
+ }
+ }
+ Ok(format!("{:x}", digest.finalize()))
+}
+
+fn collect_files(
+ root: &Path,
+ directory: &Path,
+ files: &mut Vec,
+ entries: &mut usize,
+ bytes: &mut u64,
+) -> UseResult<()> {
+ let children = std::fs::read_dir(directory)
+ .map_err(|error| io_error("read extension package directory", directory, error))?;
+ for child in children {
+ let child =
+ child.map_err(|error| io_error("read extension package entry", directory, error))?;
+ *entries = entries.saturating_add(1);
+ if *entries > MAX_PACKAGE_FILES {
+ return Err(package_limit_error());
+ }
+ let path = child.path();
+ let metadata = std::fs::symlink_metadata(&path)
+ .map_err(|error| io_error("inspect extension package entry", &path, error))?;
+ if metadata.file_type().is_symlink() {
+ return Err(UseError::new(
+ "use.extension.package_symlink",
+ format!(
+ "Extension package entry '{}' is a symbolic link.",
+ path.display()
+ ),
+ ));
+ }
+ if metadata.is_dir() {
+ collect_files(root, &path, files, entries, bytes)?;
+ continue;
+ }
+ if !metadata.is_file() {
+ return Err(UseError::new(
+ "use.extension.package_entry_invalid",
+ format!(
+ "Extension package entry '{}' is not a regular file or directory.",
+ path.display()
+ ),
+ ));
+ }
+ *bytes = bytes.saturating_add(metadata.len());
+ if *bytes > MAX_PACKAGE_BYTES {
+ return Err(package_limit_error());
+ }
+ let relative = path.strip_prefix(root).map_err(|_| {
+ UseError::new(
+ "use.extension.path_escape",
+ format!(
+ "Extension package entry '{}' escapes its root.",
+ path.display()
+ ),
+ )
+ })?;
+ let relative = sanitized_relative_path(relative)?.ok_or_else(|| {
+ UseError::new(
+ "use.extension.package_entry_invalid",
+ "Extension package contains an empty file path.",
+ )
+ })?;
+ let normalized = relative
+ .iter()
+ .map(|segment| {
+ segment.to_str().ok_or_else(|| {
+ UseError::new(
+ "use.extension.package_entry_invalid",
+ format!(
+ "Extension package path '{}' is not valid UTF-8.",
+ relative.display()
+ ),
+ )
+ })
+ })
+ .collect::>>()?
+ .join("/");
+ files.push(PackageFile {
+ normalized,
+ path,
+ size: metadata.len(),
+ });
+ }
+ Ok(())
+}
+
+fn package_changed(path: &Path) -> UseError {
+ UseError::new(
+ "use.extension.package_changed",
+ format!(
+ "Extension package file '{}' changed while it was hashed.",
+ path.display()
+ ),
+ )
+}
+
+fn package_limit_error() -> UseError {
+ UseError::new(
+ "use.extension.package_too_large",
+ "The extension package exceeds the local installation limits.",
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn package_digest_is_order_independent_and_content_sensitive() {
+ let temp = tempfile::tempdir().unwrap();
+ let first = temp.path().join("first");
+ let second = temp.path().join("second");
+ std::fs::create_dir_all(first.join("bin")).unwrap();
+ std::fs::create_dir_all(second.join("bin")).unwrap();
+ std::fs::write(first.join("z.txt"), b"z").unwrap();
+ std::fs::write(first.join("bin/tool"), b"tool").unwrap();
+ std::fs::write(second.join("bin/tool"), b"tool").unwrap();
+ std::fs::write(second.join("z.txt"), b"z").unwrap();
+
+ let first_digest = package_sha256(&first).await.unwrap();
+ let second_digest = package_sha256(&second).await.unwrap();
+ assert_eq!(first_digest, second_digest);
+ assert_eq!(first_digest.len(), 64);
+
+ std::fs::write(second.join("bin/tool"), b"changed").unwrap();
+ assert_ne!(first_digest, package_sha256(&second).await.unwrap());
+ }
+}
diff --git a/crates/extension/src/lib.rs b/crates/extension/src/lib.rs
index cc76e2dc..9b692c3c 100644
--- a/crates/extension/src/lib.rs
+++ b/crates/extension/src/lib.rs
@@ -5,11 +5,14 @@ use a3s_acl::{Block, Value};
use a3s_use_core::{RiskClass, UseError, UseResult};
use serde::{Deserialize, Serialize};
+mod digest;
mod package;
mod paths;
mod registry;
mod registry_io;
+mod remote;
mod route_lock;
+mod source;
pub use paths::ExtensionPaths;
pub use registry::{
@@ -17,6 +20,10 @@ pub use registry::{
ExtensionRouteBinding, ExtensionRouteLease, ExtensionTrust, InstallOptions, InstallResult,
InstalledExtension, UninstallResult,
};
+pub use remote::{
+ prepare_remote_package, refresh_remote_registry, DownloadedRemotePackage,
+ PreparedRemotePackage, ResolvedRemotePackage, TrustedRegistry, VerifiedRegistryMetadata,
+};
const RESERVED_ROUTES: &[&str] = &[
"browser",
diff --git a/crates/extension/src/package.rs b/crates/extension/src/package.rs
index a939a430..9778ace7 100644
--- a/crates/extension/src/package.rs
+++ b/crates/extension/src/package.rs
@@ -12,9 +12,9 @@ use tokio::io::AsyncWriteExt;
use super::registry::ExtensionReceipt;
use super::{ExtensionManifest, ExtensionPaths};
-const MANIFEST_NAME: &str = "a3s-use-extension.acl";
-const MAX_PACKAGE_FILES: usize = 10_000;
-const MAX_PACKAGE_BYTES: u64 = 1_073_741_824;
+pub(crate) const MANIFEST_NAME: &str = "a3s-use-extension.acl";
+pub(crate) const MAX_PACKAGE_FILES: usize = 10_000;
+pub(crate) const MAX_PACKAGE_BYTES: u64 = 1_073_741_824;
pub(crate) async fn read_manifest(package_root: &Path) -> UseResult<(ExtensionManifest, Vec)> {
let path = package_root.join(MANIFEST_NAME);
diff --git a/crates/extension/src/paths.rs b/crates/extension/src/paths.rs
index 62adef50..3232a610 100644
--- a/crates/extension/src/paths.rs
+++ b/crates/extension/src/paths.rs
@@ -93,6 +93,12 @@ impl ExtensionPaths {
path.set_extension("lock");
path
}
+
+ pub fn tuf_datastore(&self, registry_name: &str) -> PathBuf {
+ self.state_root
+ .join("remote-registries")
+ .join(registry_name)
+ }
}
fn configured_root(
@@ -163,5 +169,9 @@ mod tests {
paths.registry_snapshot_path(),
PathBuf::from("/state/use/registry.json")
);
+ assert_eq!(
+ paths.tuf_datastore("a3s"),
+ PathBuf::from("/state/use/remote-registries/a3s")
+ );
}
}
diff --git a/crates/extension/src/registry.rs b/crates/extension/src/registry.rs
index 5f3c0135..05e3dc97 100644
--- a/crates/extension/src/registry.rs
+++ b/crates/extension/src/registry.rs
@@ -7,12 +7,15 @@ use fs2::FileExt;
use serde::{Deserialize, Serialize};
use tokio::fs;
+use super::digest::package_sha256;
use super::package::{
copy_package, io_error, owned_package_path, read_manifest, sha256, unique_suffix,
unix_timestamp, validate_surface_files, write_receipt, RegistryLock,
};
use super::registry_io::{read_registry_snapshot, write_registry_snapshot};
+use super::remote::{prepare_remote_package, ResolvedRemotePackage, TrustedRegistry};
use super::route_lock::{acquire_drain_lock, deadline_after, open_route_lock};
+use super::source::prepare_package_source;
use super::{ExtensionManifest, ExtensionPaths, McpTransport};
const RECEIPT_SCHEMA_VERSION: u32 = 1;
@@ -24,6 +27,7 @@ const WATCH_INTERVAL: Duration = Duration::from_millis(50);
#[serde(rename_all = "kebab-case")]
pub enum ExtensionTrust {
LocalExplicit,
+ RegistryTuf,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -36,7 +40,11 @@ pub struct ExtensionReceipt {
pub version: String,
pub package_root: PathBuf,
pub manifest_sha256: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub package_sha256: Option,
pub trust: ExtensionTrust,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub registry: Option,
pub installed_at_unix: u64,
#[serde(default = "enabled_by_default")]
pub enabled: bool,
@@ -115,6 +123,8 @@ pub struct ExtensionRouteBinding {
#[serde(default)]
pub package_root: PathBuf,
pub manifest_sha256: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub package_sha256: Option,
pub enabled: bool,
pub surfaces: Vec,
}
@@ -368,21 +378,113 @@ impl ExtensionRegistry {
.with_suggestion("Rerun the explicit install with --allow-unsigned."));
}
- let source = fs::canonicalize(source)
- .await
- .map_err(|error| io_error("resolve extension package", source, error))?;
- let source_metadata = fs::metadata(&source)
- .await
- .map_err(|error| io_error("inspect extension package", &source, error))?;
- if !source_metadata.is_dir() {
- return Err(UseError::new(
- "use.extension.package_unsupported",
- "The initial local installer accepts a package directory.",
+ let source = prepare_package_source(source).await?;
+ self.install_prepared(
+ &expected_package_id,
+ source.root(),
+ options.force,
+ ExtensionTrust::LocalExplicit,
+ None,
+ )
+ .await
+ }
+
+ /// Install an extension selected through a fully verified TUF repository.
+ ///
+ /// Metadata is resolved and the optional reviewed plan is checked before
+ /// the target payload is downloaded. The package manifest must repeat the
+ /// exact ID and version carried by the signed target metadata.
+ pub async fn install_remote(
+ &self,
+ expected_package_id: &str,
+ registry: &TrustedRegistry,
+ requested_version: Option<&str>,
+ channel: &str,
+ expected_plan_digest: Option<&str>,
+ force: bool,
+ ) -> UseResult {
+ let expected_package_id = normalize_package_id(expected_package_id)?;
+ let prepared = prepare_remote_package(
+ registry,
+ &expected_package_id,
+ requested_version,
+ channel,
+ expected_plan_digest,
+ )
+ .await?;
+ if !force {
+ if let Some(result) = self
+ .converged_remote_install(&expected_package_id, prepared.resolved())
+ .await?
+ {
+ return Ok(result);
+ }
+ }
+ let downloaded = prepared.download().await?;
+ let provenance = downloaded.resolved().clone();
+ let source = prepare_package_source(downloaded.path()).await?;
+ self.install_prepared(
+ &expected_package_id,
+ source.root(),
+ force,
+ ExtensionTrust::RegistryTuf,
+ Some(provenance),
+ )
+ .await
+ }
+
+ async fn converged_remote_install(
+ &self,
+ expected_package_id: &str,
+ resolved: &ResolvedRemotePackage,
+ ) -> UseResult> {
+ let _lock = RegistryLock::acquire(&self.paths.registry_lock_path())?;
+ let Some(mut current) = self.get(expected_package_id).await? else {
+ return Ok(None);
+ };
+ let same_target = current.receipt.trust == ExtensionTrust::RegistryTuf
+ && current.receipt.version == resolved.version
+ && registry_identity(current.receipt.registry.as_ref())
+ == registry_identity(Some(resolved));
+ if !same_target {
+ return Ok(None);
+ }
+ verify_package_integrity(¤t).await?;
+ if current.receipt.registry.as_ref() != Some(resolved) {
+ current.receipt.registry = Some(resolved.clone());
+ write_receipt(
+ &self.paths.receipt_path(expected_package_id),
+ ¤t.receipt,
)
- .with_suggestion("Extract the package archive and pass its directory with --from."));
+ .await?;
+ }
+ let installed = self.list().await?;
+ self.publish_snapshot_locked(&installed).await?;
+ Ok(Some(InstallResult {
+ changed: false,
+ extension: current,
+ }))
+ }
+
+ async fn install_prepared(
+ &self,
+ expected_package_id: &str,
+ source: &Path,
+ force: bool,
+ trust: ExtensionTrust,
+ registry: Option,
+ ) -> UseResult {
+ match (trust, registry.as_ref()) {
+ (ExtensionTrust::LocalExplicit, None) | (ExtensionTrust::RegistryTuf, Some(_)) => {}
+ _ => {
+ return Err(UseError::new(
+ "use.extension.trust_invalid",
+ "Extension installation provenance is internally inconsistent.",
+ ))
+ }
}
- let (manifest, manifest_bytes) = read_manifest(&source).await?;
+ let (manifest, manifest_bytes) = read_manifest(source).await?;
if manifest.package_id != expected_package_id {
return Err(UseError::new(
"use.extension.identity_mismatch",
@@ -392,7 +494,22 @@ impl ExtensionRegistry {
),
));
}
- validate_surface_files(&manifest, &source).await?;
+ if let Some(registry) = ®istry {
+ if registry.package_id != manifest.package_id || registry.version != manifest.version {
+ return Err(UseError::new(
+ "use.extension.registry_identity_mismatch",
+ format!(
+ "Signed target '{}@{}' does not match package manifest '{}@{}'.",
+ registry.package_id,
+ registry.version,
+ manifest.package_id,
+ manifest.version
+ ),
+ ));
+ }
+ }
+ validate_surface_files(&manifest, source).await?;
+ let package_digest = package_sha256(source).await?;
let _lock = RegistryLock::acquire(&self.paths.registry_lock_path())?;
let installed = self.list().await?;
@@ -414,9 +531,17 @@ impl ExtensionRegistry {
.iter()
.find(|extension| extension.receipt.package_id == expected_package_id)
{
- if !options.force
+ let current_package_digest = match ¤t.receipt.package_sha256 {
+ Some(digest) => digest.clone(),
+ None => package_sha256(¤t.receipt.package_root).await?,
+ };
+ let same_provenance = current.receipt.trust == trust
+ && registry_identity(current.receipt.registry.as_ref())
+ == registry_identity(registry.as_ref());
+ if !force
&& current.receipt.version == manifest.version
- && current.receipt.manifest_sha256 == digest
+ && current_package_digest == package_digest
+ && same_provenance
{
self.publish_snapshot_locked(&installed).await?;
return Ok(InstallResult {
@@ -424,7 +549,10 @@ impl ExtensionRegistry {
extension: current.clone(),
});
}
- if !options.force && current.receipt.version == manifest.version {
+ if !force
+ && current.receipt.version == manifest.version
+ && current_package_digest != package_digest
+ {
return Err(UseError::new(
"use.extension.version_conflict",
format!(
@@ -436,7 +564,7 @@ impl ExtensionRegistry {
}
}
- let package_parent = self.paths.package_parent(&expected_package_id);
+ let package_parent = self.paths.package_parent(expected_package_id);
fs::create_dir_all(&package_parent).await.map_err(|error| {
io_error("create extension package directory", &package_parent, error)
})?;
@@ -446,7 +574,7 @@ impl ExtensionRegistry {
.map_err(|error| {
io_error("create extension staging directory", &package_parent, error)
})?;
- copy_package(&source, staging.path()).await?;
+ copy_package(source, staging.path()).await?;
let (staged_manifest, staged_bytes) = read_manifest(staging.path()).await?;
if staged_manifest != manifest || sha256(&staged_bytes) != digest {
return Err(UseError::new(
@@ -455,11 +583,17 @@ impl ExtensionRegistry {
));
}
validate_surface_files(&staged_manifest, staging.path()).await?;
+ if package_sha256(staging.path()).await? != package_digest {
+ return Err(UseError::new(
+ "use.extension.package_changed",
+ "The extension package changed while it was staged.",
+ ));
+ }
let activation = unique_suffix();
let target = self
.paths
- .package_root(&expected_package_id, &manifest.version, &activation);
+ .package_root(expected_package_id, &manifest.version, &activation);
let staging = staging.keep();
if let Err(error) = fs::rename(&staging, &target).await {
let _ = fs::remove_dir_all(&staging).await;
@@ -474,17 +608,19 @@ impl ExtensionRegistry {
let receipt = ExtensionReceipt {
schema_version: RECEIPT_SCHEMA_VERSION,
- package_id: expected_package_id.clone(),
+ package_id: expected_package_id.to_string(),
component_id: format!("use/{expected_package_id}"),
route: manifest.route.clone(),
version: manifest.version.clone(),
package_root: target.clone(),
manifest_sha256: digest,
- trust: ExtensionTrust::LocalExplicit,
+ package_sha256: Some(package_digest),
+ trust,
+ registry,
installed_at_unix: unix_timestamp(),
enabled,
};
- let receipt_path = self.paths.receipt_path(&expected_package_id);
+ let receipt_path = self.paths.receipt_path(expected_package_id);
if let Err(error) = write_receipt(&receipt_path, &receipt).await {
let _ = fs::remove_dir_all(&target).await;
return Err(error);
@@ -650,6 +786,7 @@ impl ExtensionRegistry {
let _ = FileExt::unlock(&file);
return Ok(None);
}
+ verify_package_integrity(&extension).await?;
Ok(Some(ExtensionRouteLease { extension, file }))
}
@@ -699,6 +836,46 @@ impl ExtensionRegistry {
),
));
}
+ if receipt.package_sha256.as_deref().is_some_and(|digest| {
+ digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit())
+ }) {
+ return Err(UseError::new(
+ "use.extension.receipt_invalid",
+ format!(
+ "Extension receipt for '{}' has an invalid package digest.",
+ receipt.package_id
+ ),
+ ));
+ }
+ match (
+ receipt.trust,
+ receipt.registry.as_ref(),
+ receipt.package_sha256.as_ref(),
+ ) {
+ (ExtensionTrust::LocalExplicit, None, _) => {}
+ (ExtensionTrust::RegistryTuf, Some(registry), Some(_)) => {
+ registry.validate_provenance()?;
+ if registry.package_id != receipt.package_id || registry.version != receipt.version
+ {
+ return Err(UseError::new(
+ "use.extension.receipt_invalid",
+ format!(
+ "Registry provenance for '{}' does not match its receipt.",
+ receipt.package_id
+ ),
+ ));
+ }
+ }
+ _ => {
+ return Err(UseError::new(
+ "use.extension.receipt_invalid",
+ format!(
+ "Extension receipt for '{}' has inconsistent trust provenance.",
+ receipt.package_id
+ ),
+ ))
+ }
+ }
let package_id = normalize_package_id(&receipt.package_id)?;
if receipt.component_id != format!("use/{package_id}")
|| !owned_package_path(&self.paths, &package_id, &receipt.package_root)
@@ -730,6 +907,24 @@ impl ExtensionRegistry {
}
}
+async fn verify_package_integrity(extension: &InstalledExtension) -> UseResult<()> {
+ let Some(expected) = extension.receipt.package_sha256.as_deref() else {
+ return Ok(());
+ };
+ let actual = package_sha256(&extension.receipt.package_root).await?;
+ if actual != expected {
+ return Err(UseError::new(
+ "use.extension.package_digest_mismatch",
+ format!(
+ "Installed package '{}' no longer matches its recorded digest.",
+ extension.receipt.package_id
+ ),
+ )
+ .with_suggestion("Reinstall the extension from its trusted source."));
+ }
+ Ok(())
+}
+
fn route_bindings(installed: &[InstalledExtension]) -> Vec {
installed
.iter()
@@ -740,6 +935,7 @@ fn route_bindings(installed: &[InstalledExtension]) -> Vec UseResult {
Ok(value.to_string())
}
+fn registry_identity(registry: Option<&ResolvedRemotePackage>) -> Option<(&str, &str, &str, &str)> {
+ registry.map(|registry| {
+ (
+ registry.registry_name.as_str(),
+ registry.registry_url.as_str(),
+ registry.root_sha256.as_str(),
+ registry.sha256.as_str(),
+ )
+ })
+}
+
fn ensure_unique_routes(installed: &[InstalledExtension]) -> UseResult<()> {
for (index, extension) in installed.iter().enumerate() {
if let Some(conflict) = installed[index + 1..]
diff --git a/crates/extension/src/registry_tests.rs b/crates/extension/src/registry_tests.rs
index 59664552..875fd2bc 100644
--- a/crates/extension/src/registry_tests.rs
+++ b/crates/extension/src/registry_tests.rs
@@ -1,3 +1,5 @@
+use std::fs::File;
+use std::io::Write;
use std::time::Duration;
#[cfg(unix)]
@@ -48,6 +50,43 @@ fn registry(root: &Path) -> ExtensionRegistry {
ExtensionRegistry::new(ExtensionPaths::new(root.join("data"), root.join("state")))
}
+fn tar_package(source: &Path, archive: &Path) {
+ let file = File::create(archive).unwrap();
+ let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
+ let mut builder = tar::Builder::new(encoder);
+ builder.append_dir_all("package", source).unwrap();
+ builder.finish().unwrap();
+}
+
+fn zip_package(source: &Path, archive: &Path) {
+ let file = File::create(archive).unwrap();
+ let mut writer = zip::ZipWriter::new(file);
+ for relative in [
+ "a3s-use-extension.acl",
+ "bin/extension",
+ "skills/demo/SKILL.md",
+ ] {
+ let source_file = source.join(relative);
+ let mut options = zip::write::SimpleFileOptions::default()
+ .compression_method(zip::CompressionMethod::Deflated);
+ #[cfg(unix)]
+ {
+ let mode = std::fs::metadata(&source_file)
+ .unwrap()
+ .permissions()
+ .mode();
+ options = options.unix_permissions(mode);
+ }
+ writer
+ .start_file(format!("package/{relative}"), options)
+ .unwrap();
+ writer
+ .write_all(&std::fs::read(source_file).unwrap())
+ .unwrap();
+ }
+ writer.finish().unwrap();
+}
+
#[tokio::test]
async fn installs_lists_and_uninstalls_an_explicit_local_package() {
let temp = tempfile::tempdir().unwrap();
@@ -89,6 +128,63 @@ async fn installs_lists_and_uninstalls_an_explicit_local_package() {
assert!(registry.list().await.unwrap().is_empty());
}
+#[tokio::test]
+async fn installs_and_uninstalls_a_local_tar_package() {
+ let temp = tempfile::tempdir().unwrap();
+ let source = temp.path().join("source");
+ package(&source, "acme/slack", "slack", "1.2.0").await;
+ let archive = temp.path().join("acme-slack.tar.gz");
+ tar_package(&source, &archive);
+ let registry = registry(temp.path());
+
+ let result = registry
+ .install_local(
+ "acme/slack",
+ &archive,
+ InstallOptions {
+ allow_unsigned: true,
+ force: false,
+ },
+ )
+ .await
+ .unwrap();
+ assert!(result.changed);
+ assert_eq!(result.extension.receipt.package_id, "acme/slack");
+ assert!(result.extension.cli_executable().unwrap().is_file());
+
+ let removed = registry.uninstall("acme/slack").await.unwrap();
+ assert!(removed.changed);
+ assert!(registry.list().await.unwrap().is_empty());
+}
+
+#[tokio::test]
+async fn installs_and_uninstalls_a_local_zip_package() {
+ let temp = tempfile::tempdir().unwrap();
+ let source = temp.path().join("source");
+ package(&source, "acme/slack", "slack", "1.2.0").await;
+ let archive = temp.path().join("acme-slack.zip");
+ zip_package(&source, &archive);
+ let registry = registry(temp.path());
+
+ let result = registry
+ .install_local(
+ "acme/slack",
+ &archive,
+ InstallOptions {
+ allow_unsigned: true,
+ force: false,
+ },
+ )
+ .await
+ .unwrap();
+ assert!(result.changed);
+ assert_eq!(result.extension.receipt.package_id, "acme/slack");
+ assert!(result.extension.cli_executable().unwrap().is_file());
+
+ assert!(registry.uninstall("acme/slack").await.unwrap().changed);
+ assert!(registry.list().await.unwrap().is_empty());
+}
+
#[tokio::test]
async fn rejects_route_conflicts_and_untrusted_installs() {
let temp = tempfile::tempdir().unwrap();
@@ -198,6 +294,8 @@ async fn hot_upgrade_keeps_the_previous_package_until_inflight_routes_drain() {
let second = temp.path().join("second");
package(&first, "acme/slack", "slack", "1.0.0").await;
package(&second, "acme/slack", "slack", "2.0.0").await;
+ let second_archive = temp.path().join("second.tar.gz");
+ tar_package(&second, &second_archive);
let registry = registry(temp.path());
let first_install = registry
@@ -217,7 +315,7 @@ async fn hot_upgrade_keeps_the_previous_package_until_inflight_routes_drain() {
let second_install = registry
.install_local(
"acme/slack",
- &second,
+ &second_archive,
InstallOptions {
allow_unsigned: true,
force: false,
@@ -272,6 +370,16 @@ async fn forced_reactivation_of_identical_metadata_publishes_a_new_generation()
second.extension.receipt.package_root,
first.extension.receipt.package_root
);
+ assert_eq!(
+ second.extension.receipt.package_sha256,
+ first.extension.receipt.package_sha256
+ );
+ assert!(second
+ .extension
+ .receipt
+ .package_sha256
+ .as_deref()
+ .is_some_and(|digest| digest.len() == 64));
let second_snapshot = registry.snapshot().await.unwrap();
assert_eq!(second_snapshot.generation, 2);
assert_eq!(
@@ -280,6 +388,148 @@ async fn forced_reactivation_of_identical_metadata_publishes_a_new_generation()
);
}
+#[tokio::test]
+async fn same_version_changed_executable_requires_force_and_changes_package_digest() {
+ let temp = tempfile::tempdir().unwrap();
+ let source = temp.path().join("source");
+ package(&source, "acme/slack", "slack", "1.0.0").await;
+ let registry = registry(temp.path());
+
+ let first = registry
+ .install_local(
+ "acme/slack",
+ &source,
+ InstallOptions {
+ allow_unsigned: true,
+ force: false,
+ },
+ )
+ .await
+ .unwrap();
+ fs::write(
+ source.join("bin/extension"),
+ "#!/bin/sh\nprintf 'changed\\n'\n",
+ )
+ .await
+ .unwrap();
+
+ let error = registry
+ .install_local(
+ "acme/slack",
+ &source,
+ InstallOptions {
+ allow_unsigned: true,
+ force: false,
+ },
+ )
+ .await
+ .unwrap_err();
+ assert_eq!(error.code, "use.extension.version_conflict");
+
+ let second = registry
+ .install_local(
+ "acme/slack",
+ &source,
+ InstallOptions {
+ allow_unsigned: true,
+ force: true,
+ },
+ )
+ .await
+ .unwrap();
+ assert_ne!(
+ second.extension.receipt.package_root,
+ first.extension.receipt.package_root
+ );
+ assert_ne!(
+ second.extension.receipt.package_sha256,
+ first.extension.receipt.package_sha256
+ );
+ assert!(second.extension.receipt.package_sha256.is_some());
+ assert_eq!(
+ fs::read_to_string(second.extension.cli_executable().unwrap())
+ .await
+ .unwrap(),
+ "#!/bin/sh\nprintf 'changed\\n'\n"
+ );
+}
+
+#[tokio::test]
+async fn legacy_receipt_without_package_digest_remains_readable_and_idempotent() {
+ let temp = tempfile::tempdir().unwrap();
+ let source = temp.path().join("source");
+ package(&source, "acme/slack", "slack", "1.0.0").await;
+ let registry = registry(temp.path());
+
+ registry
+ .install_local(
+ "acme/slack",
+ &source,
+ InstallOptions {
+ allow_unsigned: true,
+ force: false,
+ },
+ )
+ .await
+ .unwrap();
+
+ let receipt_path = registry.paths().receipt_path("acme/slack");
+ let mut legacy: serde_json::Value =
+ serde_json::from_slice(&fs::read(&receipt_path).await.unwrap()).unwrap();
+ legacy.as_object_mut().unwrap().remove("packageSha256");
+ fs::write(&receipt_path, serde_json::to_vec_pretty(&legacy).unwrap())
+ .await
+ .unwrap();
+
+ let installed = registry.get("acme/slack").await.unwrap().unwrap();
+ assert_eq!(installed.receipt.package_sha256, None);
+
+ let unchanged = registry
+ .install_local(
+ "acme/slack",
+ &source,
+ InstallOptions {
+ allow_unsigned: true,
+ force: false,
+ },
+ )
+ .await
+ .unwrap();
+ assert!(!unchanged.changed);
+ assert_eq!(unchanged.extension.receipt.package_sha256, None);
+}
+
+#[tokio::test]
+async fn receipt_rejects_an_invalid_optional_package_digest() {
+ let temp = tempfile::tempdir().unwrap();
+ let source = temp.path().join("source");
+ package(&source, "acme/slack", "slack", "1.0.0").await;
+ let registry = registry(temp.path());
+
+ registry
+ .install_local(
+ "acme/slack",
+ &source,
+ InstallOptions {
+ allow_unsigned: true,
+ force: false,
+ },
+ )
+ .await
+ .unwrap();
+
+ let receipt_path = registry.paths().receipt_path("acme/slack");
+ let mut invalid: serde_json::Value =
+ serde_json::from_slice(&fs::read(&receipt_path).await.unwrap()).unwrap();
+ invalid["packageSha256"] = serde_json::json!("not-a-sha256");
+ fs::write(&receipt_path, serde_json::to_vec_pretty(&invalid).unwrap())
+ .await
+ .unwrap();
+
+ let error = registry.get("acme/slack").await.unwrap_err();
+ assert_eq!(error.code, "use.extension.receipt_invalid");
+}
+
#[tokio::test]
async fn snapshot_reconciles_a_pre_activation_identity_binding() {
let temp = tempfile::tempdir().unwrap();
diff --git a/crates/extension/src/remote.rs b/crates/extension/src/remote.rs
new file mode 100644
index 00000000..8bd205b1
--- /dev/null
+++ b/crates/extension/src/remote.rs
@@ -0,0 +1,970 @@
+//! TUF-backed remote extension registry resolution.
+//!
+//! The trusted root is pinned out of band by SHA-256. Tough then verifies the
+//! complete root/timestamp/snapshot/targets chain, enforces expiration, and
+//! persists metadata versions in its datastore to reject rollback attacks.
+
+use std::collections::BTreeSet;
+use std::fs::{File, OpenOptions};
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use a3s_use_core::{UseError, UseResult};
+use fs2::FileExt;
+use semver::Version;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use tempfile::TempDir;
+use tokio::fs;
+use tokio::io::AsyncWriteExt;
+use tough::{ExpirationEnforcement, HttpTransportBuilder, Limits, Prefix, Repository};
+use tough::{RepositoryLoader, TargetName};
+use url::Url;
+
+use super::package::{activate_temporary_file, io_error, sync_parent_directory, unique_suffix};
+
+const ROOT_NAME: &str = "root.json";
+const ROOT_CACHE_NAME: &str = "bootstrap-root.json";
+const REGISTRY_METADATA_KEY: &str = "a3s";
+const REGISTRY_TARGET_SCHEMA_VERSION: u32 = 1;
+const MAX_BOOTSTRAP_ROOT_BYTES: u64 = 1024 * 1024;
+const MAX_REMOTE_ARCHIVE_BYTES: u64 = 512 * 1024 * 1024;
+const MAX_ROOT_UPDATES: u64 = 64;
+
+/// One configured registry whose TUF root is pinned out of band.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TrustedRegistry {
+ name: String,
+ base_url: Url,
+ root_sha256: String,
+ trusted_root_path: Option,
+ datastore: PathBuf,
+}
+
+impl TrustedRegistry {
+ pub fn new(
+ name: impl Into,
+ base_url: impl AsRef,
+ root_sha256: impl AsRef,
+ trusted_root_path: Option,
+ datastore: PathBuf,
+ ) -> UseResult {
+ let name = name.into();
+ validate_registry_name(&name)?;
+ let base_url = normalize_registry_url(base_url.as_ref())?;
+ let root_sha256 = normalize_sha256(root_sha256.as_ref(), "registry trust root")?;
+ if !datastore.is_absolute() {
+ return Err(UseError::new(
+ "use.extension.registry_path_invalid",
+ "The TUF metadata datastore must be an absolute path.",
+ ));
+ }
+ if trusted_root_path
+ .as_ref()
+ .is_some_and(|path| !path.is_absolute())
+ {
+ return Err(UseError::new(
+ "use.extension.registry_path_invalid",
+ "The trusted TUF root path must be absolute.",
+ ));
+ }
+ Ok(Self {
+ name,
+ base_url,
+ root_sha256,
+ trusted_root_path,
+ datastore,
+ })
+ }
+
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+
+ pub fn base_url(&self) -> &Url {
+ &self.base_url
+ }
+
+ pub fn root_sha256(&self) -> &str {
+ &self.root_sha256
+ }
+
+ pub fn datastore(&self) -> &Path {
+ &self.datastore
+ }
+
+ fn metadata_url(&self) -> UseResult {
+ self.base_url.join("metadata/").map_err(|error| {
+ UseError::new(
+ "use.extension.registry_url_invalid",
+ format!("Failed to resolve the registry metadata URL: {error}"),
+ )
+ })
+ }
+
+ fn targets_url(&self) -> UseResult {
+ self.base_url.join("targets/").map_err(|error| {
+ UseError::new(
+ "use.extension.registry_url_invalid",
+ format!("Failed to resolve the registry targets URL: {error}"),
+ )
+ })
+ }
+}
+
+/// Exact signed target selected from a verified TUF repository.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ResolvedRemotePackage {
+ pub registry_name: String,
+ pub registry_url: String,
+ pub root_sha256: String,
+ pub root_version: u64,
+ pub timestamp_version: u64,
+ pub snapshot_version: u64,
+ pub targets_version: u64,
+ pub package_id: String,
+ pub version: String,
+ pub channel: String,
+ pub target: String,
+ pub target_name: String,
+ pub archive_name: String,
+ pub length: u64,
+ pub sha256: String,
+}
+
+/// Signed metadata versions observed after a complete TUF refresh.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct VerifiedRegistryMetadata {
+ pub registry_name: String,
+ pub registry_url: String,
+ pub root_sha256: String,
+ pub root_version: u64,
+ pub timestamp_version: u64,
+ pub snapshot_version: u64,
+ pub targets_version: u64,
+ pub package_targets: u64,
+}
+
+impl ResolvedRemotePackage {
+ pub fn plan_digest(&self) -> UseResult {
+ let bytes = serde_json::to_vec(self).map_err(|error| {
+ UseError::new(
+ "use.extension.registry_plan_invalid",
+ format!("Failed to encode the resolved registry plan: {error}"),
+ )
+ })?;
+ Ok(format!("{:x}", Sha256::digest(bytes)))
+ }
+
+ pub fn verify_expected_plan(&self, expected: Option<&str>) -> UseResult<()> {
+ let Some(expected) = expected else {
+ return Ok(());
+ };
+ let expected = normalize_sha256(expected, "expected registry plan")?;
+ let actual = self.plan_digest()?;
+ if expected == actual {
+ return Ok(());
+ }
+ Err(UseError::new(
+ "use.extension.registry_plan_mismatch",
+ "The signed registry target changed after review.",
+ )
+ .with_detail("expected", expected)
+ .with_detail("actual", actual))
+ }
+
+ pub(crate) fn validate_provenance(&self) -> UseResult<()> {
+ validate_registry_name(&self.registry_name)?;
+ let normalized_url = normalize_registry_url(&self.registry_url)?;
+ if normalized_url.as_str() != self.registry_url {
+ return Err(UseError::new(
+ "use.extension.receipt_invalid",
+ "The registry URL in the extension receipt is not canonical.",
+ ));
+ }
+ normalize_sha256(&self.root_sha256, "registry trust root")?;
+ normalize_sha256(&self.sha256, "registry target")?;
+ if self.root_version == 0
+ || self.timestamp_version == 0
+ || self.snapshot_version == 0
+ || self.targets_version == 0
+ || self.length == 0
+ || self.length > MAX_REMOTE_ARCHIVE_BYTES
+ || !super::valid_package_id(&self.package_id)
+ || Version::parse(&self.version).is_err()
+ {
+ return Err(UseError::new(
+ "use.extension.receipt_invalid",
+ "The registry provenance in the extension receipt is invalid.",
+ ));
+ }
+ validate_channel(&self.channel)?;
+ let host = host_target()?;
+ if self.target != host && self.target != "any" {
+ return Err(UseError::new(
+ "use.extension.receipt_invalid",
+ "The installed registry target does not match this platform.",
+ ));
+ }
+ let target_name = TargetName::new(self.target_name.clone()).map_err(|error| {
+ UseError::new(
+ "use.extension.receipt_invalid",
+ format!("The registry target name in the receipt is invalid: {error}"),
+ )
+ })?;
+ validate_target_name(
+ &target_name,
+ &RegistryTargetMetadata {
+ schema_version: REGISTRY_TARGET_SCHEMA_VERSION,
+ package_id: self.package_id.clone(),
+ version: self.version.clone(),
+ channel: self.channel.clone(),
+ target: self.target.clone(),
+ },
+ )?;
+ if target_name.raw().rsplit('/').next() != Some(self.archive_name.as_str()) {
+ return Err(UseError::new(
+ "use.extension.receipt_invalid",
+ "The registry archive name does not match its signed target path.",
+ ));
+ }
+ Ok(())
+ }
+}
+
+/// Verified repository state retained until its exact target is downloaded.
+pub struct PreparedRemotePackage {
+ repository: Repository,
+ target_name: TargetName,
+ resolved: ResolvedRemotePackage,
+}
+
+impl std::fmt::Debug for PreparedRemotePackage {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter
+ .debug_struct("PreparedRemotePackage")
+ .field("resolved", &self.resolved)
+ .finish_non_exhaustive()
+ }
+}
+
+impl PreparedRemotePackage {
+ pub fn resolved(&self) -> &ResolvedRemotePackage {
+ &self.resolved
+ }
+
+ pub async fn download(self) -> UseResult {
+ let temporary = tokio::task::spawn_blocking(tempfile::tempdir)
+ .await
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.registry_download_failed",
+ format!("Failed to create the remote package staging task: {error}"),
+ )
+ })?
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.registry_download_failed",
+ format!("Failed to create remote package staging: {error}"),
+ )
+ })?;
+ self.repository
+ .save_target(&self.target_name, temporary.path(), Prefix::None)
+ .await
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.registry_download_failed",
+ format!(
+ "Failed to download and verify TUF target '{}': {error}",
+ self.resolved.target_name
+ ),
+ )
+ })?;
+ let path = temporary.path().join(self.target_name.resolved());
+ let metadata = fs::metadata(&path)
+ .await
+ .map_err(|error| io_error("inspect downloaded TUF target", &path, error))?;
+ if !metadata.is_file() || metadata.len() != self.resolved.length {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ "The downloaded TUF target does not match its signed length.",
+ ));
+ }
+ Ok(DownloadedRemotePackage {
+ path,
+ resolved: self.resolved,
+ _temporary: temporary,
+ })
+ }
+}
+
+/// One downloaded archive kept alive through extension activation.
+#[derive(Debug)]
+pub struct DownloadedRemotePackage {
+ path: PathBuf,
+ resolved: ResolvedRemotePackage,
+ _temporary: TempDir,
+}
+
+impl DownloadedRemotePackage {
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+
+ pub fn resolved(&self) -> &ResolvedRemotePackage {
+ &self.resolved
+ }
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+struct RegistryTargetMetadata {
+ schema_version: u32,
+ package_id: String,
+ version: String,
+ channel: String,
+ target: String,
+}
+
+struct MetadataLock(File);
+
+impl Drop for MetadataLock {
+ fn drop(&mut self) {
+ let _ = FileExt::unlock(&self.0);
+ }
+}
+
+/// Load and verify a TUF repository, then select one exact extension target.
+pub async fn prepare_remote_package(
+ registry: &TrustedRegistry,
+ package_id: &str,
+ requested_version: Option<&str>,
+ channel: &str,
+ expected_plan_digest: Option<&str>,
+) -> UseResult {
+ if !super::valid_package_id(package_id) {
+ return Err(UseError::new(
+ "use.extension.id_invalid",
+ "Extension IDs must be '/' lowercase identifiers.",
+ ));
+ }
+ let requested_version = requested_version
+ .map(|version| {
+ Version::parse(version).map_err(|error| {
+ UseError::new(
+ "use.extension.version_invalid",
+ format!("Invalid requested extension version: {error}"),
+ )
+ })
+ })
+ .transpose()?;
+ validate_channel(channel)?;
+ let repository = load_repository(registry).await?;
+
+ let host_target = host_target()?;
+ let mut candidates = Vec::new();
+ let mut identities = BTreeSet::new();
+ for (target_name, target) in repository.all_targets() {
+ let Some(metadata) = target.custom.get(REGISTRY_METADATA_KEY) else {
+ continue;
+ };
+ let metadata: RegistryTargetMetadata =
+ serde_json::from_value(metadata.clone()).map_err(|error| {
+ UseError::new(
+ "use.extension.registry_target_invalid",
+ format!(
+ "TUF target '{}' has invalid A3S metadata: {error}",
+ target_name.raw()
+ ),
+ )
+ })?;
+ validate_target_metadata(target_name, target, &metadata)?;
+ let identity = (
+ metadata.package_id.clone(),
+ metadata.version.clone(),
+ metadata.channel.clone(),
+ metadata.target.clone(),
+ );
+ if !identities.insert(identity) {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ "The TUF repository contains duplicate A3S package targets.",
+ ));
+ }
+ if metadata.package_id != package_id
+ || metadata.channel != channel
+ || (metadata.target != host_target && metadata.target != "any")
+ {
+ continue;
+ }
+ let version = Version::parse(&metadata.version).map_err(|error| {
+ UseError::new(
+ "use.extension.registry_target_invalid",
+ format!(
+ "TUF target '{}' declares an invalid version: {error}",
+ target_name.raw()
+ ),
+ )
+ })?;
+ if requested_version
+ .as_ref()
+ .is_some_and(|requested| requested != &version)
+ {
+ continue;
+ }
+ candidates.push((version, metadata, target_name.clone(), target.clone()));
+ }
+ candidates.sort_by(|left, right| {
+ left.0
+ .cmp(&right.0)
+ .then_with(|| (left.1.target == host_target).cmp(&(right.1.target == host_target)))
+ .then_with(|| left.2.raw().cmp(right.2.raw()))
+ });
+ let Some((version, metadata, target_name, target)) = candidates.pop() else {
+ return Err(UseError::new(
+ "use.extension.registry_package_missing",
+ format!(
+ "Registry '{}' has no '{}' package for channel '{}' and target '{}'.",
+ registry.name, package_id, channel, host_target
+ ),
+ ));
+ };
+ if candidates.last().is_some_and(|candidate| {
+ candidate.0 == version
+ && (candidate.1.target == host_target) == (metadata.target == host_target)
+ }) {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ "The TUF repository resolves the same package version to multiple targets.",
+ ));
+ }
+ let archive_name = target_name
+ .raw()
+ .rsplit('/')
+ .next()
+ .unwrap_or_default()
+ .to_string();
+ let resolved = ResolvedRemotePackage {
+ registry_name: registry.name.clone(),
+ registry_url: registry.base_url.to_string(),
+ root_sha256: registry.root_sha256.clone(),
+ root_version: repository.root().signed.version.get(),
+ timestamp_version: repository.timestamp().signed.version.get(),
+ snapshot_version: repository.snapshot().signed.version.get(),
+ targets_version: repository.targets().signed.version.get(),
+ package_id: package_id.to_string(),
+ version: version.to_string(),
+ channel: channel.to_string(),
+ target: metadata.target,
+ target_name: target_name.raw().to_string(),
+ archive_name,
+ length: target.length,
+ sha256: hex_lower(target.hashes.sha256.as_ref()),
+ };
+ resolved.verify_expected_plan(expected_plan_digest)?;
+ Ok(PreparedRemotePackage {
+ repository,
+ target_name,
+ resolved,
+ })
+}
+
+/// Refresh and fully verify a registry without downloading any package target.
+pub async fn refresh_remote_registry(
+ registry: &TrustedRegistry,
+) -> UseResult {
+ let repository = load_repository(registry).await?;
+ let mut identities = BTreeSet::new();
+ let mut package_targets = 0_u64;
+ for (target_name, target) in repository.all_targets() {
+ let Some(metadata) = target.custom.get(REGISTRY_METADATA_KEY) else {
+ continue;
+ };
+ let metadata: RegistryTargetMetadata =
+ serde_json::from_value(metadata.clone()).map_err(|error| {
+ UseError::new(
+ "use.extension.registry_target_invalid",
+ format!(
+ "TUF target '{}' has invalid A3S metadata: {error}",
+ target_name.raw()
+ ),
+ )
+ })?;
+ validate_target_metadata(target_name, target, &metadata)?;
+ let identity = (
+ metadata.package_id,
+ metadata.version,
+ metadata.channel,
+ metadata.target,
+ );
+ if !identities.insert(identity) {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ "The TUF repository contains duplicate A3S package targets.",
+ ));
+ }
+ package_targets = package_targets.checked_add(1).ok_or_else(|| {
+ UseError::new(
+ "use.extension.registry_target_invalid",
+ "The TUF repository contains too many package targets.",
+ )
+ })?;
+ }
+ Ok(VerifiedRegistryMetadata {
+ registry_name: registry.name.clone(),
+ registry_url: registry.base_url.to_string(),
+ root_sha256: registry.root_sha256.clone(),
+ root_version: repository.root().signed.version.get(),
+ timestamp_version: repository.timestamp().signed.version.get(),
+ snapshot_version: repository.snapshot().signed.version.get(),
+ targets_version: repository.targets().signed.version.get(),
+ package_targets,
+ })
+}
+
+async fn load_repository(registry: &TrustedRegistry) -> UseResult {
+ ensure_metadata_directory(®istry.datastore).await?;
+ let lock = acquire_metadata_lock(®istry.datastore)?;
+ let root = load_trusted_root(registry).await?;
+ let metadata_url = registry.metadata_url()?;
+ let targets_url = registry.targets_url()?;
+ let transport = HttpTransportBuilder::new()
+ .timeout(Duration::from_secs(300))
+ .connect_timeout(Duration::from_secs(15))
+ .tries(3)
+ .build();
+ let repository = RepositoryLoader::new(&root, metadata_url, targets_url)
+ .transport(transport)
+ .datastore(®istry.datastore)
+ .limits(Limits {
+ max_root_size: MAX_BOOTSTRAP_ROOT_BYTES,
+ max_targets_size: 10 * 1024 * 1024,
+ max_timestamp_size: 1024 * 1024,
+ max_snapshot_size: 1024 * 1024,
+ max_root_updates: MAX_ROOT_UPDATES,
+ })
+ .expiration_enforcement(ExpirationEnforcement::Safe)
+ .load()
+ .await
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.registry_untrusted",
+ format!(
+ "TUF verification failed for registry '{}': {error}",
+ registry.name
+ ),
+ )
+ })?;
+ drop(lock);
+ Ok(repository)
+}
+
+fn validate_target_metadata(
+ target_name: &TargetName,
+ target: &tough::schema::Target,
+ metadata: &RegistryTargetMetadata,
+) -> UseResult<()> {
+ if metadata.schema_version != REGISTRY_TARGET_SCHEMA_VERSION {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ format!(
+ "TUF target '{}' uses unsupported A3S metadata schema {}.",
+ target_name.raw(),
+ metadata.schema_version
+ ),
+ ));
+ }
+ if !super::valid_package_id(&metadata.package_id) {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ format!(
+ "TUF target '{}' has an invalid package ID.",
+ target_name.raw()
+ ),
+ ));
+ }
+ Version::parse(&metadata.version).map_err(|error| {
+ UseError::new(
+ "use.extension.registry_target_invalid",
+ format!(
+ "TUF target '{}' has an invalid package version: {error}",
+ target_name.raw()
+ ),
+ )
+ })?;
+ validate_channel(&metadata.channel)?;
+ validate_target_name(target_name, metadata)?;
+ if target.length == 0 || target.length > MAX_REMOTE_ARCHIVE_BYTES {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ format!(
+ "TUF target '{}' exceeds the supported package size.",
+ target_name.raw()
+ ),
+ ));
+ }
+ let digest = target.hashes.sha256.as_ref();
+ if digest.len() != 32 {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ format!(
+ "TUF target '{}' does not have a valid SHA-256 digest.",
+ target_name.raw()
+ ),
+ ));
+ }
+ Ok(())
+}
+
+fn validate_target_name(
+ target_name: &TargetName,
+ metadata: &RegistryTargetMetadata,
+) -> UseResult<()> {
+ let raw = target_name.raw();
+ if raw != target_name.resolved()
+ || raw.starts_with('/')
+ || raw.contains('\\')
+ || raw.split('/').any(str::is_empty)
+ {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ format!("TUF target '{raw}' is not a portable package path."),
+ ));
+ }
+ let archive = raw.rsplit('/').next().unwrap_or_default();
+ if !(archive.ends_with(".tar.gz") || archive.ends_with(".tgz") || archive.ends_with(".zip")) {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ format!("TUF target '{raw}' is not a supported package archive."),
+ ));
+ }
+ let expected_prefix = format!(
+ "extensions/{}/{}/{}/{}/",
+ metadata.package_id, metadata.version, metadata.channel, metadata.target
+ );
+ if !raw.starts_with(&expected_prefix) {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ format!("TUF target '{raw}' must be published below '{expected_prefix}'."),
+ ));
+ }
+ Ok(())
+}
+
+fn validate_channel(channel: &str) -> UseResult<()> {
+ if matches!(channel, "stable" | "beta" | "nightly") {
+ Ok(())
+ } else {
+ Err(UseError::new(
+ "use.extension.registry_channel_invalid",
+ format!("Unsupported extension release channel '{channel}'."),
+ ))
+ }
+}
+
+fn host_target() -> UseResult {
+ match (std::env::consts::OS, std::env::consts::ARCH) {
+ ("macos", "aarch64") => Ok("darwin-arm64".to_string()),
+ ("macos", "x86_64") => Ok("darwin-x86_64".to_string()),
+ ("linux", "aarch64") => Ok("linux-arm64".to_string()),
+ ("linux", "x86_64") => Ok("linux-x86_64".to_string()),
+ ("windows", "x86_64") => Ok("windows-x86_64".to_string()),
+ (os, arch) => Err(UseError::new(
+ "use.extension.registry_target_unsupported",
+ format!("Remote extension packages are unavailable for {os}-{arch}."),
+ )),
+ }
+}
+
+async fn ensure_metadata_directory(path: &Path) -> UseResult<()> {
+ fs::create_dir_all(path)
+ .await
+ .map_err(|error| io_error("create TUF metadata datastore", path, error))?;
+ let metadata = fs::symlink_metadata(path)
+ .await
+ .map_err(|error| io_error("inspect TUF metadata datastore", path, error))?;
+ if metadata.file_type().is_symlink() || !metadata.is_dir() {
+ return Err(UseError::new(
+ "use.extension.registry_path_invalid",
+ format!(
+ "The TUF metadata datastore '{}' must be a real directory.",
+ path.display()
+ ),
+ ));
+ }
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
+ .await
+ .map_err(|error| io_error("secure TUF metadata datastore", path, error))?;
+ }
+ Ok(())
+}
+
+fn acquire_metadata_lock(datastore: &Path) -> UseResult {
+ let path = datastore.join(".metadata.lock");
+ let file = OpenOptions::new()
+ .create(true)
+ .read(true)
+ .write(true)
+ .truncate(false)
+ .open(&path)
+ .map_err(|error| io_error("open TUF metadata lock", &path, error))?;
+ file.try_lock_exclusive().map_err(|error| {
+ UseError::new(
+ "use.extension.registry_busy",
+ format!(
+ "Another process is updating registry metadata '{}': {error}",
+ datastore.display()
+ ),
+ )
+ })?;
+ Ok(MetadataLock(file))
+}
+
+async fn load_trusted_root(registry: &TrustedRegistry) -> UseResult> {
+ let explicit = registry.trusted_root_path.as_deref();
+ let cache = registry.datastore.join(ROOT_CACHE_NAME);
+ let path = explicit.unwrap_or(&cache);
+ let bytes = match fs::read(path).await {
+ Ok(bytes) => bytes,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound && explicit.is_none() => {
+ let metadata_url = registry.metadata_url()?;
+ let root_url = metadata_url.join(ROOT_NAME).map_err(|error| {
+ UseError::new(
+ "use.extension.registry_url_invalid",
+ format!("Failed to resolve the bootstrap root URL: {error}"),
+ )
+ })?;
+ let bytes = download_bootstrap_root(&root_url).await?;
+ verify_root_digest(registry, &bytes)?;
+ write_bootstrap_root(&cache, &bytes).await?;
+ bytes
+ }
+ Err(error) => return Err(io_error("read trusted TUF root", path, error)),
+ };
+ if bytes.len() as u64 > MAX_BOOTSTRAP_ROOT_BYTES {
+ return Err(UseError::new(
+ "use.extension.registry_root_invalid",
+ "The trusted TUF root exceeds the one MiB limit.",
+ ));
+ }
+ verify_root_digest(registry, &bytes)?;
+ Ok(bytes)
+}
+
+async fn download_bootstrap_root(url: &Url) -> UseResult> {
+ validate_download_url(url)?;
+ let client = reqwest::Client::builder()
+ .user_agent("a3s-use-extension/0.1")
+ .connect_timeout(Duration::from_secs(15))
+ .timeout(Duration::from_secs(30))
+ .redirect(reqwest::redirect::Policy::limited(5))
+ .build()
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.registry_download_failed",
+ format!("Failed to build the registry client: {error}"),
+ )
+ })?;
+ let mut response = client.get(url.clone()).send().await.map_err(|error| {
+ UseError::new(
+ "use.extension.registry_download_failed",
+ format!("Failed to download the bootstrap TUF root: {error}"),
+ )
+ })?;
+ validate_download_url(response.url())?;
+ if !response.status().is_success() {
+ return Err(UseError::new(
+ "use.extension.registry_download_failed",
+ format!(
+ "Bootstrap TUF root download returned HTTP {}.",
+ response.status()
+ ),
+ ));
+ }
+ if response
+ .content_length()
+ .is_some_and(|length| length > MAX_BOOTSTRAP_ROOT_BYTES)
+ {
+ return Err(UseError::new(
+ "use.extension.registry_root_invalid",
+ "The bootstrap TUF root exceeds the one MiB limit.",
+ ));
+ }
+ let mut bytes = Vec::with_capacity(
+ response
+ .content_length()
+ .unwrap_or_default()
+ .min(MAX_BOOTSTRAP_ROOT_BYTES) as usize,
+ );
+ while let Some(chunk) = response.chunk().await.map_err(|error| {
+ UseError::new(
+ "use.extension.registry_download_failed",
+ format!("Failed to read the bootstrap TUF root: {error}"),
+ )
+ })? {
+ if bytes.len().saturating_add(chunk.len()) as u64 > MAX_BOOTSTRAP_ROOT_BYTES {
+ return Err(UseError::new(
+ "use.extension.registry_root_invalid",
+ "The bootstrap TUF root exceeds the one MiB limit.",
+ ));
+ }
+ bytes.extend_from_slice(&chunk);
+ }
+ Ok(bytes)
+}
+
+fn verify_root_digest(registry: &TrustedRegistry, bytes: &[u8]) -> UseResult<()> {
+ let actual = format!("{:x}", Sha256::digest(bytes));
+ if actual == registry.root_sha256 {
+ return Ok(());
+ }
+ Err(UseError::new(
+ "use.extension.registry_root_mismatch",
+ format!(
+ "Registry '{}' bootstrap root does not match its pinned SHA-256.",
+ registry.name
+ ),
+ )
+ .with_detail("expected", registry.root_sha256.clone())
+ .with_detail("actual", actual))
+}
+
+async fn write_bootstrap_root(path: &Path, bytes: &[u8]) -> UseResult<()> {
+ let parent = path.parent().ok_or_else(|| {
+ UseError::new(
+ "use.extension.registry_path_invalid",
+ "The bootstrap TUF root cache has no parent directory.",
+ )
+ })?;
+ let temporary = parent.join(format!(".root-{}.tmp", unique_suffix()));
+ let mut options = fs::OpenOptions::new();
+ options.create_new(true).write(true);
+ let mut file = options
+ .open(&temporary)
+ .await
+ .map_err(|error| io_error("create bootstrap TUF root cache", &temporary, error))?;
+ if let Err(error) = file.write_all(bytes).await {
+ let _ = fs::remove_file(&temporary).await;
+ return Err(io_error(
+ "write bootstrap TUF root cache",
+ &temporary,
+ error,
+ ));
+ }
+ if let Err(error) = file.sync_all().await {
+ let _ = fs::remove_file(&temporary).await;
+ return Err(io_error("sync bootstrap TUF root cache", &temporary, error));
+ }
+ drop(file);
+ if let Err(error) = activate_temporary_file(
+ temporary.clone(),
+ path.to_path_buf(),
+ "activate bootstrap TUF root cache",
+ )
+ .await
+ {
+ let _ = fs::remove_file(&temporary).await;
+ return Err(error);
+ }
+ sync_parent_directory(parent, "TUF metadata").await
+}
+
+fn normalize_registry_url(value: &str) -> UseResult {
+ let mut url = Url::parse(value).map_err(|error| {
+ UseError::new(
+ "use.extension.registry_url_invalid",
+ format!("Invalid registry URL: {error}"),
+ )
+ })?;
+ validate_download_url(&url)?;
+ if !url.username().is_empty()
+ || url.password().is_some()
+ || url.query().is_some()
+ || url.fragment().is_some()
+ {
+ return Err(UseError::new(
+ "use.extension.registry_url_invalid",
+ "Registry URLs must not contain credentials, query parameters, or fragments.",
+ ));
+ }
+ if !url.path().ends_with('/') {
+ let path = format!("{}/", url.path());
+ url.set_path(&path);
+ }
+ Ok(url)
+}
+
+fn validate_download_url(url: &Url) -> UseResult<()> {
+ let https = url.scheme() == "https";
+ let loopback_http = url.scheme() == "http"
+ && url.host_str().is_some_and(|host| {
+ host.eq_ignore_ascii_case("localhost")
+ || host
+ .parse::()
+ .is_ok_and(|ip| ip.is_loopback())
+ });
+ if https || loopback_http {
+ Ok(())
+ } else {
+ Err(UseError::new(
+ "use.extension.registry_url_invalid",
+ "Registry downloads require HTTPS; HTTP is accepted only on loopback for local testing.",
+ ))
+ }
+}
+
+fn validate_registry_name(name: &str) -> UseResult<()> {
+ let mut characters = name.chars();
+ if characters
+ .next()
+ .is_some_and(|character| character.is_ascii_lowercase())
+ && characters.all(|character| {
+ character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
+ })
+ {
+ Ok(())
+ } else {
+ Err(UseError::new(
+ "use.extension.registry_name_invalid",
+ "Registry names use lowercase letters, digits, and hyphens and start with a letter.",
+ ))
+ }
+}
+
+fn normalize_sha256(value: &str, label: &str) -> UseResult {
+ let value = value.strip_prefix("sha256:").unwrap_or(value);
+ if value.len() == 64
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
+ {
+ Ok(value.to_string())
+ } else {
+ Err(UseError::new(
+ "use.extension.registry_digest_invalid",
+ format!("The {label} must be exactly 64 lowercase hexadecimal characters."),
+ ))
+ }
+}
+
+fn hex_lower(bytes: &[u8]) -> String {
+ let mut output = String::with_capacity(bytes.len() * 2);
+ for byte in bytes {
+ use std::fmt::Write as _;
+ let _ = write!(output, "{byte:02x}");
+ }
+ output
+}
+
+#[cfg(test)]
+#[path = "tuf_test_support.rs"]
+mod test_support;
+
+#[cfg(test)]
+#[path = "remote_tests.rs"]
+mod tests;
diff --git a/crates/extension/src/remote_tests.rs b/crates/extension/src/remote_tests.rs
new file mode 100644
index 00000000..0b538dc8
--- /dev/null
+++ b/crates/extension/src/remote_tests.rs
@@ -0,0 +1,310 @@
+use std::path::PathBuf;
+
+use super::test_support::{
+ extension_archive, find_subslice, TestRepository, TestServer, EXPIRED, FUTURE, PACKAGE_VERSION,
+};
+use super::*;
+use crate::{ExtensionPaths, ExtensionRegistry, ExtensionTrust};
+
+#[tokio::test]
+async fn tuf_refresh_verifies_metadata_without_downloading_targets() {
+ let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 7, FUTURE);
+ let server = TestServer::start(repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let trusted = trusted_registry(&server, &repository, temp.path().join("tuf"));
+
+ let metadata = refresh_remote_registry(&trusted).await.unwrap();
+
+ assert_eq!(metadata.registry_name, "fixture");
+ assert_eq!(metadata.root_version, 1);
+ assert_eq!(metadata.timestamp_version, 7);
+ assert_eq!(metadata.snapshot_version, 7);
+ assert_eq!(metadata.targets_version, 7);
+ assert_eq!(metadata.package_targets, 1);
+ assert!(server
+ .requests()
+ .iter()
+ .all(|request| !request.starts_with("/targets/")));
+}
+
+#[tokio::test]
+async fn tuf_install_records_signed_provenance_and_converges() {
+ let archive = extension_archive(PACKAGE_VERSION);
+ let repository = TestRepository::new(archive, 1, FUTURE);
+ let server = TestServer::start(repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let trusted = trusted_registry(&server, &repository, temp.path().join("tuf"));
+
+ let prepared = prepare_remote_package(&trusted, "acme/slack", None, "stable", None)
+ .await
+ .unwrap();
+ let digest = prepared.resolved().plan_digest().unwrap();
+ drop(prepared);
+ assert!(server
+ .requests()
+ .iter()
+ .all(|request| !request.starts_with("/targets/")));
+
+ let paths = ExtensionPaths::new(
+ temp.path().join("data"),
+ temp.path().join("extension-state"),
+ );
+ let registry = ExtensionRegistry::new(paths);
+ let installed = registry
+ .install_remote("acme/slack", &trusted, None, "stable", Some(&digest), false)
+ .await
+ .unwrap();
+ assert!(installed.changed);
+ assert_eq!(
+ installed.extension.receipt.trust,
+ ExtensionTrust::RegistryTuf
+ );
+ let provenance = installed.extension.receipt.registry.as_ref().unwrap();
+ assert_eq!(provenance.package_id, "acme/slack");
+ assert_eq!(provenance.version, PACKAGE_VERSION);
+ assert_eq!(provenance.sha256, repository.target_sha256);
+ assert!(installed.extension.cli_executable().unwrap().is_file());
+
+ server.clear_requests();
+ let second = registry
+ .install_remote("acme/slack", &trusted, None, "stable", Some(&digest), false)
+ .await
+ .unwrap();
+ assert!(!second.changed);
+ assert_eq!(registry.list().await.unwrap().len(), 1);
+ assert!(server
+ .requests()
+ .iter()
+ .all(|request| !request.starts_with("/targets/")));
+}
+
+#[tokio::test]
+async fn tuf_convergence_refreshes_signed_provenance_without_downloading_the_target() {
+ let archive = extension_archive(PACKAGE_VERSION);
+ let first_repository = TestRepository::new(archive.clone(), 1, FUTURE);
+ let server = TestServer::start(first_repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let trusted = trusted_registry(&server, &first_repository, temp.path().join("tuf"));
+ let paths = ExtensionPaths::new(
+ temp.path().join("data"),
+ temp.path().join("extension-state"),
+ );
+ let registry = ExtensionRegistry::new(paths);
+ registry
+ .install_remote("acme/slack", &trusted, None, "stable", None, false)
+ .await
+ .unwrap();
+
+ let second_repository = TestRepository::new(archive, 2, FUTURE);
+ assert_eq!(
+ second_repository.target_sha256,
+ first_repository.target_sha256
+ );
+ server.replace_routes(second_repository.routes);
+ server.clear_requests();
+
+ let converged = registry
+ .install_remote("acme/slack", &trusted, None, "stable", None, false)
+ .await
+ .unwrap();
+
+ assert!(!converged.changed);
+ let provenance = converged.extension.receipt.registry.unwrap();
+ assert_eq!(provenance.timestamp_version, 2);
+ assert_eq!(provenance.snapshot_version, 2);
+ assert_eq!(provenance.targets_version, 2);
+ assert!(server
+ .requests()
+ .iter()
+ .all(|request| !request.starts_with("/targets/")));
+}
+
+#[tokio::test]
+async fn tuf_install_rejects_modified_installed_content_before_dispatch_or_convergence() {
+ let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE);
+ let server = TestServer::start(repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let trusted = trusted_registry(&server, &repository, temp.path().join("tuf"));
+ let paths = ExtensionPaths::new(
+ temp.path().join("data"),
+ temp.path().join("extension-state"),
+ );
+ let registry = ExtensionRegistry::new(paths);
+ let installed = registry
+ .install_remote("acme/slack", &trusted, None, "stable", None, false)
+ .await
+ .unwrap();
+ std::fs::write(
+ installed.extension.cli_executable().unwrap(),
+ b"modified executable",
+ )
+ .unwrap();
+
+ let dispatch_error = match registry.acquire_route("slack").await {
+ Err(error) => error,
+ Ok(_) => panic!("modified signed content must not be dispatched"),
+ };
+ assert_eq!(dispatch_error.code, "use.extension.package_digest_mismatch");
+
+ server.clear_requests();
+ let convergence_error = registry
+ .install_remote("acme/slack", &trusted, None, "stable", None, false)
+ .await
+ .unwrap_err();
+ assert_eq!(
+ convergence_error.code,
+ "use.extension.package_digest_mismatch"
+ );
+ assert!(server
+ .requests()
+ .iter()
+ .all(|request| !request.starts_with("/targets/")));
+}
+
+#[tokio::test]
+async fn tuf_receipt_requires_an_expanded_package_digest() {
+ let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE);
+ let server = TestServer::start(repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let trusted = trusted_registry(&server, &repository, temp.path().join("tuf"));
+ let paths = ExtensionPaths::new(
+ temp.path().join("data"),
+ temp.path().join("extension-state"),
+ );
+ let registry = ExtensionRegistry::new(paths);
+ registry
+ .install_remote("acme/slack", &trusted, None, "stable", None, false)
+ .await
+ .unwrap();
+
+ let receipt_path = registry.paths().receipt_path("acme/slack");
+ let mut receipt: serde_json::Value =
+ serde_json::from_slice(&std::fs::read(&receipt_path).unwrap()).unwrap();
+ receipt.as_object_mut().unwrap().remove("packageSha256");
+ std::fs::write(&receipt_path, serde_json::to_vec_pretty(&receipt).unwrap()).unwrap();
+
+ let error = registry.get("acme/slack").await.unwrap_err();
+ assert_eq!(error.code, "use.extension.receipt_invalid");
+}
+
+#[tokio::test]
+async fn reviewed_registry_plan_fails_before_target_download() {
+ let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE);
+ let server = TestServer::start(repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let trusted = trusted_registry(&server, &repository, temp.path().join("tuf"));
+
+ let error = prepare_remote_package(
+ &trusted,
+ "acme/slack",
+ None,
+ "stable",
+ Some(&"0".repeat(64)),
+ )
+ .await
+ .unwrap_err();
+
+ assert_eq!(error.code, "use.extension.registry_plan_mismatch");
+ assert!(server
+ .requests()
+ .iter()
+ .all(|request| !request.starts_with("/targets/")));
+}
+
+#[tokio::test]
+async fn tuf_rejects_wrong_root_and_tampered_target() {
+ let archive = extension_archive(PACKAGE_VERSION);
+ let repository = TestRepository::new(archive, 1, FUTURE);
+ let server = TestServer::start(repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let wrong = TrustedRegistry::new(
+ "fixture",
+ server.base_url(),
+ "f".repeat(64),
+ None,
+ temp.path().join("wrong-root"),
+ )
+ .unwrap();
+ let error = prepare_remote_package(&wrong, "acme/slack", None, "stable", None)
+ .await
+ .unwrap_err();
+ assert_eq!(error.code, "use.extension.registry_root_mismatch");
+
+ let mut routes = repository.routes.clone();
+ routes.insert(
+ format!("/targets/{}", repository.target_name),
+ b"tampered archive".to_vec(),
+ );
+ let tampered_server = TestServer::start(routes);
+ let trusted = trusted_registry(
+ &tampered_server,
+ &repository,
+ temp.path().join("tampered-target"),
+ );
+ let prepared = prepare_remote_package(&trusted, "acme/slack", None, "stable", None)
+ .await
+ .unwrap();
+ let error = prepared.download().await.unwrap_err();
+ assert_eq!(error.code, "use.extension.registry_download_failed");
+}
+
+#[tokio::test]
+async fn tuf_rejects_metadata_tampering_expiration_and_rollback() {
+ let archive = extension_archive(PACKAGE_VERSION);
+ let version_two = TestRepository::new(archive.clone(), 2, FUTURE);
+ let server_two = TestServer::start(version_two.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let datastore = temp.path().join("rollback-state");
+ let trusted_two = trusted_registry(&server_two, &version_two, datastore.clone());
+ prepare_remote_package(&trusted_two, "acme/slack", None, "stable", None)
+ .await
+ .unwrap();
+
+ let version_one = TestRepository::new(archive.clone(), 1, FUTURE);
+ assert_eq!(version_one.root_sha256, version_two.root_sha256);
+ let server_one = TestServer::start(version_one.routes.clone());
+ let trusted_one = trusted_registry(&server_one, &version_one, datastore);
+ let rollback = prepare_remote_package(&trusted_one, "acme/slack", None, "stable", None)
+ .await
+ .unwrap_err();
+ assert_eq!(rollback.code, "use.extension.registry_untrusted");
+
+ let expired = TestRepository::new(archive.clone(), 1, EXPIRED);
+ let expired_server = TestServer::start(expired.routes.clone());
+ let expired_registry =
+ trusted_registry(&expired_server, &expired, temp.path().join("expired-state"));
+ let error = prepare_remote_package(&expired_registry, "acme/slack", None, "stable", None)
+ .await
+ .unwrap_err();
+ assert_eq!(error.code, "use.extension.registry_untrusted");
+
+ let mut tampered_routes = version_one.routes.clone();
+ let targets = tampered_routes.get_mut("/metadata/targets.json").unwrap();
+ let position = find_subslice(targets, b"stable").unwrap();
+ targets[position..position + 6].copy_from_slice(b"nightl");
+ let tampered_server = TestServer::start(tampered_routes);
+ let tampered_registry = trusted_registry(
+ &tampered_server,
+ &version_one,
+ temp.path().join("tampered-metadata"),
+ );
+ let error = prepare_remote_package(&tampered_registry, "acme/slack", None, "stable", None)
+ .await
+ .unwrap_err();
+ assert_eq!(error.code, "use.extension.registry_untrusted");
+}
+
+fn trusted_registry(
+ server: &TestServer,
+ repository: &TestRepository,
+ datastore: PathBuf,
+) -> TrustedRegistry {
+ TrustedRegistry::new(
+ "fixture",
+ server.base_url(),
+ &repository.root_sha256,
+ None,
+ datastore,
+ )
+ .unwrap()
+}
diff --git a/crates/extension/src/source.rs b/crates/extension/src/source.rs
new file mode 100644
index 00000000..e8132daf
--- /dev/null
+++ b/crates/extension/src/source.rs
@@ -0,0 +1,708 @@
+use std::collections::BTreeSet;
+use std::fs::{File, OpenOptions};
+use std::io::{self, Read, Write};
+use std::path::{Component, Path, PathBuf};
+
+use a3s_use_core::{UseError, UseResult};
+use tempfile::TempDir;
+use tokio::fs;
+
+use super::package::{io_error, MANIFEST_NAME, MAX_PACKAGE_BYTES, MAX_PACKAGE_FILES};
+
+const MAX_ARCHIVE_BYTES: u64 = 512 * 1024 * 1024;
+const MAX_PATH_BYTES: usize = 4_096;
+const MAX_PATH_DEPTH: usize = 32;
+
+#[derive(Clone, Copy)]
+enum ArchiveKind {
+ TarGz,
+ Zip,
+}
+
+struct ExtractedEntry {
+ relative: PathBuf,
+ file: bool,
+}
+
+/// One validated local package source kept alive through installation.
+#[derive(Debug)]
+pub(crate) struct PreparedPackageSource {
+ root: PathBuf,
+ _temporary: Option,
+}
+
+impl PreparedPackageSource {
+ pub(crate) fn root(&self) -> &Path {
+ &self.root
+ }
+}
+
+pub(crate) async fn prepare_package_source(source: &Path) -> UseResult {
+ let source = fs::canonicalize(source)
+ .await
+ .map_err(|error| io_error("resolve extension package", source, error))?;
+ let metadata = fs::metadata(&source)
+ .await
+ .map_err(|error| io_error("inspect extension package", &source, error))?;
+ if metadata.is_dir() {
+ return Ok(PreparedPackageSource {
+ root: source,
+ _temporary: None,
+ });
+ }
+ if !metadata.is_file() {
+ return Err(UseError::new(
+ "use.extension.package_unsupported",
+ "The local extension source must be a package directory, .tar.gz, .tgz, or .zip archive.",
+ ));
+ }
+ if metadata.len() > MAX_ARCHIVE_BYTES {
+ return Err(UseError::new(
+ "use.extension.package_too_large",
+ format!(
+ "The extension archive exceeds the {MAX_ARCHIVE_BYTES} byte compressed-size limit."
+ ),
+ ));
+ }
+ let kind = archive_kind(&source)?;
+ let temporary = tokio::task::spawn_blocking(tempfile::tempdir)
+ .await
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.io",
+ format!("Failed to create extension archive staging task: {error}"),
+ )
+ })?
+ .map_err(|error| io_error("create extension archive staging directory", &source, error))?;
+ let extraction_root = temporary.path().join("package");
+ let blocking_source = source.clone();
+ let blocking_root = extraction_root.clone();
+ let package_relative = tokio::task::spawn_blocking(move || {
+ extract_archive(&blocking_source, &blocking_root, kind)
+ })
+ .await
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.package_archive_invalid",
+ format!("Extension archive extraction task failed: {error}"),
+ )
+ })??;
+ let extraction_root = fs::canonicalize(&extraction_root).await.map_err(|error| {
+ io_error(
+ "resolve extension archive staging directory",
+ &extraction_root,
+ error,
+ )
+ })?;
+ let root = extraction_root.join(package_relative);
+ let root = fs::canonicalize(&root)
+ .await
+ .map_err(|error| io_error("resolve extracted extension package", &root, error))?;
+ if !root.starts_with(&extraction_root) {
+ return Err(UseError::new(
+ "use.extension.path_escape",
+ "The extracted extension package root escapes its staging directory.",
+ ));
+ }
+ Ok(PreparedPackageSource {
+ root,
+ _temporary: Some(temporary),
+ })
+}
+
+fn archive_kind(path: &Path) -> UseResult {
+ let name = path
+ .file_name()
+ .and_then(|value| value.to_str())
+ .unwrap_or_default()
+ .to_ascii_lowercase();
+ if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
+ Ok(ArchiveKind::TarGz)
+ } else if name.ends_with(".zip") {
+ Ok(ArchiveKind::Zip)
+ } else {
+ Err(UseError::new(
+ "use.extension.package_unsupported",
+ "Extension package archives must use .tar.gz, .tgz, or .zip.",
+ ))
+ }
+}
+
+fn extract_archive(source: &Path, target: &Path, kind: ArchiveKind) -> UseResult {
+ std::fs::create_dir_all(target)
+ .map_err(|error| archive_io("create extraction directory", target, error))?;
+ let entries = match kind {
+ ArchiveKind::TarGz => extract_tar_gz(source, target)?,
+ ArchiveKind::Zip => extract_zip(source, target)?,
+ };
+ resolve_package_root(&entries)
+}
+
+fn extract_tar_gz(source: &Path, target: &Path) -> UseResult> {
+ let file = File::open(source).map_err(|error| archive_io("open", source, error))?;
+ let decoder = flate2::read::GzDecoder::new(file);
+ let mut archive = tar::Archive::new(decoder);
+ let mut extracted = Vec::new();
+ let mut seen = BTreeSet::new();
+ let mut extracted_bytes = 0_u64;
+ let entries = archive
+ .entries()
+ .map_err(|error| archive_invalid(format!("Failed to read tar entries: {error}")))?;
+ for (entry_count, entry) in entries.enumerate() {
+ if entry_count >= MAX_PACKAGE_FILES {
+ return Err(package_limit_error());
+ }
+ let mut entry =
+ entry.map_err(|error| archive_invalid(format!("Failed to read tar entry: {error}")))?;
+ let entry_path = entry
+ .path()
+ .map_err(|error| archive_invalid(format!("Failed to read tar entry path: {error}")))?
+ .into_owned();
+ let entry_type = entry.header().entry_type();
+ if ignored_macos_metadata_path(&entry_path)? {
+ if entry_type.is_dir() {
+ continue;
+ }
+ if entry_type.is_file() {
+ let remaining = MAX_PACKAGE_BYTES.saturating_sub(extracted_bytes);
+ extracted_bytes = extracted_bytes.saturating_add(copy_bounded(
+ &mut entry,
+ &mut io::sink(),
+ remaining,
+ &entry_path,
+ )?);
+ continue;
+ }
+ }
+ let Some(relative) = sanitized_relative_path(&entry_path)? else {
+ if entry_type.is_dir() {
+ continue;
+ }
+ return Err(archive_invalid(
+ "The archive contains a non-directory root entry.",
+ ));
+ };
+ if !seen.insert(relative.clone()) {
+ return Err(archive_invalid(format!(
+ "The archive contains duplicate entry '{}'.",
+ relative.display()
+ )));
+ }
+ let output = target.join(&relative);
+ if entry_type.is_dir() {
+ std::fs::create_dir_all(&output)
+ .map_err(|error| archive_io("create archive directory", &output, error))?;
+ extracted.push(ExtractedEntry {
+ relative,
+ file: false,
+ });
+ } else if entry_type.is_file() {
+ let remaining = MAX_PACKAGE_BYTES.saturating_sub(extracted_bytes);
+ if entry.size() > remaining {
+ return Err(package_limit_error());
+ }
+ if let Some(parent) = output.parent() {
+ std::fs::create_dir_all(parent)
+ .map_err(|error| archive_io("create archive parent", parent, error))?;
+ }
+ let mut output_file = OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(&output)
+ .map_err(|error| archive_io("create archive file", &output, error))?;
+ extracted_bytes = extracted_bytes.saturating_add(copy_bounded(
+ &mut entry,
+ &mut output_file,
+ remaining,
+ &output,
+ )?);
+ apply_unix_mode(&output, entry.header().mode().ok())?;
+ extracted.push(ExtractedEntry {
+ relative,
+ file: true,
+ });
+ } else if entry_type.is_symlink() || entry_type.is_hard_link() {
+ return Err(UseError::new(
+ "use.extension.package_symlink",
+ format!(
+ "Extension archive entry '{}' is a link.",
+ relative.display()
+ ),
+ ));
+ } else {
+ return Err(UseError::new(
+ "use.extension.package_entry_invalid",
+ format!(
+ "Extension archive entry '{}' is not a regular file or directory.",
+ relative.display()
+ ),
+ ));
+ }
+ }
+ Ok(extracted)
+}
+
+fn extract_zip(source: &Path, target: &Path) -> UseResult> {
+ let file = File::open(source).map_err(|error| archive_io("open", source, error))?;
+ let mut archive = zip::ZipArchive::new(file)
+ .map_err(|error| archive_invalid(format!("Failed to read ZIP archive: {error}")))?;
+ if archive.len() > MAX_PACKAGE_FILES {
+ return Err(package_limit_error());
+ }
+ let mut extracted = Vec::new();
+ let mut seen = BTreeSet::new();
+ let mut extracted_bytes = 0_u64;
+ for index in 0..archive.len() {
+ let mut entry = archive.by_index(index).map_err(|error| {
+ archive_invalid(format!("Failed to read ZIP entry {index}: {error}"))
+ })?;
+ if entry.is_symlink() {
+ return Err(UseError::new(
+ "use.extension.package_symlink",
+ format!("Extension archive entry '{}' is a link.", entry.name()),
+ ));
+ }
+ let enclosed = entry.enclosed_name().ok_or_else(|| {
+ UseError::new(
+ "use.extension.path_escape",
+ format!(
+ "Extension archive entry '{}' escapes the package.",
+ entry.name()
+ ),
+ )
+ })?;
+ if ignored_macos_metadata_path(&enclosed)? {
+ if entry.is_dir() {
+ continue;
+ }
+ if entry.is_file() {
+ let remaining = MAX_PACKAGE_BYTES.saturating_sub(extracted_bytes);
+ extracted_bytes = extracted_bytes.saturating_add(copy_bounded(
+ &mut entry,
+ &mut io::sink(),
+ remaining,
+ &enclosed,
+ )?);
+ continue;
+ }
+ }
+ let Some(relative) = sanitized_relative_path(&enclosed)? else {
+ if entry.is_dir() {
+ continue;
+ }
+ return Err(archive_invalid(
+ "The ZIP archive contains a non-directory root entry.",
+ ));
+ };
+ if !seen.insert(relative.clone()) {
+ return Err(archive_invalid(format!(
+ "The ZIP archive contains duplicate entry '{}'.",
+ relative.display()
+ )));
+ }
+ let output = target.join(&relative);
+ if entry.is_dir() {
+ std::fs::create_dir_all(&output)
+ .map_err(|error| archive_io("create ZIP directory", &output, error))?;
+ extracted.push(ExtractedEntry {
+ relative,
+ file: false,
+ });
+ } else if entry.is_file() {
+ let remaining = MAX_PACKAGE_BYTES.saturating_sub(extracted_bytes);
+ if entry.size() > remaining {
+ return Err(package_limit_error());
+ }
+ if let Some(parent) = output.parent() {
+ std::fs::create_dir_all(parent)
+ .map_err(|error| archive_io("create ZIP parent", parent, error))?;
+ }
+ let mut output_file = OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(&output)
+ .map_err(|error| archive_io("create ZIP file", &output, error))?;
+ extracted_bytes = extracted_bytes.saturating_add(copy_bounded(
+ &mut entry,
+ &mut output_file,
+ remaining,
+ &output,
+ )?);
+ apply_unix_mode(&output, entry.unix_mode())?;
+ extracted.push(ExtractedEntry {
+ relative,
+ file: true,
+ });
+ } else {
+ return Err(UseError::new(
+ "use.extension.package_entry_invalid",
+ format!("Extension ZIP entry '{}' is unsupported.", entry.name()),
+ ));
+ }
+ }
+ Ok(extracted)
+}
+
+fn ignored_macos_metadata_path(path: &Path) -> UseResult {
+ if path.as_os_str().is_empty() {
+ return Err(archive_invalid("The archive contains an empty entry path."));
+ }
+ let encoded = path.to_str().ok_or_else(|| {
+ archive_invalid("Extension archive paths must be valid UTF-8 for portability.")
+ })?;
+ if encoded.len() > MAX_PATH_BYTES {
+ return Err(archive_invalid(format!(
+ "Extension archive path '{}' is not portable.",
+ path.display()
+ )));
+ }
+
+ let mut first = None;
+ let mut last = None;
+ let mut depth = 0_usize;
+ for component in path.components() {
+ match component {
+ Component::Normal(segment) => {
+ depth += 1;
+ if depth > MAX_PATH_DEPTH {
+ return Err(archive_invalid(format!(
+ "Extension archive path '{}' exceeds the depth limit.",
+ path.display()
+ )));
+ }
+ let segment = segment.to_str().ok_or_else(|| {
+ archive_invalid("Extension archive paths must be valid UTF-8 for portability.")
+ })?;
+ first.get_or_insert(segment);
+ last = Some(segment);
+ }
+ Component::CurDir => {}
+ Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
+ return Err(UseError::new(
+ "use.extension.path_escape",
+ format!(
+ "Extension archive path '{}' escapes the package.",
+ path.display()
+ ),
+ ));
+ }
+ }
+ }
+
+ Ok(first == Some("__MACOSX") || last.is_some_and(|segment| segment.starts_with("._")))
+}
+
+pub(crate) fn sanitized_relative_path(path: &Path) -> UseResult> {
+ if path.as_os_str().is_empty() {
+ return Err(archive_invalid("The archive contains an empty entry path."));
+ }
+ let encoded = path.to_str().ok_or_else(|| {
+ archive_invalid("Extension archive paths must be valid UTF-8 for portability.")
+ })?;
+ if encoded.len() > MAX_PATH_BYTES {
+ return Err(archive_invalid(format!(
+ "Extension archive path '{}' is not portable.",
+ path.display()
+ )));
+ }
+ let mut sanitized = PathBuf::new();
+ let mut depth = 0_usize;
+ for component in path.components() {
+ match component {
+ Component::Normal(segment) => {
+ depth += 1;
+ if depth > MAX_PATH_DEPTH {
+ return Err(archive_invalid(format!(
+ "Extension archive path '{}' exceeds the depth limit.",
+ path.display()
+ )));
+ }
+ validate_portable_segment(segment, path)?;
+ sanitized.push(segment);
+ }
+ Component::CurDir => {}
+ Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
+ return Err(UseError::new(
+ "use.extension.path_escape",
+ format!(
+ "Extension archive path '{}' escapes the package.",
+ path.display()
+ ),
+ ));
+ }
+ }
+ }
+ if sanitized.as_os_str().is_empty() {
+ Ok(None)
+ } else {
+ Ok(Some(sanitized))
+ }
+}
+
+fn validate_portable_segment(segment: &std::ffi::OsStr, path: &Path) -> UseResult<()> {
+ let segment = segment.to_str().ok_or_else(|| {
+ archive_invalid("Extension archive paths must be valid UTF-8 for portability.")
+ })?;
+ if segment.ends_with(['.', ' '])
+ || segment
+ .chars()
+ .any(|character| character.is_control() || r#"<>:"/\|?*"#.contains(character))
+ {
+ return Err(archive_invalid(format!(
+ "Extension archive path '{}' is not portable.",
+ path.display()
+ )));
+ }
+ let device = segment
+ .split('.')
+ .next()
+ .unwrap_or_default()
+ .to_ascii_uppercase();
+ let reserved = matches!(device.as_str(), "CON" | "PRN" | "AUX" | "NUL")
+ || device
+ .strip_prefix("COM")
+ .or_else(|| device.strip_prefix("LPT"))
+ .is_some_and(|number| {
+ matches!(number, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
+ });
+ if reserved {
+ return Err(archive_invalid(format!(
+ "Extension archive path '{}' uses a reserved device name.",
+ path.display()
+ )));
+ }
+ Ok(())
+}
+
+fn copy_bounded(
+ reader: &mut impl Read,
+ writer: &mut impl Write,
+ remaining: u64,
+ path: &Path,
+) -> UseResult {
+ let mut bounded = reader.take(remaining.saturating_add(1));
+ let copied = io::copy(&mut bounded, writer)
+ .map_err(|error| archive_io("extract archive file", path, error))?;
+ if copied > remaining {
+ return Err(package_limit_error());
+ }
+ Ok(copied)
+}
+
+fn resolve_package_root(entries: &[ExtractedEntry]) -> UseResult {
+ let manifests = entries
+ .iter()
+ .filter(|entry| {
+ entry.file
+ && entry
+ .relative
+ .file_name()
+ .is_some_and(|name| name == MANIFEST_NAME)
+ })
+ .collect::>();
+ let [manifest] = manifests.as_slice() else {
+ return Err(UseError::new(
+ "use.extension.package_layout_invalid",
+ format!("Extension archives must contain exactly one regular {MANIFEST_NAME} file."),
+ ));
+ };
+ let root = manifest
+ .relative
+ .parent()
+ .map(Path::to_path_buf)
+ .unwrap_or_default();
+ if !root.as_os_str().is_empty()
+ && entries
+ .iter()
+ .any(|entry| !entry.relative.starts_with(&root))
+ {
+ return Err(UseError::new(
+ "use.extension.package_layout_invalid",
+ "Extension archive entries must all belong to the directory containing its manifest.",
+ ));
+ }
+ Ok(root)
+}
+
+#[cfg(unix)]
+fn apply_unix_mode(path: &Path, mode: Option) -> UseResult<()> {
+ use std::os::unix::fs::PermissionsExt;
+
+ if let Some(mode) = mode {
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode & 0o777))
+ .map_err(|error| archive_io("set archive file permissions", path, error))?;
+ }
+ Ok(())
+}
+
+#[cfg(not(unix))]
+fn apply_unix_mode(_path: &Path, _mode: Option) -> UseResult<()> {
+ Ok(())
+}
+
+fn archive_io(action: &str, path: &Path, error: io::Error) -> UseError {
+ UseError::new(
+ "use.extension.package_archive_invalid",
+ format!(
+ "Failed to {action} extension archive entry '{}': {error}",
+ path.display()
+ ),
+ )
+}
+
+fn archive_invalid(message: impl Into) -> UseError {
+ UseError::new("use.extension.package_archive_invalid", message)
+}
+
+fn package_limit_error() -> UseError {
+ UseError::new(
+ "use.extension.package_too_large",
+ "The extension package exceeds the local installation limits.",
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use std::io::Write;
+
+ use super::*;
+
+ #[tokio::test]
+ async fn tar_package_accepts_an_explicit_current_directory_root() {
+ let temp = tempfile::tempdir().unwrap();
+ let archive_path = temp.path().join("package.tar.gz");
+ {
+ let file = File::create(&archive_path).unwrap();
+ let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
+ let mut builder = tar::Builder::new(encoder);
+
+ let mut root = tar::Header::new_gnu();
+ root.set_path(".").unwrap();
+ root.set_entry_type(tar::EntryType::Directory);
+ root.set_size(0);
+ root.set_mode(0o755);
+ root.set_cksum();
+ builder.append(&root, io::empty()).unwrap();
+
+ let manifest = b"extension fixture";
+ let mut header = tar::Header::new_gnu();
+ header.set_path(format!("./{MANIFEST_NAME}")).unwrap();
+ header.set_size(manifest.len() as u64);
+ header.set_mode(0o644);
+ header.set_cksum();
+ builder.append(&header, &manifest[..]).unwrap();
+ builder.finish().unwrap();
+ }
+
+ let prepared = prepare_package_source(&archive_path).await.unwrap();
+ assert_eq!(
+ std::fs::read(prepared.root().join(MANIFEST_NAME)).unwrap(),
+ b"extension fixture"
+ );
+ }
+
+ #[tokio::test]
+ async fn tar_package_ignores_bounded_macos_appledouble_metadata() {
+ let temp = tempfile::tempdir().unwrap();
+ let archive_path = temp.path().join("package.tar.gz");
+ {
+ let file = File::create(&archive_path).unwrap();
+ let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
+ let mut builder = tar::Builder::new(encoder);
+
+ let metadata = b"appledouble";
+ let mut metadata_header = tar::Header::new_gnu();
+ metadata_header.set_path("./._.").unwrap();
+ metadata_header.set_size(metadata.len() as u64);
+ metadata_header.set_mode(0o644);
+ metadata_header.set_cksum();
+ builder.append(&metadata_header, &metadata[..]).unwrap();
+
+ let manifest = b"extension fixture";
+ let mut manifest_header = tar::Header::new_gnu();
+ manifest_header
+ .set_path(format!("./{MANIFEST_NAME}"))
+ .unwrap();
+ manifest_header.set_size(manifest.len() as u64);
+ manifest_header.set_mode(0o644);
+ manifest_header.set_cksum();
+ builder.append(&manifest_header, &manifest[..]).unwrap();
+ builder.finish().unwrap();
+ }
+
+ let prepared = prepare_package_source(&archive_path).await.unwrap();
+ assert_eq!(
+ std::fs::read(prepared.root().join(MANIFEST_NAME)).unwrap(),
+ b"extension fixture"
+ );
+ assert!(!prepared.root().join("._.").exists());
+ }
+
+ #[tokio::test]
+ async fn zip_package_rejects_parent_traversal() {
+ let temp = tempfile::tempdir().unwrap();
+ let archive_path = temp.path().join("escape.zip");
+ {
+ let file = File::create(&archive_path).unwrap();
+ let mut writer = zip::ZipWriter::new(file);
+ writer
+ .start_file(
+ "../a3s-use-extension.acl",
+ zip::write::SimpleFileOptions::default(),
+ )
+ .unwrap();
+ writer.write_all(b"escape").unwrap();
+ writer.finish().unwrap();
+ }
+
+ let error = prepare_package_source(&archive_path).await.unwrap_err();
+ assert_eq!(error.code, "use.extension.path_escape");
+ }
+
+ #[tokio::test]
+ async fn tar_package_rejects_symbolic_links() {
+ let temp = tempfile::tempdir().unwrap();
+ let archive_path = temp.path().join("link.tar.gz");
+ {
+ let file = File::create(&archive_path).unwrap();
+ let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
+ let mut builder = tar::Builder::new(encoder);
+
+ let manifest = b"extension fixture";
+ let mut manifest_header = tar::Header::new_gnu();
+ manifest_header
+ .set_path(format!("package/{MANIFEST_NAME}"))
+ .unwrap();
+ manifest_header.set_size(manifest.len() as u64);
+ manifest_header.set_mode(0o644);
+ manifest_header.set_cksum();
+ builder.append(&manifest_header, &manifest[..]).unwrap();
+
+ let mut link = tar::Header::new_gnu();
+ link.set_entry_type(tar::EntryType::Symlink);
+ link.set_path("package/escape").unwrap();
+ link.set_link_name("../../outside").unwrap();
+ link.set_size(0);
+ link.set_cksum();
+ builder.append(&link, io::empty()).unwrap();
+ builder.finish().unwrap();
+ }
+
+ let error = prepare_package_source(&archive_path).await.unwrap_err();
+ assert_eq!(error.code, "use.extension.package_symlink");
+ }
+
+ #[test]
+ fn archive_paths_reject_cross_platform_escapes_and_device_names() {
+ for path in ["C:/escape", "..\\escape", "package/CON", "package/name. "] {
+ assert!(
+ sanitized_relative_path(Path::new(path)).is_err(),
+ "accepted unsafe path {path}"
+ );
+ }
+ assert_eq!(
+ sanitized_relative_path(Path::new("./package/bin/tool")).unwrap(),
+ Some(PathBuf::from("package/bin/tool"))
+ );
+ }
+}
diff --git a/crates/extension/src/tuf_test_support.rs b/crates/extension/src/tuf_test_support.rs
new file mode 100644
index 00000000..06767097
--- /dev/null
+++ b/crates/extension/src/tuf_test_support.rs
@@ -0,0 +1,324 @@
+#![allow(dead_code)]
+
+use std::collections::HashMap;
+use std::io::{Read, Write};
+use std::net::{Shutdown, TcpListener, TcpStream};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex};
+use std::thread::JoinHandle;
+use std::time::Duration;
+
+use olpc_cjson::CanonicalFormatter;
+use ring::signature::{Ed25519KeyPair, KeyPair};
+use serde::Serialize;
+use serde_json::{json, Map, Value};
+use sha2::{Digest, Sha256};
+
+pub(crate) const FUTURE: &str = "2999-01-01T00:00:00Z";
+pub(crate) const EXPIRED: &str = "2000-01-01T00:00:00Z";
+pub(crate) const PACKAGE_VERSION: &str = "0.1.1";
+
+pub(crate) struct TestRepository {
+ pub(crate) routes: HashMap>,
+ pub(crate) root_sha256: String,
+ pub(crate) target_name: String,
+ pub(crate) target_sha256: String,
+}
+
+impl TestRepository {
+ pub(crate) fn new(archive: Vec, metadata_version: u64, expires: &str) -> Self {
+ Self::with_package_version(archive, PACKAGE_VERSION, metadata_version, expires)
+ }
+
+ pub(crate) fn with_package_version(
+ archive: Vec,
+ package_version: &str,
+ metadata_version: u64,
+ expires: &str,
+ ) -> Self {
+ let key = Ed25519KeyPair::from_seed_unchecked(&[7_u8; 32]).unwrap();
+ let public = hex_lower(key.public_key().as_ref());
+ let key_value = json!({
+ "keytype": "ed25519",
+ "scheme": "ed25519",
+ "keyval": {"public": public}
+ });
+ let key_id = sha256(&canonical(&key_value));
+ let role = json!({"keyids": [key_id.clone()], "threshold": 1});
+ let mut keys = Map::new();
+ keys.insert(key_id.clone(), key_value);
+ let root_signed = json!({
+ "_type": "root",
+ "spec_version": "1.0.0",
+ "consistent_snapshot": false,
+ "version": 1,
+ "expires": FUTURE,
+ "keys": keys,
+ "roles": {
+ "root": role.clone(),
+ "snapshot": role.clone(),
+ "targets": role.clone(),
+ "timestamp": role
+ }
+ });
+ let root = signed_document(&key, &key_id, root_signed);
+ let root_sha256 = sha256(&root);
+
+ let target = host_target();
+ let archive_name = format!("a3s-use-acme-slack-{package_version}-{target}.tar.gz");
+ let target_name =
+ format!("extensions/acme/slack/{package_version}/stable/{target}/{archive_name}");
+ let target_sha256 = sha256(&archive);
+ let mut targets_map = Map::new();
+ targets_map.insert(
+ target_name.clone(),
+ json!({
+ "length": archive.len(),
+ "hashes": {"sha256": target_sha256},
+ "custom": {
+ "a3s": {
+ "schemaVersion": 1,
+ "packageId": "acme/slack",
+ "version": package_version,
+ "channel": "stable",
+ "target": target
+ }
+ }
+ }),
+ );
+ let targets_signed = json!({
+ "_type": "targets",
+ "spec_version": "1.0.0",
+ "version": metadata_version,
+ "expires": expires,
+ "targets": targets_map
+ });
+ let targets = signed_document(&key, &key_id, targets_signed);
+ let snapshot_signed = json!({
+ "_type": "snapshot",
+ "spec_version": "1.0.0",
+ "version": metadata_version,
+ "expires": expires,
+ "meta": {
+ "targets.json": {
+ "version": metadata_version,
+ "length": targets.len(),
+ "hashes": {"sha256": sha256(&targets)}
+ }
+ }
+ });
+ let snapshot = signed_document(&key, &key_id, snapshot_signed);
+ let timestamp_signed = json!({
+ "_type": "timestamp",
+ "spec_version": "1.0.0",
+ "version": metadata_version,
+ "expires": expires,
+ "meta": {
+ "snapshot.json": {
+ "version": metadata_version,
+ "length": snapshot.len(),
+ "hashes": {"sha256": sha256(&snapshot)}
+ }
+ }
+ });
+ let timestamp = signed_document(&key, &key_id, timestamp_signed);
+
+ let routes = HashMap::from([
+ ("/metadata/root.json".to_string(), root),
+ ("/metadata/timestamp.json".to_string(), timestamp),
+ ("/metadata/snapshot.json".to_string(), snapshot),
+ ("/metadata/targets.json".to_string(), targets),
+ (format!("/targets/{target_name}"), archive),
+ ]);
+ Self {
+ routes,
+ root_sha256,
+ target_name,
+ target_sha256,
+ }
+ }
+}
+
+fn signed_document(key: &Ed25519KeyPair, key_id: &str, signed: Value) -> Vec {
+ let signature = key.sign(&canonical(&signed));
+ serde_json::to_vec(&json!({
+ "signatures": [{"keyid": key_id, "sig": hex_lower(signature.as_ref())}],
+ "signed": signed
+ }))
+ .unwrap()
+}
+
+fn canonical(value: &Value) -> Vec {
+ let mut bytes = Vec::new();
+ let mut serializer =
+ serde_json::Serializer::with_formatter(&mut bytes, CanonicalFormatter::new());
+ value.serialize(&mut serializer).unwrap();
+ bytes
+}
+
+fn sha256(bytes: &[u8]) -> String {
+ format!("{:x}", Sha256::digest(bytes))
+}
+
+fn hex_lower(bytes: &[u8]) -> String {
+ let mut output = String::with_capacity(bytes.len() * 2);
+ for byte in bytes {
+ use std::fmt::Write as _;
+ let _ = write!(output, "{byte:02x}");
+ }
+ output
+}
+
+pub(crate) fn extension_archive(version: &str) -> Vec {
+ let manifest = format!(
+ "extension \"acme/slack\" {{\n schema_version = 1\n version = \"{version}\"\n route = \"slack\"\n actions = [\"read\"]\n\n cli {{\n executable = \"bin/a3s-use-acme-slack\"\n json_output = true\n }}\n}}\n"
+ );
+ let mut bytes = Vec::new();
+ {
+ let encoder = flate2::write::GzEncoder::new(&mut bytes, flate2::Compression::default());
+ let mut archive = tar::Builder::new(encoder);
+ append_tar_file(
+ &mut archive,
+ "package/a3s-use-extension.acl",
+ 0o644,
+ manifest.as_bytes(),
+ );
+ append_tar_file(
+ &mut archive,
+ "package/bin/a3s-use-acme-slack",
+ 0o755,
+ b"#!/bin/sh\nprintf 'slack fixture\\n'\n",
+ );
+ archive.finish().unwrap();
+ }
+ bytes
+}
+
+fn append_tar_file(archive: &mut tar::Builder, path: &str, mode: u32, body: &[u8]) {
+ let mut header = tar::Header::new_gnu();
+ header.set_path(path).unwrap();
+ header.set_size(body.len() as u64);
+ header.set_mode(mode);
+ header.set_cksum();
+ archive.append(&header, body).unwrap();
+}
+
+pub(crate) fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option {
+ haystack
+ .windows(needle.len())
+ .position(|window| window == needle)
+}
+
+fn host_target() -> &'static str {
+ match (std::env::consts::OS, std::env::consts::ARCH) {
+ ("macos", "aarch64") => "darwin-arm64",
+ ("macos", "x86_64") => "darwin-x86_64",
+ ("linux", "aarch64") => "linux-arm64",
+ ("linux", "x86_64") => "linux-x86_64",
+ ("windows", "x86_64") => "windows-x86_64",
+ (os, arch) => panic!("unsupported TUF test target {os}-{arch}"),
+ }
+}
+
+pub(crate) struct TestServer {
+ base_url: String,
+ routes: Arc>>>,
+ requests: Arc>>,
+ stop: Arc,
+ thread: Option>,
+}
+
+impl TestServer {
+ pub(crate) fn start(routes: HashMap>) -> Self {
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ listener.set_nonblocking(true).unwrap();
+ let base_url = format!("http://{}/", listener.local_addr().unwrap());
+ let routes = Arc::new(Mutex::new(routes));
+ let requests = Arc::new(Mutex::new(Vec::new()));
+ let stop = Arc::new(AtomicBool::new(false));
+ let thread_routes = Arc::clone(&routes);
+ let thread_requests = Arc::clone(&requests);
+ let thread_stop = Arc::clone(&stop);
+ let thread = std::thread::spawn(move || {
+ while !thread_stop.load(Ordering::Relaxed) {
+ match listener.accept() {
+ Ok((stream, _)) => {
+ stream.set_nonblocking(false).unwrap();
+ let routes = Arc::clone(&thread_routes);
+ let requests = Arc::clone(&thread_requests);
+ std::thread::spawn(move || serve(stream, &routes, &requests));
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
+ std::thread::sleep(Duration::from_millis(5));
+ }
+ Err(_) => break,
+ }
+ }
+ });
+ Self {
+ base_url,
+ routes,
+ requests,
+ stop,
+ thread: Some(thread),
+ }
+ }
+
+ pub(crate) fn base_url(&self) -> &str {
+ &self.base_url
+ }
+
+ pub(crate) fn requests(&self) -> Vec {
+ self.requests.lock().unwrap().clone()
+ }
+
+ pub(crate) fn clear_requests(&self) {
+ self.requests.lock().unwrap().clear();
+ }
+
+ pub(crate) fn replace_routes(&self, routes: HashMap>) {
+ *self.routes.lock().unwrap() = routes;
+ }
+}
+
+impl Drop for TestServer {
+ fn drop(&mut self) {
+ self.stop.store(true, Ordering::Relaxed);
+ if let Some(thread) = self.thread.take() {
+ let _ = thread.join();
+ }
+ }
+}
+
+fn serve(
+ mut stream: TcpStream,
+ routes: &Mutex>>,
+ requests: &Mutex>,
+) {
+ let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
+ let mut buffer = [0_u8; 8192];
+ let Ok(size) = stream.read(&mut buffer) else {
+ return;
+ };
+ let request = String::from_utf8_lossy(&buffer[..size]);
+ let path = request
+ .lines()
+ .next()
+ .and_then(|line| line.split_whitespace().nth(1))
+ .unwrap_or("/")
+ .to_string();
+ requests.lock().unwrap().push(path.clone());
+ let body = routes.lock().unwrap().get(&path).cloned();
+ let (status, body) = body
+ .as_deref()
+ .map(|body| ("200 OK", body))
+ .unwrap_or(("404 Not Found", b"not found"));
+ let header = format!(
+ "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
+ body.len()
+ );
+ if stream.write_all(header.as_bytes()).is_ok() && stream.write_all(body).is_ok() {
+ let _ = stream.flush();
+ let _ = stream.shutdown(Shutdown::Write);
+ }
+}
diff --git a/docs/architecture.md b/docs/architecture.md
index c165d7b5..1408dc9d 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -70,6 +70,14 @@ Consumers read `extension snapshot` for the current projection or long-poll
`extension watch --after-generation ` for a later generation. No daemon,
custom RPC protocol, `dlopen`, or restart is required.
+Explicit local sources may be directories or bounded `.tar.gz`, `.tgz`, and
+`.zip` archives. Archive extraction runs off the async executor, accepts one
+manifest-rooted package, preserves executable permissions, and rejects links,
+path traversal, duplicate entries, unsupported file types, and expansion beyond
+the package limits before lifecycle activation begins. Standard bounded macOS
+AppleDouble sidecars are ignored rather than installed; they still count toward
+the archive entry and expanded-byte limits.
+
### Unified capability projection
Resident Code hosts do not need separate discovery paths for built-in and
@@ -500,6 +508,11 @@ Implemented:
vision adapters, standard MCP annotations/output schemas, and a
release-packaged content-bound Skill that projects to `mcp__use_ocr__*` in
A3S Code.
+17. TUF-verified remote extension registries with pinned bootstrap roots,
+ expiration and rollback enforcement, exact review/apply plans, and signed
+ provenance receipts. Registry upgrades restore the recorded source and
+ channel, reject identity drift and version downgrades, and converge before
+ payload download when the installed signed target is already current.
Next:
@@ -513,5 +526,6 @@ Next:
with the same runtime guarantees as macOS and Linux. Windows compilation,
CLI/MCP schemas, packaged assets, and non-runtime tests remain continuously
checked in CI meanwhile.
-4. Signed remote extension publishers. External publisher infrastructure is
- independent of the built-in Browser compatibility contract.
+4. Production publication for the official A3S extension registry, including
+ an offline-held root-key policy and release automation. The client does not
+ substitute a placeholder or generated key for that operational trust root.
diff --git a/src/cli.rs b/src/cli.rs
index 23bad00f..42d387f0 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -6,7 +6,8 @@ use crate::capability_registry::{
use crate::extension_cli::{
extension_capabilities, extension_disable, extension_enable, extension_inspect, extension_list,
extension_snapshot, extension_watch, external_component_value, external_package_id,
- install_extension, installed_extension, installed_extensions, uninstall_extension,
+ install_extension, install_remote_extension, installed_extension, installed_extensions,
+ uninstall_extension,
};
use std::time::Duration;
@@ -451,15 +452,78 @@ async fn component_install(args: &[String]) -> UseResult {
format!("Unknown delegated component '{id}'."),
));
};
- let source = option_argument(args, "--from")?
- .ok_or_else(|| usage_error("external extension install requires --from "))?;
- let result = install_extension(
- package_id,
- std::path::Path::new(source),
- args.iter().any(|argument| argument == "--force"),
- args.iter().any(|argument| argument == "--allow-unsigned"),
- )
- .await?;
+ let source = option_argument(args, "--from")?;
+ let registry_name = option_argument(args, "--registry-name")?;
+ let registry_url = option_argument(args, "--registry-url")?;
+ let trust_root = option_argument(args, "--trust-root")?;
+ let trusted_root = option_argument(args, "--trusted-root")?;
+ let version = option_argument(args, "--version")?;
+ let channel = option_argument(args, "--channel")?.unwrap_or("stable");
+ let expected_plan = option_argument(args, "--registry-plan-digest")?;
+ let force = args.iter().any(|argument| argument == "--force");
+ let allow_unsigned = args.iter().any(|argument| argument == "--allow-unsigned");
+ let remote_requested = registry_name.is_some()
+ || registry_url.is_some()
+ || trust_root.is_some()
+ || trusted_root.is_some()
+ || version.is_some()
+ || expected_plan.is_some()
+ || option_argument(args, "--channel")?.is_some();
+ let result = if let Some(source) = source {
+ if remote_requested {
+ return Err(usage_error(
+ "--from cannot be combined with signed registry options",
+ ));
+ }
+ install_extension(
+ package_id,
+ std::path::Path::new(source),
+ force,
+ allow_unsigned,
+ )
+ .await?
+ } else {
+ if allow_unsigned {
+ return Err(usage_error(
+ "--allow-unsigned is valid only with an explicit local --from package",
+ ));
+ }
+ let registry_name = registry_name
+ .ok_or_else(|| usage_error("remote extension install requires --registry-name"))?;
+ let registry_url = registry_url
+ .ok_or_else(|| usage_error("remote extension install requires --registry-url"))?;
+ let trust_root = trust_root
+ .ok_or_else(|| usage_error("remote extension install requires --trust-root"))?;
+ let trusted_root = trusted_root
+ .map(|path| {
+ let path = std::path::PathBuf::from(path);
+ if path.is_absolute() {
+ Ok(path)
+ } else {
+ std::env::current_dir()
+ .map(|directory| directory.join(path))
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.registry_path_invalid",
+ format!("Failed to resolve the trusted root path: {error}"),
+ )
+ })
+ }
+ })
+ .transpose()?;
+ install_remote_extension(
+ package_id,
+ registry_name,
+ registry_url,
+ trust_root,
+ trusted_root.as_deref(),
+ version,
+ channel,
+ expected_plan,
+ force,
+ )
+ .await?
+ };
Ok(CommandOutput::success(
if result.changed {
format!("Installed extension '{}'.", result.extension.package_id)
@@ -958,9 +1022,16 @@ fn validate_component_install_options(args: &[String]) -> UseResult<()> {
while index < args.len() {
match args[index].as_str() {
"--json" | "--force" | "--allow-unsigned" => index += 1,
- "--from" => {
+ "--from"
+ | "--registry-name"
+ | "--registry-url"
+ | "--trust-root"
+ | "--trusted-root"
+ | "--version"
+ | "--channel"
+ | "--registry-plan-digest" => {
if args.get(index + 1).is_none() {
- return Err(usage_error("--from requires a value"));
+ return Err(usage_error(format!("{} requires a value", args[index])));
}
index += 2;
}
diff --git a/src/extension_cli.rs b/src/extension_cli.rs
index 4e8afb64..ee5b30e2 100644
--- a/src/extension_cli.rs
+++ b/src/extension_cli.rs
@@ -14,6 +14,8 @@ pub(crate) struct ExtensionView {
pub enabled: bool,
pub package_root: PathBuf,
pub surfaces: Vec<&'static str>,
+ pub trust: &'static str,
+ pub registry: Option,
pub manifest: serde_json::Value,
}
@@ -196,7 +198,8 @@ pub(crate) fn external_component_value(
"route": extension.route,
"enabled": extension.enabled,
"surfaces": extension.surfaces,
- "trust": "local-explicit"
+ "trust": extension.trust,
+ "registry": extension.registry
})
}
@@ -209,7 +212,8 @@ fn extension_value(extension: &ExtensionView) -> serde_json::Value {
"enabled": extension.enabled,
"packageRoot": extension.package_root,
"surfaces": extension.surfaces,
- "trust": "local-explicit"
+ "trust": extension.trust,
+ "registry": extension.registry
})
}
@@ -254,6 +258,42 @@ pub(crate) async fn install_extension(
})
}
+#[cfg(feature = "extensions")]
+#[allow(clippy::too_many_arguments)]
+pub(crate) async fn install_remote_extension(
+ package_id: &str,
+ registry_name: &str,
+ registry_url: &str,
+ trust_root: &str,
+ trusted_root_path: Option<&Path>,
+ version: Option<&str>,
+ channel: &str,
+ expected_plan_digest: Option<&str>,
+ force: bool,
+) -> UseResult {
+ let paths = a3s_use_extension::ExtensionPaths::from_env()?;
+ let registry = a3s_use_extension::TrustedRegistry::new(
+ registry_name,
+ registry_url,
+ trust_root,
+ trusted_root_path.map(Path::to_path_buf),
+ paths.tuf_datastore(registry_name),
+ )?;
+ let result = crate::extension_host::install_remote(
+ package_id,
+ ®istry,
+ version,
+ channel,
+ expected_plan_digest,
+ force,
+ )
+ .await?;
+ Ok(ExtensionInstallView {
+ changed: result.changed,
+ extension: extension_view(result.extension)?,
+ })
+}
+
#[cfg(not(feature = "extensions"))]
pub(crate) async fn install_extension(
_package_id: &str,
@@ -264,6 +304,22 @@ pub(crate) async fn install_extension(
Err(extensions_disabled())
}
+#[cfg(not(feature = "extensions"))]
+#[allow(clippy::too_many_arguments)]
+pub(crate) async fn install_remote_extension(
+ _package_id: &str,
+ _registry_name: &str,
+ _registry_url: &str,
+ _trust_root: &str,
+ _trusted_root_path: Option<&Path>,
+ _version: Option<&str>,
+ _channel: &str,
+ _expected_plan_digest: Option<&str>,
+ _force: bool,
+) -> UseResult {
+ Err(extensions_disabled())
+}
+
#[cfg(feature = "extensions")]
pub(crate) async fn uninstall_extension(package_id: &str) -> UseResult {
let result = crate::extension_host::uninstall(package_id).await?;
@@ -368,6 +424,22 @@ async fn watch_registry(
#[cfg(feature = "extensions")]
fn extension_view(extension: a3s_use_extension::InstalledExtension) -> UseResult {
let surfaces = extension.surfaces();
+ let trust = match extension.receipt.trust {
+ a3s_use_extension::ExtensionTrust::LocalExplicit => "local-explicit",
+ a3s_use_extension::ExtensionTrust::RegistryTuf => "registry-tuf",
+ };
+ let registry = extension
+ .receipt
+ .registry
+ .as_ref()
+ .map(serde_json::to_value)
+ .transpose()
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.receipt_invalid",
+ format!("Failed to encode the extension registry provenance: {error}"),
+ )
+ })?;
let manifest = serde_json::to_value(&extension.manifest).map_err(|error| {
UseError::new(
"use.extension.manifest_invalid",
@@ -382,6 +454,8 @@ fn extension_view(extension: a3s_use_extension::InstalledExtension) -> UseResult
enabled: extension.receipt.enabled,
package_root: extension.receipt.package_root,
surfaces,
+ trust,
+ registry,
manifest,
})
}
diff --git a/src/extension_host.rs b/src/extension_host.rs
index b2e845e7..a2fc60bf 100644
--- a/src/extension_host.rs
+++ b/src/extension_host.rs
@@ -3,7 +3,7 @@ use std::path::Path;
use a3s_use_core::{UseError, UseResult};
use a3s_use_extension::{
ActivationResult, ExtensionRegistry, ExtensionRegistrySnapshot, InstallOptions, InstallResult,
- InstalledExtension, UninstallResult,
+ InstalledExtension, TrustedRegistry, UninstallResult,
};
use std::time::Duration;
@@ -33,6 +33,26 @@ pub async fn install(
.await
}
+pub async fn install_remote(
+ package_id: &str,
+ registry: &TrustedRegistry,
+ version: Option<&str>,
+ channel: &str,
+ expected_plan_digest: Option<&str>,
+ force: bool,
+) -> UseResult {
+ ExtensionRegistry::from_env()?
+ .install_remote(
+ package_id,
+ registry,
+ version,
+ channel,
+ expected_plan_digest,
+ force,
+ )
+ .await
+}
+
pub async fn uninstall(package_id: &str) -> UseResult {
ExtensionRegistry::from_env()?.uninstall(package_id).await
}
diff --git a/tests/extension_archives.rs b/tests/extension_archives.rs
new file mode 100644
index 00000000..c3d81e32
--- /dev/null
+++ b/tests/extension_archives.rs
@@ -0,0 +1,102 @@
+#![cfg(all(unix, feature = "extensions"))]
+
+use std::fs::File;
+use std::os::unix::fs::PermissionsExt;
+use std::process::Command;
+
+fn binary() -> &'static str {
+ env!("CARGO_BIN_EXE_a3s-use")
+}
+
+#[test]
+fn archived_extension_installs_dispatches_and_uninstalls_through_the_cli() {
+ let temp = tempfile::tempdir().unwrap();
+ let package = temp.path().join("package");
+ std::fs::create_dir_all(package.join("bin")).unwrap();
+ std::fs::write(
+ package.join("a3s-use-extension.acl"),
+ r#"extension "acme/slack" {
+ schema_version = 1
+ version = "1.0.0"
+ route = "slack"
+ actions = ["read"]
+
+ cli {
+ executable = "bin/a3s-use-acme-slack"
+ json_output = true
+ }
+}
+"#,
+ )
+ .unwrap();
+ let executable = package.join("bin/a3s-use-acme-slack");
+ std::fs::write(
+ &executable,
+ "#!/bin/sh\nprintf '%s\\n' \"$A3S_USE_EXTENSION_ID\"\nprintf '%s\\n' \"$*\"\nexit 7\n",
+ )
+ .unwrap();
+ std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
+
+ let archive = temp.path().join("acme-slack.tar.gz");
+ let file = File::create(&archive).unwrap();
+ let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
+ let mut builder = tar::Builder::new(encoder);
+ builder.append_dir_all("package", &package).unwrap();
+ builder.finish().unwrap();
+ drop(builder);
+
+ let home = temp.path().join("home");
+ let installed = Command::new(binary())
+ .args([
+ "component",
+ "install",
+ "acme/slack",
+ "--from",
+ archive.to_str().unwrap(),
+ "--allow-unsigned",
+ "--json",
+ ])
+ .env("A3S_USE_HOME", &home)
+ .output()
+ .unwrap();
+ assert!(
+ installed.status.success(),
+ "status: {}\nstdout: {}\nstderr: {}",
+ installed.status,
+ String::from_utf8_lossy(&installed.stdout),
+ String::from_utf8_lossy(&installed.stderr)
+ );
+ let installed_json: serde_json::Value = serde_json::from_slice(&installed.stdout).unwrap();
+ assert_eq!(installed_json["data"]["component"]["id"], "acme/slack");
+
+ let delegated = Command::new(binary())
+ .args(["slack", "channels", "list", "--json"])
+ .env("A3S_USE_HOME", &home)
+ .output()
+ .unwrap();
+ assert_eq!(delegated.status.code(), Some(7));
+ assert_eq!(
+ String::from_utf8(delegated.stdout).unwrap(),
+ "acme/slack\nchannels list --json\n"
+ );
+
+ let removed = Command::new(binary())
+ .args(["component", "uninstall", "acme/slack", "--json"])
+ .env("A3S_USE_HOME", &home)
+ .output()
+ .unwrap();
+ assert!(removed.status.success(), "{removed:?}");
+ let removed_json: serde_json::Value = serde_json::from_slice(&removed.stdout).unwrap();
+ assert_eq!(removed_json["data"]["changed"], true);
+
+ let listed = Command::new(binary())
+ .args(["extension", "list", "--json"])
+ .env("A3S_USE_HOME", &home)
+ .output()
+ .unwrap();
+ assert!(listed.status.success(), "{listed:?}");
+ let listed_json: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap();
+ assert_eq!(listed_json["data"]["extensions"], serde_json::json!([]));
+ assert!(!home.join("data/extensions/acme/slack").exists());
+ assert!(!home.join("state/extensions/acme/slack.json").exists());
+}
diff --git a/tests/remote_extension_cli.rs b/tests/remote_extension_cli.rs
new file mode 100644
index 00000000..74167a76
--- /dev/null
+++ b/tests/remote_extension_cli.rs
@@ -0,0 +1,183 @@
+#![cfg(feature = "extensions")]
+
+use std::process::{Command, Output};
+
+use a3s_use_extension::{prepare_remote_package, ResolvedRemotePackage, TrustedRegistry};
+
+#[path = "../crates/extension/src/tuf_test_support.rs"]
+mod tuf_test_support;
+
+use tuf_test_support::{extension_archive, TestRepository, TestServer, FUTURE, PACKAGE_VERSION};
+
+fn binary() -> &'static str {
+ env!("CARGO_BIN_EXE_a3s-use")
+}
+
+#[tokio::test]
+async fn signed_registry_install_uses_reviewed_target_and_reports_tuf_provenance() {
+ let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE);
+ let server = TestServer::start(repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let trusted = TrustedRegistry::new(
+ "fixture",
+ server.base_url(),
+ &repository.root_sha256,
+ None,
+ temp.path().join("review-state"),
+ )
+ .unwrap();
+ let reviewed = prepare_remote_package(&trusted, "acme/slack", None, "stable", None)
+ .await
+ .unwrap();
+ let plan_digest = reviewed.resolved().plan_digest().unwrap();
+ drop(reviewed);
+ assert_no_target_request(&server);
+
+ let home = temp.path().join("home");
+ let installed = registry_install(&server, &repository, &home, Some(&plan_digest), &[]);
+ assert!(installed.status.success(), "{installed:?}");
+ let installed_json = json(&installed);
+ assert_eq!(installed_json["data"]["changed"], true);
+ assert_eq!(installed_json["data"]["component"]["trust"], "registry-tuf");
+ assert_eq!(
+ installed_json["data"]["component"]["registry"]["registryName"],
+ "fixture"
+ );
+ assert_eq!(
+ installed_json["data"]["component"]["registry"]["sha256"],
+ repository.target_sha256
+ );
+ assert_eq!(
+ server
+ .requests()
+ .iter()
+ .filter(|request| request.starts_with("/targets/"))
+ .count(),
+ 1
+ );
+
+ let receipt: serde_json::Value = serde_json::from_slice(
+ &std::fs::read(home.join("state/extensions/acme/slack.json")).unwrap(),
+ )
+ .unwrap();
+ assert_eq!(receipt["trust"], "registry-tuf");
+ let provenance: ResolvedRemotePackage =
+ serde_json::from_value(receipt["registry"].clone()).unwrap();
+ assert_eq!(provenance.plan_digest().unwrap(), plan_digest);
+
+ let inspected = Command::new(binary())
+ .args(["extension", "inspect", "acme/slack", "--json"])
+ .env("A3S_USE_HOME", &home)
+ .output()
+ .unwrap();
+ assert!(inspected.status.success(), "{inspected:?}");
+ let inspected = json(&inspected);
+ assert_eq!(inspected["data"]["extension"]["trust"], "registry-tuf");
+ assert_eq!(
+ inspected["data"]["extension"]["registry"]["targetName"],
+ repository.target_name
+ );
+
+ let second = registry_install(&server, &repository, &home, Some(&plan_digest), &[]);
+ assert!(second.status.success(), "{second:?}");
+ assert_eq!(json(&second)["data"]["changed"], false);
+}
+
+#[test]
+fn registry_plan_mismatch_fails_before_target_download() {
+ let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE);
+ let server = TestServer::start(repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let output = registry_install(
+ &server,
+ &repository,
+ &temp.path().join("home"),
+ Some(&"0".repeat(64)),
+ &[],
+ );
+
+ assert!(!output.status.success(), "{output:?}");
+ assert_eq!(
+ json(&output)["error"]["code"],
+ "use.extension.registry_plan_mismatch"
+ );
+ assert_no_target_request(&server);
+}
+
+#[test]
+fn registry_install_rejects_unsigned_and_local_source_combinations() {
+ let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE);
+ let server = TestServer::start(repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let home = temp.path().join("home");
+
+ let unsigned = registry_install(&server, &repository, &home, None, &["--allow-unsigned"]);
+ assert!(!unsigned.status.success(), "{unsigned:?}");
+ assert_eq!(json(&unsigned)["error"]["code"], "use.cli.invalid_usage");
+
+ let local = Command::new(binary())
+ .args([
+ "component",
+ "install",
+ "acme/slack",
+ "--from",
+ temp.path().to_str().unwrap(),
+ "--allow-unsigned",
+ "--registry-name",
+ "fixture",
+ "--json",
+ ])
+ .env("A3S_USE_HOME", &home)
+ .output()
+ .unwrap();
+ assert!(!local.status.success(), "{local:?}");
+ assert_eq!(json(&local)["error"]["code"], "use.cli.invalid_usage");
+ assert!(server.requests().is_empty());
+}
+
+fn registry_install(
+ server: &TestServer,
+ repository: &TestRepository,
+ home: &std::path::Path,
+ plan_digest: Option<&str>,
+ extra: &[&str],
+) -> Output {
+ let mut command = Command::new(binary());
+ command.args([
+ "component",
+ "install",
+ "acme/slack",
+ "--registry-name",
+ "fixture",
+ "--registry-url",
+ server.base_url(),
+ "--trust-root",
+ &repository.root_sha256,
+ ]);
+ if let Some(plan_digest) = plan_digest {
+ command.args(["--registry-plan-digest", plan_digest]);
+ }
+ command
+ .args(extra)
+ .arg("--json")
+ .env("A3S_USE_HOME", home)
+ .output()
+ .unwrap()
+}
+
+fn json(output: &Output) -> serde_json::Value {
+ serde_json::from_slice(&output.stdout).unwrap_or_else(|error| {
+ panic!(
+ "invalid JSON output ({error}): stdout={:?}, stderr={:?}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ )
+ })
+}
+
+fn assert_no_target_request(server: &TestServer) {
+ assert!(server
+ .requests()
+ .iter()
+ .all(|request| !request.starts_with("/targets/")));
+}
From fd8649bd20f90613863ba74ae6734066336c9d8a Mon Sep 17 00:00:00 2001
From: RoyLin
Date: Sun, 19 Jul 2026 10:23:41 +0800
Subject: [PATCH 3/9] feat(science): add typed life-science extension
---
Cargo.lock | 18 +
Cargo.toml | 1 +
README.md | 34 ++
crates/science/Cargo.toml | 33 ++
crates/science/DATA_SOURCES.md | 31 ++
crates/science/README.md | 85 ++++
crates/science/UPSTREAM.md | 26 ++
crates/science/package/a3s-use-extension.acl | 21 +
.../package/skills/a3s-use-science/SKILL.md | 47 ++
crates/science/scripts/package.sh | 25 +
crates/science/src/biorxiv.rs | 293 ++++++++++++
crates/science/src/chembl.rs | 270 +++++++++++
crates/science/src/cli.rs | 370 +++++++++++++++
crates/science/src/client.rs | 365 +++++++++++++++
crates/science/src/clinical_trials.rs | 312 +++++++++++++
crates/science/src/ensembl.rs | 203 ++++++++
crates/science/src/lib.rs | 24 +
crates/science/src/main.rs | 39 ++
crates/science/src/mcp.rs | 434 ++++++++++++++++++
crates/science/src/models.rs | 130 ++++++
crates/science/src/pubmed.rs | 248 ++++++++++
crates/science/tests/integration.rs | 318 +++++++++++++
docs/architecture.md | 8 +
23 files changed, 3335 insertions(+)
create mode 100644 crates/science/Cargo.toml
create mode 100644 crates/science/DATA_SOURCES.md
create mode 100644 crates/science/README.md
create mode 100644 crates/science/UPSTREAM.md
create mode 100644 crates/science/package/a3s-use-extension.acl
create mode 100644 crates/science/package/skills/a3s-use-science/SKILL.md
create mode 100755 crates/science/scripts/package.sh
create mode 100644 crates/science/src/biorxiv.rs
create mode 100644 crates/science/src/chembl.rs
create mode 100644 crates/science/src/cli.rs
create mode 100644 crates/science/src/client.rs
create mode 100644 crates/science/src/clinical_trials.rs
create mode 100644 crates/science/src/ensembl.rs
create mode 100644 crates/science/src/lib.rs
create mode 100644 crates/science/src/main.rs
create mode 100644 crates/science/src/mcp.rs
create mode 100644 crates/science/src/models.rs
create mode 100644 crates/science/src/pubmed.rs
create mode 100644 crates/science/tests/integration.rs
diff --git a/Cargo.lock b/Cargo.lock
index 14cb1eb3..d4f96f2f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -135,6 +135,24 @@ dependencies = [
"zip",
]
+[[package]]
+name = "a3s-use-science"
+version = "0.1.1"
+dependencies = [
+ "a3s-use-core",
+ "a3s-use-extension",
+ "axum",
+ "clap",
+ "reqwest",
+ "rmcp",
+ "schemars",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "tokio",
+ "url",
+]
+
[[package]]
name = "adler2"
version = "2.0.1"
diff --git a/Cargo.toml b/Cargo.toml
index a136a13b..9b807729 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,6 +5,7 @@ members = [
"crates/browser-driver",
"crates/office",
"crates/extension",
+ "crates/science",
]
resolver = "2"
diff --git a/README.md b/README.md
index 9b8376a1..90ff91a5 100644
--- a/README.md
+++ b/README.md
@@ -129,6 +129,8 @@ Every domain argument accepted by `a3s use ...` can also be passed directly to
safe Word, Spreadsheet, Presentation, native MCP, and compatibility workflows
- **External Domains**: Install process-isolated packages that expose any useful
combination of CLI, MCP, and Skill surfaces
+- **Reference Science Toolkit**: Query PubMed, ChEMBL, ClinicalTrials.gov,
+ bioRxiv, and Ensembl through one typed read-only extension
- **Hot-Plug Discovery**: Publish immutable generation/revision snapshots so a
resident host can add, replace, or remove live capabilities without restarting
- **Content-Bound Skills**: Project an absolute package path and lowercase
@@ -149,6 +151,7 @@ Every domain argument accepted by `a3s use ...` can also be passed directly to
| Browser | Built in | Full Browser vocabulary | A3S Use standard MCP server | Six packaged Browser Skills | A3S Use |
| Office | Built in | Stable Office vocabulary | Typed native preview plus OfficeCLI compatibility server | Packaged `a3s-use-office` Skill | A3S Use native engine; OfficeCLI compatibility in 0.1.x |
| Box | Reserved built-in route | Native A3S Box vocabulary | — | — | Umbrella A3S CLI |
+| Science | External `a3s/science` package | Source-specific retrieval commands | 13 typed `science_*` tools | One research workflow Skill | Science extension process |
| External domain | Installed extension | Optional native executable | Optional standard MCP server | Optional `SKILL.md` | Extension package plus A3S Use lifecycle |
The Box route is component-backed. The umbrella CLI resolves its authoritative
@@ -179,6 +182,7 @@ A compiled command surface is not proof that its provider is installed. Use
| `a3s-use-browser-driver` | Complete interactive Browser CLI, MCP tools, Skills, Dashboard, and compatibility runtime |
| `a3s-use-office` | Native OOXML foundation, typed Office operations, and compatibility lifecycle |
| `a3s-use-extension` | A3S ACL manifest model, package registry, leases, and native surface descriptors |
+| `a3s-use-science` | Typed public life-science APIs, CLI, MCP tools, and extension package assets |
| `a3s-use` | Facade library, standalone CLI host, capability projection, and MCP entry points |
## Quick Start
@@ -1586,6 +1590,36 @@ compatibility response can return
See [Native Office Engine](docs/native-office.md) for the complete requirements,
compatibility scope, safety invariants, delivery gates, and migration plan.
+## Science Toolkit
+
+The repository includes `a3s-use-science` as a reference external extension,
+not as another built-in route. Its process exposes one typed Rust client as 13
+read-only MCP tools plus source-specific CLI commands for PubMed, ChEMBL,
+ClinicalTrials.gov, bioRxiv, and Ensembl.
+
+Build a local package into a new directory and install it explicitly:
+
+```bash
+./crates/science/scripts/package.sh /tmp/a3s-use-science-package
+a3s install use/a3s/science \
+ --from /tmp/a3s-use-science-package \
+ --allow-unsigned
+
+export A3S_SCIENCE_CONTACT_EMAIL=researcher@example.org
+a3s use science pubmed search "single-cell atlas" --limit 10 --json
+a3s use science ensembl lookup homo_sapiens TP53 --json
+a3s use mcp serve a3s/science
+```
+
+The same package can be archived as `.tar.gz`, `.tgz`, or `.zip` and installed
+through the explicit local-package flow. Local packages require
+`--allow-unsigned`; use them only after review. PubMed requires the contact
+email, while `NCBI_API_KEY` is optional. See the
+[Science crate](crates/science/README.md), its
+[data-source notice](crates/science/DATA_SOURCES.md), and
+[clean-room provenance](crates/science/UPSTREAM.md) for the full command set,
+data egress, limits, and interpretation boundaries.
+
## External Extensions
External Use domains stay behind process boundaries. A package contains an
diff --git a/crates/science/Cargo.toml b/crates/science/Cargo.toml
new file mode 100644
index 00000000..6e99d89d
--- /dev/null
+++ b/crates/science/Cargo.toml
@@ -0,0 +1,33 @@
+[package]
+name = "a3s-use-science"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+authors.workspace = true
+rust-version.workspace = true
+description = "Typed life-science data retrieval for A3S Use"
+
+[lib]
+name = "a3s_use_science"
+path = "src/lib.rs"
+
+[[bin]]
+name = "a3s-use-science"
+path = "src/main.rs"
+
+[dependencies]
+a3s-use-core = { version = "0.1.1", path = "../core" }
+clap.workspace = true
+reqwest = { workspace = true, features = ["json"] }
+rmcp.workspace = true
+schemars.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+tokio.workspace = true
+url.workspace = true
+
+[dev-dependencies]
+a3s-use-extension = { version = "0.1.1", path = "../extension" }
+axum.workspace = true
+tempfile.workspace = true
diff --git a/crates/science/DATA_SOURCES.md b/crates/science/DATA_SOURCES.md
new file mode 100644
index 00000000..4c37b0e2
--- /dev/null
+++ b/crates/science/DATA_SOURCES.md
@@ -0,0 +1,31 @@
+# Science Data Sources
+
+The Science extension sends user-supplied search terms and identifiers over
+HTTPS to public third-party services. A query can reveal research interests;
+do not submit confidential, patient-identifying, controlled, or unpublished
+information unless the applicable policy and upstream terms permit it.
+
+| Source | Endpoint | Data returned | Local credential |
+| --- | --- | --- | --- |
+| PubMed / NCBI E-utilities | `eutils.ncbi.nlm.nih.gov` | Citation summaries and identifiers | Contact email required; API key optional |
+| ChEMBL | `www.ebi.ac.uk/chembl` | Molecules, targets, and activities | None |
+| ClinicalTrials.gov | `clinicaltrials.gov/api/v2` | Public study protocol records | None |
+| bioRxiv | `api.biorxiv.org` | Public preprint metadata | None |
+| Ensembl REST | `rest.ensembl.org` | Public gene and homology records | None |
+
+The extension applies per-source request pacing, bounded result limits, a
+30-second default request timeout, and bounded upstream error bodies. bioRxiv
+free-text filtering scans at most 500 records per command. PubMed requests
+identify `a3s-use-science` and include the configured contact email in line
+with NCBI guidance.
+
+Upstream services remain authoritative for licenses, terms, retention,
+availability, update cadence, and record interpretation. Their schemas and
+content can change independently of A3S. A successful response means only that
+the public API returned a record; it does not establish scientific validity,
+peer review, clinical suitability, or regulatory approval.
+
+Always preserve source identifiers and retrieval context. Label bioRxiv
+records as preprints, verify consequential conclusions against the underlying
+publication or protocol, and do not use this toolkit as a substitute for
+medical, safety, ethics, or regulatory review.
diff --git a/crates/science/README.md b/crates/science/README.md
new file mode 100644
index 00000000..2259e755
--- /dev/null
+++ b/crates/science/README.md
@@ -0,0 +1,85 @@
+# A3S Use Science
+
+`a3s-use-science` is a process-isolated, read-only life-science extension for
+A3S Use. It provides one typed asynchronous Rust client and projects the same
+operations through a native CLI and a standard MCP server.
+
+The initial toolkit covers:
+
+| Source | Operations |
+| --- | --- |
+| PubMed | Search article summaries; retrieve a PMID |
+| ChEMBL | Search molecules and targets; retrieve molecules and activities |
+| ClinicalTrials.gov | Search studies; retrieve an NCT record |
+| bioRxiv | Search a bounded date range; retrieve a DOI |
+| Ensembl | Look up a gene; retrieve orthologs |
+
+All operations are retrieval-only. The crate does not copy implementation code
+from upstream skill collections and does not run their Python environments.
+See [UPSTREAM.md](UPSTREAM.md) for the inspiration, reviewed revision, and
+clean-room boundary.
+
+## Configuration
+
+Set a contact email before using PubMed, as requested by NCBI E-utilities:
+
+```bash
+export A3S_SCIENCE_CONTACT_EMAIL=researcher@example.org
+export NCBI_API_KEY=optional-ncbi-key
+```
+
+`NCBI_API_KEY` is optional. The other sources currently use public endpoints
+without credentials. See [DATA_SOURCES.md](DATA_SOURCES.md) for network,
+provenance, and usage considerations.
+
+## CLI
+
+Build and run from the A3S Use workspace:
+
+```bash
+cargo build -p a3s-use-science
+./target/debug/a3s-use-science doctor --json
+./target/debug/a3s-use-science pubmed search "single-cell atlas" --limit 10 --json
+./target/debug/a3s-use-science chembl get-molecule CHEMBL25 --json
+./target/debug/a3s-use-science clinical-trials search glioblastoma --status RECRUITING --json
+./target/debug/a3s-use-science biorxiv search --from 2026-01-01 --to 2026-01-31 --json
+./target/debug/a3s-use-science ensembl lookup homo_sapiens BRCA1 --json
+```
+
+Every `--json` invocation returns one versioned CLI document. Without
+`--json`, commands print the retrieved typed value as readable JSON.
+
+## Standard MCP
+
+Run the extension's stdio MCP server directly with:
+
+```bash
+./target/debug/a3s-use-science serve --mcp
+```
+
+After packaging and installing the extension, the A3S host route is:
+
+```bash
+a3s use mcp serve a3s/science
+```
+
+The server exposes 13 source-specific `science_*` tools. It does not introduce
+an A3S-specific RPC envelope or combine unrelated source vocabularies into a
+generic execute action.
+
+## Package
+
+Create a local extension directory at a new path:
+
+```bash
+./crates/science/scripts/package.sh /tmp/a3s-use-science-package
+a3s install use/a3s/science \
+ --from /tmp/a3s-use-science-package \
+ --allow-unsigned
+a3s use science doctor --json
+```
+
+The script refuses to overwrite an existing output directory. The package may
+also be archived as `.tar.gz`, `.tgz`, or `.zip` and passed directly to
+`--from`. Local directories and archives require explicit `--allow-unsigned`
+trust; a signed remote distribution channel remains roadmap work.
diff --git a/crates/science/UPSTREAM.md b/crates/science/UPSTREAM.md
new file mode 100644
index 00000000..74f9b447
--- /dev/null
+++ b/crates/science/UPSTREAM.md
@@ -0,0 +1,26 @@
+# Upstream Inspiration and Clean-Room Boundary
+
+The public capability inventory in
+[`baifan-wang/skills/claude-science`](https://github.com/baifan-wang/skills/tree/main/claude-science)
+inspired the source selection and agent workflow for this extension. The
+inventory was reviewed at commit
+`2b61d890c5ba50570717599b16d34514458b3955` on 2026-07-17.
+
+That repository describes a much larger collection of data tools, model
+workflows, compute integrations, and scientific Skills. Its components carry
+component-specific licensing rather than one clearly stated project-level
+license. Consequently, `a3s-use-science` is an independent clean-room Rust
+implementation:
+
+- no Python source, JSON schema, prompt, test fixture, model wrapper, or other
+ implementation artifact is copied or distributed;
+- this initial package implements a smaller source-specific retrieval surface
+ and does not claim command, MCP-tool, or output compatibility;
+- Ensembl access uses the documented Ensembl REST API, not the upstream
+ collection's BioMart implementation;
+- the extension's code and package assets are licensed with A3S Use under MIT.
+
+Public database names and documented HTTP contracts are factual integration
+points, not bundled upstream software. Each remote data service retains its own
+terms, licenses, and attribution requirements; see
+[DATA_SOURCES.md](DATA_SOURCES.md).
diff --git a/crates/science/package/a3s-use-extension.acl b/crates/science/package/a3s-use-extension.acl
new file mode 100644
index 00000000..97a5fe49
--- /dev/null
+++ b/crates/science/package/a3s-use-extension.acl
@@ -0,0 +1,21 @@
+extension "a3s/science" {
+ schema_version = 1
+ version = "0.1.1"
+ route = "science"
+ actions = ["read"]
+
+ cli {
+ executable = "bin/a3s-use-science"
+ json_output = true
+ }
+
+ mcp {
+ executable = "bin/a3s-use-science"
+ args = ["serve", "--mcp"]
+ transport = "stdio"
+ }
+
+ skill {
+ path = "skills/a3s-use-science/SKILL.md"
+ }
+}
diff --git a/crates/science/package/skills/a3s-use-science/SKILL.md b/crates/science/package/skills/a3s-use-science/SKILL.md
new file mode 100644
index 00000000..6a6449b1
--- /dev/null
+++ b/crates/science/package/skills/a3s-use-science/SKILL.md
@@ -0,0 +1,47 @@
+---
+name: a3s-use-science
+description: Retrieve and cross-check public biomedical evidence from PubMed, ChEMBL, ClinicalTrials.gov, bioRxiv, and Ensembl. Use for literature searches, preprint checks, compound and target research, trial discovery, gene lookup, and ortholog analysis through A3S Use.
+allowed-tools: Bash(a3s:*)
+---
+
+# A3S Use Science
+
+Use the host surface that is already available:
+
+- In an A3S Code `use` worker, call the available
+ `mcp__use_science__*` tools directly. The host owns installation and MCP
+ lifecycle; do not run installation or shell commands there.
+- In a CLI-only agent host, use `a3s use science ...` commands.
+
+Select the narrowest authoritative source:
+
+- Use PubMed for peer-reviewed biomedical literature and article metadata.
+- Use bioRxiv for preprints; always label results as preprints.
+- Use ChEMBL for molecules, targets, and bioactivity records.
+- Use ClinicalTrials.gov for registered study protocols and recruitment status.
+- Use Ensembl for gene coordinates, identifiers, and orthologs.
+
+Start with `science_doctor`. PubMed calls require
+`A3S_SCIENCE_CONTACT_EMAIL`; `NCBI_API_KEY` is optional. Other sources do not
+require those variables.
+
+Preserve PMID, DOI, ChEMBL, NCT, and Ensembl identifiers in the answer. State
+which source supports each claim, distinguish database metadata from research
+conclusions, and report empty or partial results plainly. Never invent missing
+records, silently treat a preprint as peer reviewed, or present retrieved data
+as diagnosis or medical advice. Cross-check important claims in more than one
+source when the task warrants it.
+
+CLI examples:
+
+```bash
+a3s use science doctor --json
+a3s use science pubmed search "CRISPR off-target effects" --limit 10 --json
+a3s use science pubmed get 39712345 --json
+a3s use science chembl search-molecules aspirin --limit 10 --json
+a3s use science chembl activities --molecule CHEMBL25 --limit 20 --json
+a3s use science clinical-trials search melanoma --status RECRUITING --json
+a3s use science biorxiv search --from 2026-01-01 --to 2026-01-31 --query protein --json
+a3s use science ensembl lookup homo_sapiens TP53 --json
+a3s use science ensembl homologs homo_sapiens TP53 --target-species mus_musculus --json
+```
diff --git a/crates/science/scripts/package.sh b/crates/science/scripts/package.sh
new file mode 100755
index 00000000..ce53f664
--- /dev/null
+++ b/crates/science/scripts/package.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+crate_dir="$(cd "${script_dir}/.." && pwd)"
+workspace_dir="$(cd "${crate_dir}/../.." && pwd)"
+output_dir="${1:-${crate_dir}/dist/a3s-use-science}"
+
+if [[ -e "${output_dir}" ]]; then
+ echo "refusing to overwrite existing output: ${output_dir}" >&2
+ exit 2
+fi
+
+cargo build --manifest-path "${workspace_dir}/Cargo.toml" --release --locked -p a3s-use-science
+
+target_dir="${CARGO_TARGET_DIR:-${workspace_dir}/target}"
+mkdir -p "${output_dir}/bin" "${output_dir}/skills/a3s-use-science"
+install -m 0755 "${target_dir}/release/a3s-use-science" "${output_dir}/bin/a3s-use-science"
+install -m 0644 "${crate_dir}/package/a3s-use-extension.acl" "${output_dir}/a3s-use-extension.acl"
+install -m 0644 "${crate_dir}/package/skills/a3s-use-science/SKILL.md" "${output_dir}/skills/a3s-use-science/SKILL.md"
+install -m 0644 "${workspace_dir}/LICENSE" "${output_dir}/LICENSE"
+install -m 0644 "${crate_dir}/DATA_SOURCES.md" "${output_dir}/DATA_SOURCES.md"
+install -m 0644 "${crate_dir}/UPSTREAM.md" "${output_dir}/UPSTREAM.md"
+
+echo "packaged a3s/science at ${output_dir}"
diff --git a/crates/science/src/biorxiv.rs b/crates/science/src/biorxiv.rs
new file mode 100644
index 00000000..52088dd4
--- /dev/null
+++ b/crates/science/src/biorxiv.rs
@@ -0,0 +1,293 @@
+use std::collections::HashSet;
+use std::time::Duration;
+
+use a3s_use_core::{UseError, UseResult};
+use serde::Deserialize;
+use serde_json::Value;
+
+use crate::models::{BioRxivPage, BioRxivRecord};
+use crate::ScienceClient;
+
+const BIORXIV_INTERVAL: Duration = Duration::from_millis(200);
+const MAX_SCAN_RECORDS: usize = 500;
+
+#[derive(Debug, Deserialize)]
+struct BioRxivEnvelope {
+ #[serde(default)]
+ messages: Vec,
+ #[serde(default)]
+ collection: Vec,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct BioRxivMessage {
+ #[serde(default)]
+ total: Value,
+ #[serde(default)]
+ count: Value,
+}
+
+#[derive(Debug, Deserialize)]
+struct RawBioRxivRecord {
+ doi: String,
+ title: String,
+ authors: String,
+ #[serde(default, rename = "abstract")]
+ abstract_text: Option,
+ #[serde(default)]
+ category: Option,
+ #[serde(default)]
+ date: Option,
+ #[serde(default)]
+ version: Option,
+ #[serde(default, rename = "published")]
+ published_doi: Option,
+}
+
+impl ScienceClient {
+ pub async fn biorxiv_search(
+ &self,
+ from_date: &str,
+ to_date: &str,
+ query: Option<&str>,
+ category: Option<&str>,
+ limit: usize,
+ ) -> UseResult {
+ validate_date_range(from_date, to_date)?;
+ let limit = bounded_limit(limit)?;
+ let query = query
+ .map(str::trim)
+ .filter(|query| !query.is_empty())
+ .map(str::to_lowercase);
+ let category = category
+ .map(str::trim)
+ .filter(|category| !category.is_empty())
+ .map(str::to_lowercase);
+
+ let mut cursor = 0_usize;
+ let mut scanned = 0_usize;
+ let mut total_upstream = None;
+ let mut items = Vec::new();
+ let mut seen = HashSet::new();
+ while scanned < MAX_SCAN_RECORDS && items.len() < limit {
+ let cursor_text = cursor.to_string();
+ let url = self.endpoint_url(
+ &self.endpoints.biorxiv,
+ &[
+ "details",
+ "biorxiv",
+ from_date,
+ to_date,
+ &cursor_text,
+ "json",
+ ],
+ )?;
+ let envelope: BioRxivEnvelope = self
+ .get_json("bioRxiv", self.http.get(url), BIORXIV_INTERVAL)
+ .await?;
+ let message = envelope.messages.first();
+ total_upstream =
+ total_upstream.or_else(|| message.and_then(|message| flexible_u64(&message.total)));
+ let reported_count = message
+ .and_then(|message| flexible_u64(&message.count))
+ .map(|count| count as usize)
+ .unwrap_or(envelope.collection.len());
+ let received = envelope.collection.len();
+ if received == 0 {
+ break;
+ }
+ scanned = scanned.saturating_add(received);
+ cursor = cursor.saturating_add(reported_count.max(received));
+ for record in envelope.collection {
+ if !matches_filters(&record, query.as_deref(), category.as_deref()) {
+ continue;
+ }
+ let key = format!(
+ "{}#{}",
+ record.doi,
+ record.version.as_deref().unwrap_or_default()
+ );
+ if seen.insert(key) {
+ items.push(convert_record(record));
+ if items.len() == limit {
+ break;
+ }
+ }
+ }
+ if reported_count == 0 || total_upstream.is_some_and(|total| cursor as u64 >= total) {
+ break;
+ }
+ }
+ Ok(BioRxivPage {
+ total_upstream,
+ scanned,
+ items,
+ })
+ }
+
+ pub async fn biorxiv_get(&self, doi: &str) -> UseResult> {
+ let suffix = validate_biorxiv_doi(doi)?;
+ let url = self.endpoint_url(
+ &self.endpoints.biorxiv,
+ &["details", "biorxiv", "10.1101", suffix, "na", "json"],
+ )?;
+ let envelope: BioRxivEnvelope = self
+ .get_json("bioRxiv", self.http.get(url), BIORXIV_INTERVAL)
+ .await?;
+ if envelope.collection.is_empty() {
+ return Err(UseError::new(
+ "use.science.not_found",
+ format!("bioRxiv did not return DOI {doi}."),
+ )
+ .with_detail("service", "bioRxiv")
+ .with_detail("doi", doi));
+ }
+ Ok(envelope
+ .collection
+ .into_iter()
+ .map(convert_record)
+ .collect())
+ }
+}
+
+fn convert_record(record: RawBioRxivRecord) -> BioRxivRecord {
+ BioRxivRecord {
+ doi: record.doi,
+ title: record.title,
+ authors: record.authors,
+ abstract_text: non_empty(record.abstract_text),
+ category: non_empty(record.category),
+ date: non_empty(record.date),
+ version: non_empty(record.version),
+ published_doi: non_empty(record.published_doi).filter(|value| value != "NA"),
+ }
+}
+
+fn non_empty(value: Option) -> Option {
+ value.filter(|value| !value.trim().is_empty())
+}
+
+fn matches_filters(record: &RawBioRxivRecord, query: Option<&str>, category: Option<&str>) -> bool {
+ let query_matches = query.is_none_or(|query| {
+ [
+ record.title.as_str(),
+ record.authors.as_str(),
+ record.abstract_text.as_deref().unwrap_or_default(),
+ record.doi.as_str(),
+ ]
+ .iter()
+ .any(|value| value.to_lowercase().contains(query))
+ });
+ let category_matches = category.is_none_or(|category| {
+ record
+ .category
+ .as_deref()
+ .is_some_and(|value| value.eq_ignore_ascii_case(category))
+ });
+ query_matches && category_matches
+}
+
+fn flexible_u64(value: &Value) -> Option {
+ match value {
+ Value::Number(value) => value.as_u64(),
+ Value::String(value) => value.parse().ok(),
+ _ => None,
+ }
+}
+
+fn validate_date_range(from_date: &str, to_date: &str) -> UseResult<()> {
+ if !valid_iso_date(from_date) || !valid_iso_date(to_date) || from_date > to_date {
+ return Err(UseError::new(
+ "use.science.date_invalid",
+ "bioRxiv dates must form an ordered YYYY-MM-DD range.",
+ ));
+ }
+ Ok(())
+}
+
+fn valid_iso_date(value: &str) -> bool {
+ let parts = value.split('-').collect::>();
+ let (Ok(year), Ok(month), Ok(day)) = (
+ parts.first().unwrap_or(&"").parse::(),
+ parts.get(1).unwrap_or(&"").parse::(),
+ parts.get(2).unwrap_or(&"").parse::(),
+ ) else {
+ return false;
+ };
+ if parts.len() != 3 || parts[0].len() != 4 || parts[1].len() != 2 || parts[2].len() != 2 {
+ return false;
+ }
+ let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
+ let max_day = match month {
+ 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
+ 4 | 6 | 9 | 11 => 30,
+ 2 if leap => 29,
+ 2 => 28,
+ _ => return false,
+ };
+ (1900..=9999).contains(&year) && (1..=max_day).contains(&day)
+}
+
+fn validate_biorxiv_doi(doi: &str) -> UseResult<&str> {
+ let suffix = doi.strip_prefix("10.1101/").unwrap_or_default();
+ if suffix.is_empty()
+ || suffix.len() > 200
+ || !suffix.bytes().all(|byte| {
+ byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'(' | b')')
+ })
+ {
+ return Err(UseError::new(
+ "use.science.identifier_invalid",
+ "A bioRxiv DOI must start with 10.1101/ and contain a safe DOI suffix.",
+ ));
+ }
+ Ok(suffix)
+}
+
+fn bounded_limit(limit: usize) -> UseResult {
+ if !(1..=100).contains(&limit) {
+ return Err(UseError::new(
+ "use.science.limit_invalid",
+ "bioRxiv result limit must be between 1 and 100.",
+ ));
+ }
+ Ok(limit)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn validates_real_calendar_dates_and_dois() {
+ assert!(valid_iso_date("2024-02-29"));
+ assert!(!valid_iso_date("2025-02-29"));
+ assert!(validate_biorxiv_doi("10.1101/2026.01.01.123456").is_ok());
+ assert_eq!(
+ validate_biorxiv_doi("https://example.com")
+ .unwrap_err()
+ .code,
+ "use.science.identifier_invalid"
+ );
+ }
+
+ #[test]
+ fn filters_records_without_losing_case_insensitivity() {
+ let record = RawBioRxivRecord {
+ doi: "10.1101/example".to_string(),
+ title: "Protein Design".to_string(),
+ authors: "A. Author".to_string(),
+ abstract_text: Some("A diffusion model".to_string()),
+ category: Some("Bioinformatics".to_string()),
+ date: None,
+ version: None,
+ published_doi: None,
+ };
+ assert!(matches_filters(
+ &record,
+ Some("protein"),
+ Some("bioinformatics")
+ ));
+ assert!(!matches_filters(&record, Some("genome"), None));
+ }
+}
diff --git a/crates/science/src/chembl.rs b/crates/science/src/chembl.rs
new file mode 100644
index 00000000..5043f570
--- /dev/null
+++ b/crates/science/src/chembl.rs
@@ -0,0 +1,270 @@
+use std::time::Duration;
+
+use a3s_use_core::{UseError, UseResult};
+use serde::Deserialize;
+use serde_json::Value;
+
+use crate::models::{ChemblActivity, ChemblMolecule, ChemblTarget, Page};
+use crate::ScienceClient;
+
+const CHEMBL_INTERVAL: Duration = Duration::from_millis(100);
+
+#[derive(Debug, Default, Deserialize)]
+struct PageMeta {
+ #[serde(default)]
+ total_count: Option,
+ #[serde(default)]
+ next: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct MoleculeEnvelope {
+ #[serde(default)]
+ molecules: Vec,
+ #[serde(default)]
+ page_meta: PageMeta,
+}
+
+#[derive(Debug, Deserialize)]
+struct TargetEnvelope {
+ #[serde(default)]
+ targets: Vec,
+ #[serde(default)]
+ page_meta: PageMeta,
+}
+
+#[derive(Debug, Deserialize)]
+struct ActivityEnvelope {
+ #[serde(default)]
+ activities: Vec,
+ #[serde(default)]
+ page_meta: PageMeta,
+}
+
+impl ScienceClient {
+ pub async fn chembl_search_molecules(
+ &self,
+ query: &str,
+ limit: usize,
+ ) -> UseResult> {
+ let query = required_query(query)?;
+ let limit = bounded_limit(limit)?;
+ let url = self.endpoint_url(&self.endpoints.chembl, &["molecule", "search.json"])?;
+ let params = [("q", query.to_string()), ("limit", limit.to_string())];
+ let envelope: MoleculeEnvelope = self
+ .get_json("ChEMBL", self.http.get(url).query(¶ms), CHEMBL_INTERVAL)
+ .await?;
+ Ok(Page {
+ total: envelope.page_meta.total_count,
+ next_page_token: envelope.page_meta.next,
+ items: envelope
+ .molecules
+ .iter()
+ .filter_map(parse_molecule)
+ .collect(),
+ })
+ }
+
+ pub async fn chembl_get_molecule(&self, chembl_id: &str) -> UseResult {
+ validate_chembl_id(chembl_id)?;
+ let url = self.endpoint_url(
+ &self.endpoints.chembl,
+ &["molecule", &format!("{chembl_id}.json")],
+ )?;
+ let value: Value = self
+ .get_json("ChEMBL", self.http.get(url), CHEMBL_INTERVAL)
+ .await?;
+ parse_molecule(&value).ok_or_else(|| {
+ UseError::new(
+ "use.science.response_invalid",
+ "ChEMBL returned a molecule without a molecule_chembl_id.",
+ )
+ })
+ }
+
+ pub async fn chembl_search_targets(
+ &self,
+ query: &str,
+ limit: usize,
+ ) -> UseResult> {
+ let query = required_query(query)?;
+ let limit = bounded_limit(limit)?;
+ let url = self.endpoint_url(&self.endpoints.chembl, &["target", "search.json"])?;
+ let params = [("q", query.to_string()), ("limit", limit.to_string())];
+ let envelope: TargetEnvelope = self
+ .get_json("ChEMBL", self.http.get(url).query(¶ms), CHEMBL_INTERVAL)
+ .await?;
+ Ok(Page {
+ total: envelope.page_meta.total_count,
+ next_page_token: envelope.page_meta.next,
+ items: envelope.targets.iter().filter_map(parse_target).collect(),
+ })
+ }
+
+ pub async fn chembl_activities(
+ &self,
+ molecule_chembl_id: Option<&str>,
+ target_chembl_id: Option<&str>,
+ limit: usize,
+ ) -> UseResult> {
+ let limit = bounded_limit(limit)?;
+ if molecule_chembl_id.is_none() && target_chembl_id.is_none() {
+ return Err(UseError::new(
+ "use.science.input_invalid",
+ "ChEMBL activities require a molecule or target ChEMBL ID.",
+ ));
+ }
+ if let Some(identifier) = molecule_chembl_id {
+ validate_chembl_id(identifier)?;
+ }
+ if let Some(identifier) = target_chembl_id {
+ validate_chembl_id(identifier)?;
+ }
+ let url = self.endpoint_url(&self.endpoints.chembl, &["activity.json"])?;
+ let mut query = vec![("limit", limit.to_string())];
+ if let Some(identifier) = molecule_chembl_id {
+ query.push(("molecule_chembl_id", identifier.to_string()));
+ }
+ if let Some(identifier) = target_chembl_id {
+ query.push(("target_chembl_id", identifier.to_string()));
+ }
+ let envelope: ActivityEnvelope = self
+ .get_json("ChEMBL", self.http.get(url).query(&query), CHEMBL_INTERVAL)
+ .await?;
+ Ok(Page {
+ total: envelope.page_meta.total_count,
+ next_page_token: envelope.page_meta.next,
+ items: envelope.activities.iter().map(parse_activity).collect(),
+ })
+ }
+}
+
+fn parse_molecule(value: &Value) -> Option {
+ Some(ChemblMolecule {
+ chembl_id: value.get("molecule_chembl_id")?.as_str()?.to_string(),
+ preferred_name: value_string(value.get("pref_name")),
+ molecule_type: value_string(value.get("molecule_type")),
+ max_phase: value.get("max_phase").and_then(value_f64),
+ canonical_smiles: value
+ .get("molecule_structures")
+ .and_then(|structures| structures.get("canonical_smiles"))
+ .and_then(Value::as_str)
+ .map(str::to_string),
+ standard_inchi_key: value
+ .get("molecule_structures")
+ .and_then(|structures| structures.get("standard_inchi_key"))
+ .and_then(Value::as_str)
+ .map(str::to_string),
+ })
+}
+
+fn parse_target(value: &Value) -> Option {
+ Some(ChemblTarget {
+ chembl_id: value.get("target_chembl_id")?.as_str()?.to_string(),
+ preferred_name: value_string(value.get("pref_name")),
+ target_type: value_string(value.get("target_type")),
+ organism: value_string(value.get("organism")),
+ })
+}
+
+fn parse_activity(value: &Value) -> ChemblActivity {
+ ChemblActivity {
+ activity_id: value_string(value.get("activity_id")),
+ molecule_chembl_id: value_string(value.get("molecule_chembl_id")),
+ target_chembl_id: value_string(value.get("target_chembl_id")),
+ assay_chembl_id: value_string(value.get("assay_chembl_id")),
+ standard_type: value_string(value.get("standard_type")),
+ standard_relation: value_string(value.get("standard_relation")),
+ standard_value: value_string(value.get("standard_value")),
+ standard_units: value_string(value.get("standard_units")),
+ pchembl_value: value_string(value.get("pchembl_value")),
+ }
+}
+
+fn value_string(value: Option<&Value>) -> Option {
+ match value? {
+ Value::String(value) if !value.is_empty() => Some(value.clone()),
+ Value::Number(value) => Some(value.to_string()),
+ _ => None,
+ }
+}
+
+fn value_f64(value: &Value) -> Option {
+ match value {
+ Value::Number(value) => value.as_f64(),
+ Value::String(value) => value.parse().ok(),
+ _ => None,
+ }
+}
+
+fn required_query(query: &str) -> UseResult<&str> {
+ let query = query.trim();
+ if query.is_empty() {
+ return Err(UseError::new(
+ "use.science.input_invalid",
+ "ChEMBL query cannot be empty.",
+ ));
+ }
+ Ok(query)
+}
+
+fn bounded_limit(limit: usize) -> UseResult {
+ if !(1..=100).contains(&limit) {
+ return Err(UseError::new(
+ "use.science.limit_invalid",
+ "ChEMBL result limit must be between 1 and 100.",
+ ));
+ }
+ Ok(limit)
+}
+
+fn validate_chembl_id(identifier: &str) -> UseResult<()> {
+ let suffix = identifier.strip_prefix("CHEMBL").unwrap_or_default();
+ if suffix.is_empty() || !suffix.bytes().all(|byte| byte.is_ascii_digit()) {
+ return Err(UseError::new(
+ "use.science.identifier_invalid",
+ "A ChEMBL identifier must use the form CHEMBL followed by digits.",
+ ));
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_typed_chembl_records() {
+ let molecule = serde_json::json!({
+ "molecule_chembl_id": "CHEMBL25",
+ "pref_name": "ASPIRIN",
+ "molecule_type": "Small molecule",
+ "max_phase": 4,
+ "molecule_structures": {
+ "canonical_smiles": "CC(=O)OC1=CC=CC=C1C(=O)O",
+ "standard_inchi_key": "BSYNRYMUTXBXSQ-UHFFFAOYSA-N"
+ }
+ });
+ let parsed = parse_molecule(&molecule).unwrap();
+ assert_eq!(parsed.chembl_id, "CHEMBL25");
+ assert_eq!(parsed.max_phase, Some(4.0));
+
+ let activity = parse_activity(&serde_json::json!({
+ "activity_id": 42,
+ "standard_value": "12.5"
+ }));
+ assert_eq!(activity.activity_id.as_deref(), Some("42"));
+ }
+
+ #[test]
+ fn rejects_unbounded_or_malformed_inputs() {
+ assert_eq!(
+ validate_chembl_id("../CHEMBL25").unwrap_err().code,
+ "use.science.identifier_invalid"
+ );
+ assert_eq!(
+ bounded_limit(101).unwrap_err().code,
+ "use.science.limit_invalid"
+ );
+ }
+}
diff --git a/crates/science/src/cli.rs b/crates/science/src/cli.rs
new file mode 100644
index 00000000..5aaa1e82
--- /dev/null
+++ b/crates/science/src/cli.rs
@@ -0,0 +1,370 @@
+use a3s_use_core::{UseError, UseResult};
+use clap::error::ErrorKind;
+use clap::{Args, Parser, Subcommand};
+use serde::Serialize;
+
+use crate::{ScienceClient, ScienceMcpServer};
+
+#[derive(Debug)]
+pub struct CommandOutput {
+ pub human: String,
+ pub json: serde_json::Value,
+ pub exit_code: u8,
+ pub should_print: bool,
+}
+
+impl CommandOutput {
+ fn data(value: T) -> UseResult
+ where
+ T: Serialize,
+ {
+ let data = serde_json::to_value(value).map_err(output_error)?;
+ let human = serde_json::to_string_pretty(&data).map_err(output_error)?;
+ Ok(Self {
+ human,
+ json: serde_json::json!({
+ "schemaVersion": 1,
+ "ok": true,
+ "data": data,
+ }),
+ exit_code: 0,
+ should_print: true,
+ })
+ }
+
+ fn text(value: String) -> Self {
+ Self {
+ human: value.clone(),
+ json: serde_json::json!({
+ "schemaVersion": 1,
+ "ok": true,
+ "data": { "text": value },
+ }),
+ exit_code: 0,
+ should_print: true,
+ }
+ }
+
+ fn silent() -> Self {
+ Self {
+ human: String::new(),
+ json: serde_json::Value::Null,
+ exit_code: 0,
+ should_print: false,
+ }
+ }
+}
+
+#[derive(Debug, Parser)]
+#[command(
+ name = "a3s-use-science",
+ version,
+ about = "Read-only life-science data tools for A3S Use",
+ arg_required_else_help = true
+)]
+struct Cli {
+ /// Emit one versioned JSON document.
+ #[arg(long, global = true)]
+ json: bool,
+
+ #[command(subcommand)]
+ command: Command,
+}
+
+#[derive(Debug, Subcommand)]
+enum Command {
+ /// Inspect local configuration without making a network request.
+ Doctor,
+ /// Search or retrieve PubMed article summaries.
+ Pubmed(PubmedArgs),
+ /// Search ChEMBL molecules, targets, and activities.
+ Chembl(ChemblArgs),
+ /// Search or retrieve ClinicalTrials.gov studies.
+ #[command(name = "clinical-trials")]
+ ClinicalTrials(ClinicalTrialsArgs),
+ /// Search or retrieve bioRxiv preprints.
+ Biorxiv(BioRxivArgs),
+ /// Look up Ensembl genes and homologs.
+ Ensembl(EnsemblArgs),
+ /// Run an extension protocol surface.
+ Serve(ServeArgs),
+}
+
+#[derive(Debug, Args)]
+struct PubmedArgs {
+ #[command(subcommand)]
+ command: PubmedCommand,
+}
+
+#[derive(Debug, Subcommand)]
+enum PubmedCommand {
+ /// Search PubMed and return article summaries.
+ Search {
+ query: String,
+ #[arg(long, default_value_t = 20)]
+ limit: usize,
+ },
+ /// Retrieve one PubMed article summary by PMID.
+ Get { pmid: String },
+}
+
+#[derive(Debug, Args)]
+struct ChemblArgs {
+ #[command(subcommand)]
+ command: ChemblCommand,
+}
+
+#[derive(Debug, Subcommand)]
+enum ChemblCommand {
+ /// Search ChEMBL molecules.
+ #[command(name = "search-molecules")]
+ SearchMolecules {
+ query: String,
+ #[arg(long, default_value_t = 20)]
+ limit: usize,
+ },
+ /// Retrieve one ChEMBL molecule.
+ #[command(name = "get-molecule")]
+ GetMolecule { chembl_id: String },
+ /// Search ChEMBL targets.
+ #[command(name = "search-targets")]
+ SearchTargets {
+ query: String,
+ #[arg(long, default_value_t = 20)]
+ limit: usize,
+ },
+ /// Retrieve bioactivity records for a molecule, target, or both.
+ Activities {
+ #[arg(long = "molecule")]
+ molecule_chembl_id: Option,
+ #[arg(long = "target")]
+ target_chembl_id: Option,
+ #[arg(long, default_value_t = 20)]
+ limit: usize,
+ },
+}
+
+#[derive(Debug, Args)]
+struct ClinicalTrialsArgs {
+ #[command(subcommand)]
+ command: ClinicalTrialsCommand,
+}
+
+#[derive(Debug, Subcommand)]
+enum ClinicalTrialsCommand {
+ /// Search ClinicalTrials.gov studies.
+ Search {
+ query: String,
+ #[arg(long = "status")]
+ statuses: Vec,
+ #[arg(long, default_value_t = 20)]
+ limit: usize,
+ #[arg(long)]
+ page_token: Option,
+ },
+ /// Retrieve one study by NCT identifier.
+ Get { nct_id: String },
+}
+
+#[derive(Debug, Args)]
+struct BioRxivArgs {
+ #[command(subcommand)]
+ command: BioRxivCommand,
+}
+
+#[derive(Debug, Subcommand)]
+enum BioRxivCommand {
+ /// Search a bounded bioRxiv date range.
+ Search {
+ #[arg(long = "from")]
+ from_date: String,
+ #[arg(long = "to")]
+ to_date: String,
+ #[arg(long)]
+ query: Option,
+ #[arg(long)]
+ category: Option,
+ #[arg(long, default_value_t = 20)]
+ limit: usize,
+ },
+ /// Retrieve all returned versions of one bioRxiv DOI.
+ Get { doi: String },
+}
+
+#[derive(Debug, Args)]
+struct EnsemblArgs {
+ #[command(subcommand)]
+ command: EnsemblCommand,
+}
+
+#[derive(Debug, Subcommand)]
+enum EnsemblCommand {
+ /// Look up one gene by species and symbol.
+ Lookup { species: String, symbol: String },
+ /// Retrieve orthologs for one gene symbol.
+ Homologs {
+ species: String,
+ symbol: String,
+ #[arg(long)]
+ target_species: Option,
+ #[arg(long, default_value_t = 50)]
+ limit: usize,
+ },
+}
+
+#[derive(Debug, Args)]
+struct ServeArgs {
+ /// Serve standard MCP over stdin/stdout.
+ #[arg(long)]
+ mcp: bool,
+}
+
+pub async fn run(args: Vec) -> UseResult {
+ let mut argv = vec!["a3s-use-science".to_string()];
+ argv.extend(args);
+ let cli = match Cli::try_parse_from(argv) {
+ Ok(cli) => cli,
+ Err(error)
+ if matches!(
+ error.kind(),
+ ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
+ ) =>
+ {
+ return Ok(CommandOutput::text(error.to_string()));
+ }
+ Err(error) => return Err(usage_error(error.to_string())),
+ };
+
+ if let Command::Serve(serve) = &cli.command {
+ if !serve.mcp {
+ return Err(usage_error("serve requires --mcp"));
+ }
+ if cli.json {
+ return Err(usage_error("--json cannot be combined with serve --mcp"));
+ }
+ ScienceMcpServer::from_env()?.serve_stdio().await?;
+ return Ok(CommandOutput::silent());
+ }
+
+ let client = ScienceClient::from_env()?;
+ match cli.command {
+ Command::Doctor => CommandOutput::data(client.diagnostic()),
+ Command::Pubmed(args) => match args.command {
+ PubmedCommand::Search { query, limit } => {
+ CommandOutput::data(client.pubmed_search(&query, limit).await?)
+ }
+ PubmedCommand::Get { pmid } => CommandOutput::data(client.pubmed_get(&pmid).await?),
+ },
+ Command::Chembl(args) => match args.command {
+ ChemblCommand::SearchMolecules { query, limit } => {
+ CommandOutput::data(client.chembl_search_molecules(&query, limit).await?)
+ }
+ ChemblCommand::GetMolecule { chembl_id } => {
+ CommandOutput::data(client.chembl_get_molecule(&chembl_id).await?)
+ }
+ ChemblCommand::SearchTargets { query, limit } => {
+ CommandOutput::data(client.chembl_search_targets(&query, limit).await?)
+ }
+ ChemblCommand::Activities {
+ molecule_chembl_id,
+ target_chembl_id,
+ limit,
+ } => CommandOutput::data(
+ client
+ .chembl_activities(
+ molecule_chembl_id.as_deref(),
+ target_chembl_id.as_deref(),
+ limit,
+ )
+ .await?,
+ ),
+ },
+ Command::ClinicalTrials(args) => match args.command {
+ ClinicalTrialsCommand::Search {
+ query,
+ statuses,
+ limit,
+ page_token,
+ } => CommandOutput::data(
+ client
+ .clinical_trials_search(&query, &statuses, limit, page_token.as_deref())
+ .await?,
+ ),
+ ClinicalTrialsCommand::Get { nct_id } => {
+ CommandOutput::data(client.clinical_trial_get(&nct_id).await?)
+ }
+ },
+ Command::Biorxiv(args) => match args.command {
+ BioRxivCommand::Search {
+ from_date,
+ to_date,
+ query,
+ category,
+ limit,
+ } => CommandOutput::data(
+ client
+ .biorxiv_search(
+ &from_date,
+ &to_date,
+ query.as_deref(),
+ category.as_deref(),
+ limit,
+ )
+ .await?,
+ ),
+ BioRxivCommand::Get { doi } => CommandOutput::data(client.biorxiv_get(&doi).await?),
+ },
+ Command::Ensembl(args) => match args.command {
+ EnsemblCommand::Lookup { species, symbol } => {
+ CommandOutput::data(client.ensembl_lookup_gene(&species, &symbol).await?)
+ }
+ EnsemblCommand::Homologs {
+ species,
+ symbol,
+ target_species,
+ limit,
+ } => CommandOutput::data(
+ client
+ .ensembl_homologs(&species, &symbol, target_species.as_deref(), limit)
+ .await?,
+ ),
+ },
+ Command::Serve(_) => Err(UseError::new(
+ "use.science.command_invalid",
+ "Science MCP command dispatch reached an invalid state.",
+ )),
+ }
+}
+
+fn output_error(error: serde_json::Error) -> UseError {
+ UseError::new(
+ "use.science.output_invalid",
+ format!("Failed to encode science command output: {error}"),
+ )
+}
+
+fn usage_error(message: impl Into) -> UseError {
+ UseError::new("use.science.usage_invalid", message)
+ .with_suggestion("Run 'a3s use science --help'.")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn doctor_is_versioned_and_does_not_require_network_configuration() {
+ let output = run(vec!["doctor".to_string(), "--json".to_string()])
+ .await
+ .unwrap();
+ assert_eq!(output.json["schemaVersion"], 1);
+ assert_eq!(output.json["ok"], true);
+ assert_eq!(output.json["data"]["sources"].as_array().unwrap().len(), 5);
+ }
+
+ #[tokio::test]
+ async fn serve_requires_an_explicit_protocol() {
+ let error = run(vec!["serve".to_string()]).await.unwrap_err();
+ assert_eq!(error.code, "use.science.usage_invalid");
+ }
+}
diff --git a/crates/science/src/client.rs b/crates/science/src/client.rs
new file mode 100644
index 00000000..7d030858
--- /dev/null
+++ b/crates/science/src/client.rs
@@ -0,0 +1,365 @@
+use std::collections::HashMap;
+use std::sync::Arc;
+use std::time::Duration;
+
+use a3s_use_core::{UseError, UseResult};
+use reqwest::{RequestBuilder, Response};
+use serde::de::DeserializeOwned;
+use tokio::sync::Mutex;
+use tokio::time::Instant;
+use url::Url;
+
+use crate::models::ScienceDiagnostic;
+
+const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
+const ERROR_BODY_LIMIT: usize = 1_024;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ScienceEndpoints {
+ pub pubmed: Url,
+ pub chembl: Url,
+ pub clinical_trials: Url,
+ pub biorxiv: Url,
+ pub ensembl: Url,
+}
+
+impl ScienceEndpoints {
+ /// Return the public upstream endpoints used by the toolkit.
+ pub fn public() -> UseResult {
+ Ok(Self {
+ pubmed: parse_endpoint("PubMed", "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/")?,
+ chembl: parse_endpoint("ChEMBL", "https://www.ebi.ac.uk/chembl/api/data/")?,
+ clinical_trials: parse_endpoint(
+ "ClinicalTrials.gov",
+ "https://clinicaltrials.gov/api/v2/",
+ )?,
+ biorxiv: parse_endpoint("bioRxiv", "https://api.biorxiv.org/")?,
+ ensembl: parse_endpoint("Ensembl", "https://rest.ensembl.org/")?,
+ })
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct ScienceClientBuilder {
+ endpoints: Option,
+ contact_email: Option,
+ ncbi_api_key: Option