From 9c92cd603db2946f9c8835dae6257412a65e351f Mon Sep 17 00:00:00 2001 From: edgecasehuman <309310929+edgecasehuman@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:59:50 +0000 Subject: [PATCH 1/3] Name NASM as the build prerequisite it has always been openh264-sys2 builds Cisco's H.264 decoder from C++ and assembles its hot paths with NASM, which no platform preinstalls. CI installs it as the first step of every job that compiles, and the release workflow does the same -- and those two comments were the only mention of it anywhere in the repository. So both compiling routes failed on a clean machine. `cargo build --locked --release --bin droidsight`, which the README offered as the alternative to npx, and `cargo install droidsight`, which is what someone arriving from crates.io will type. Neither failure names this crate: the error comes from a transitive dependency the reader has no reason to have heard of, which is the hardest possible shape for a first-contact error to take. The README also documented exactly one way to install this server. The crate has been on crates.io and the server in the MCP Registry since 1.0.0, and it mentioned neither. Both are now listed alongside npx, with badges, and the npx route is marked as the one that needs no build tools at all. --- CONTRIBUTING.md | 9 ++++++++- README.md | 24 +++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) 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 From 0ed4e9fb66651078077a999ade257668a96b36ad Mon Sep 17 00:00:00 2001 From: edgecasehuman <309310929+edgecasehuman@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:59:50 +0000 Subject: [PATCH 2/3] Write the launcher's diagnostics where a pipe cannot swallow them Every diagnostic in cli.js was a console.error followed immediately by process.exit. Writes to stderr are only synchronous when stderr is 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 gives this process a pipe for stderr. So the messages explaining a missing platform package, an --omit=optional install, or a binary that cannot be executed were at risk of being dropped in precisely the situation they were written for -- while looking correct in every terminal anyone would test them in. They now go through one helper that writes with fs.writeSync, which bypasses the queue. A non-blocking pipe can refuse the write with EAGAIN, which means retry rather than failed, so that case loops; EPIPE means the reader is already gone and there is nobody left to tell, so that one stops. Anything else falls back to console.error, on the grounds that reporting through the lossy path beats not reporting at all. The unsupported-platform message was also pointing at `cargo install --git` a moment before the crate existed on crates.io, and said nothing about NASM. It now names the published crate and the prerequisite, so it does not send someone to the failure the README was just taught to prevent. Verified with fourteen checks against the real file through fully piped stdio: message content on all three failure paths, argument forwarding, and exit-code passthrough. One caveat worth recording -- stderr-to-a-pipe is already synchronous on Windows, so a local run proves this did not regress rather than proving the POSIX case it exists for. --- npm/droidsight/bin/cli.js | 44 +++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 9 deletions(-) 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. From f4f03f08e6c48c6977c5692f561b208f7d264169 Mon Sep 17 00:00:00 2001 From: edgecasehuman <309310929+edgecasehuman@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:59:51 +0000 Subject: [PATCH 3/3] Attest the release archives, and take their notes from a changelog The workflow said it plainly: the npm packages carry provenance attestations, but the standalone archives are unsigned and unattested, and a published hash is the only integrity check someone downloading a tarball directly can perform. That hash is computed and published by this same workflow, so it can say a file has not changed since it was hashed and nothing at all about what produced it. attest-build-provenance binds each archive to this workflow, this commit, and this repository, in a store the release page cannot rewrite, checkable with `gh attestation verify --repo edgecasehuman/droidsight`. It runs before anything is published, like the npm preflight checks above it, so a failure ends the run while the tag is still re-runnable. The release notes were `--generate-notes`, which produces a list of commit subjects. That describes the work rather than what it means for someone deciding whether to upgrade, and it is the one thing a release page has to carry. Notes now come from a CHANGELOG section matching the version being built, with the checksums appended. A tag whose version has no section fails the run rather than publishing an empty page. The changelog itself is reconstructed from the tags and the commits, not from memory: 1.0.0 as released, and the three post-tag registry and provenance fixes under Unreleased. Verified by executing the workflow's own run: script -- read out of the YAML rather than retyped -- against the real changelog, for a version that exists, the oldest version, which has no successor heading to stop at, and a version that does not exist, which must fail. --- .github/workflows/release.yml | 53 +++++++++++++++++++++++++++--- CHANGELOG.md | 62 +++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 CHANGELOG.md 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