diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ebbc511..49392ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -157,6 +157,8 @@ jobs: # Required for npm trusted publishing, which mints a short-lived token and # attaches a provenance attestation instead of using a long-lived secret. id-token: write + # The same mechanism, for the standalone archives, which npm never sees. + attestations: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -195,9 +197,9 @@ jobs: with: path: artifacts - # The npm packages carry provenance attestations, but the standalone - # archives are unsigned and unattested. A published hash is the only - # integrity check someone downloading a tarball directly can perform. + # A published hash says a tarball has not changed since it was hashed, but + # this workflow both computes and publishes it, so it cannot say what + # produced it. - name: Record SHA-256 for the standalone archives run: | set -euo pipefail @@ -205,6 +207,49 @@ jobs: | sed 's#artifacts/archive-[^/]*/##' > SHA256SUMS cat SHA256SUMS + # The npm packages have always carried provenance; the archives did not, + # and someone who downloads a tarball rather than installing from npm had + # only the hash above. This binds each archive to this workflow, this + # commit, and this repository, checkable with `gh attestation verify`. + # + # Deliberately ahead of anything published, like the npm checks above: a + # failure here ends the run while the tag is still re-runnable. + - name: Attest the standalone archives + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: artifacts/archive-*/*.tar.gz + + # The reason to upgrade is written while the change is made, not + # reconstructed after the tag. --generate-notes produced a commit list, + # which describes the work rather than what it means for someone reading + # the release page. An absent section fails the run before it publishes. + - name: Take the release notes from the changelog + env: + VERSION: ${{ needs.resolve-version.outputs.version }} + run: | + set -euo pipefail + awk -v want="$VERSION" ' + $0 ~ ("^## \\[" want "\\]") { inside = 1; next } + inside && /^## / { exit } + inside { print } + ' CHANGELOG.md > notes.md + if ! grep -q '[^[:space:]]' notes.md; then + echo "::error::CHANGELOG.md has no section for $VERSION" >&2 + exit 1 + fi + { + echo "" + echo "## Checksums" + echo "" + echo '```' + cat SHA256SUMS + echo '```' + echo "" + echo "Every archive also carries a build provenance attestation:" + echo "\`gh attestation verify --repo $GITHUB_REPOSITORY\`." + } >> notes.md + cat notes.md + # Deliberately ahead of the npm steps. npm can fail for reasons that have # nothing to do with this repository — a trusted publisher not configured # yet, a registry outage — and when the release was created afterwards, @@ -224,7 +269,7 @@ jobs: else gh release create "$TAG" \ --title "$TAG" \ - --generate-notes \ + --notes-file notes.md \ artifacts/archive-*/*.tar.gz SHA256SUMS fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..afc3ce9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to droidsight are recorded here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +The release workflow reads the section matching the tag it is building and +publishes it as the release notes, above the generated checksum table. A tag +whose version has no section here fails the release rather than publishing an +empty page, so this file is edited in the same commit that bumps the version. + +## [Unreleased] + +### Added + +- The release archives carry a build provenance attestation, verifiable with + `gh attestation verify --repo edgecasehuman/droidsight`. The npm + packages have always been published with provenance; the standalone archives + were unsigned and unattested, with a published SHA256SUMS as their only check. +- MCP Registry metadata is published from the release workflow through GitHub + OIDC, with a pinned publisher and no long-lived token in the repository. + +### Fixed + +- The launcher could lose its own error messages. Every diagnostic was a + `console.error` followed immediately by `process.exit`, and writes to stderr + are asynchronous when stderr is a pipe — which is exactly what an MCP client + provides. The messages explaining a missing platform package or an + unexecutable binary are now written synchronously, so they survive the exit + they are reporting. +- The npm publish step's provenance path. + +### Changed + +- The README and CONTRIBUTING name NASM as a build prerequisite. `openh264-sys2` + assembles Cisco's decoder with it and no platform preinstalls it, so both + `cargo install droidsight` and a source build failed inside a transitive + dependency, where the cause is hard to read. It was documented only in + comments in the CI workflows. +- The README points at the crates.io and MCP Registry listings alongside `npx`. + +## [1.0.0] - 2026-08-12 + +### Added + +- Initial public release. An MCP server that drives a real Android device over + ADB and attaches the resulting screen to every action, so an agent does not + need a separate screenshot call. One native binary; the runtime is the binary + plus `adb`, with no Python, Appium, scrcpy, ffmpeg, or Node. +- A background H.264 `screenrecord` stream decoded in process, so the screen + following an action comes from cache rather than a fresh capture. +- Every image carries the coordinate space it was produced in, so a model can + tap what it just looked at without inferring a scale factor. +- OCR and template matching for screens the accessibility tree cannot read — + Flutter, React Native, canvas, games. +- Thirty-one tools published by default, and two more only when + `DROIDSIGHT_ALLOW_SHELL=1` is set. +- Distribution as five platform binaries through npm with provenance + attestations, a launcher package, and an entry in the MCP Registry. + +[Unreleased]: https://github.com/edgecasehuman/droidsight/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/edgecasehuman/droidsight/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 32c1eff..de348d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,7 +70,14 @@ finding must be removed and rotated rather than hidden with an ignore rule. The pinned compiler, formatter, and linter are declared in `rust-toolchain.toml`. Install rustup, then run all required host-side gates from -the repository root: +the repository root. + +One prerequisite is not managed by rustup: `openh264-sys2` builds Cisco's H.264 +decoder from C++ and assembles its hot paths with **NASM**, which no platform +preinstalls. Without `nasm` on the PATH every command below fails inside that +dependency rather than in this crate, which makes the cause hard to read. Install +it with `apt install nasm`, `brew install nasm`, or `winget install NASM.NASM`. +CI installs it the same way, as the first step of every job that compiles. ```sh cargo fmt --all -- --check diff --git a/README.md b/README.md index e309657..cc50026 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ [![CI](https://github.com/edgecasehuman/droidsight/actions/workflows/ci.yml/badge.svg)](https://github.com/edgecasehuman/droidsight/actions/workflows/ci.yml) [![Security audit](https://github.com/edgecasehuman/droidsight/actions/workflows/audit.yml/badge.svg)](https://github.com/edgecasehuman/droidsight/actions/workflows/audit.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![crates.io](https://img.shields.io/crates/v/droidsight.svg)](https://crates.io/crates/droidsight) +[![npm](https://img.shields.io/npm/v/%40edgecasehuman%2Fdroidsight.svg)](https://www.npmjs.com/package/@edgecasehuman/droidsight) An MCP server that drives a real Android device over ADB — and hands the agent back the screen its action produced. @@ -28,15 +30,35 @@ and an MCP client you trust. npx -y @edgecasehuman/droidsight ``` -Or build from source: +That downloads a prebuilt binary for your platform and needs no build tools. The +server is also listed in the official [MCP Registry] as +`io.github.edgecasehuman/droidsight`. + +To compile it instead, from [crates.io]: + +```bash +cargo install droidsight +``` + +Or from source: ```bash cargo build --locked --release --bin droidsight ``` +**Both compiling routes need NASM on the PATH.** The H.264 decoder is built from +C++ by `openh264-sys2`, which assembles its hot paths with NASM, and no platform +ships it by default -- without it the build fails inside a transitive dependency +rather than in this crate. Install it with `apt install nasm`, +`brew install nasm`, or `winget install NASM.NASM`. The `npx` route above needs +none of this. + The server is `target/release/droidsight`. It speaks newline-delimited JSON-RPC 2.0 over stdin and stdout. +[MCP Registry]: https://registry.modelcontextprotocol.io +[crates.io]: https://crates.io/crates/droidsight + ### Client configuration ```json diff --git a/npm/droidsight/bin/cli.js b/npm/droidsight/bin/cli.js index 5d14df7..e4adc11 100644 --- a/npm/droidsight/bin/cli.js +++ b/npm/droidsight/bin/cli.js @@ -10,9 +10,39 @@ const { spawn } = require("node:child_process"); const { constants } = require("node:os"); +const { writeSync } = require("node:fs"); const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"]; +// Every diagnostic below is followed immediately by process.exit, and that pair +// is exactly where console.error loses its output: writes to stderr are only +// synchronous for a TTY or a regular file. To a pipe on POSIX they are queued, +// and process.exit does not drain the queue. An MCP client always hands this +// process a pipe for stderr, so the plain form would drop these messages in the +// one situation they were written for, while looking correct in every terminal +// test. writeSync bypasses the queue. A non-blocking pipe can refuse the write +// with EAGAIN, which means retry rather than failed; EPIPE means the reader is +// already gone, and there is no one left to tell. +function fail(message) { + const buffer = Buffer.from(`${message}\n`, "utf8"); + let written = 0; + while (written < buffer.length) { + try { + written += writeSync(2, buffer, written); + } catch (error) { + if (error.code === "EAGAIN") { + continue; + } + if (error.code !== "EPIPE") { + // Last resort: report through the lossy path rather than not at all. + console.error(message); + } + break; + } + } + process.exit(1); +} + const PACKAGES = { "linux-x64": "@edgecasehuman/droidsight-linux-x64", "linux-arm64": "@edgecasehuman/droidsight-linux-arm64", @@ -25,24 +55,22 @@ const key = `${process.platform}-${process.arch}`; const pkg = PACKAGES[key]; if (!pkg) { - console.error( + fail( `droidsight: no prebuilt binary for ${key}.\n` + `Supported: ${Object.keys(PACKAGES).join(", ")}.\n` + - `Build from source instead: cargo install --git https://github.com/edgecasehuman/droidsight droidsight` + `Build from source instead: cargo install droidsight (needs NASM on the PATH).` ); - process.exit(1); } let binary; try { binary = require.resolve(`${pkg}/bin/${process.platform === "win32" ? "droidsight.exe" : "droidsight"}`); } catch { - console.error( + fail( `droidsight: the platform package ${pkg} is not installed.\n` + `This usually means the install ran with --no-optional or --omit=optional.\n` + `Reinstall without those flags, or install it directly: npm i ${pkg}` ); - process.exit(1); } // stdio: "inherit" hands the real descriptors to the child, so the JSON-RPC @@ -59,13 +87,11 @@ let child; try { child = spawn(binary, process.argv.slice(2), { stdio: "inherit" }); } catch (error) { - console.error(`droidsight: failed to start ${binary}: ${error.message}`); - process.exit(1); + fail(`droidsight: failed to start ${binary}: ${error.message}`); } child.on("error", (error) => { - console.error(`droidsight: failed to start ${binary}: ${error.message}`); - process.exit(1); + fail(`droidsight: failed to start ${binary}: ${error.message}`); }); // Forward termination so a client killing the launcher stops the server too.