Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 49 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -195,16 +197,59 @@ 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
sha256sum artifacts/archive-*/*.tar.gz \
| 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 <archive> --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,
Expand All @@ -224,7 +269,7 @@ jobs:
else
gh release create "$TAG" \
--title "$TAG" \
--generate-notes \
--notes-file notes.md \
artifacts/archive-*/*.tar.gz SHA256SUMS
fi

Expand Down
62 changes: 62 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 <archive> --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
9 changes: 8 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
44 changes: 35 additions & 9 deletions npm/droidsight/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand All @@ -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.
Expand Down
Loading