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
58 changes: 58 additions & 0 deletions integrations/adb/openphone-adb-transport.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const DEFAULT_ADB_TIMEOUT_MS = 15000;

const moduleDir = path.dirname(fileURLToPath(import.meta.url));
// The transport ships in two layouts: packaged (adb/ next to runtime/) and
// repo source (integrations/adb/ two levels above runtime/).
const MANIFEST_CANDIDATES = [
path.resolve(moduleDir, "../runtime/protocol/openphone-commands.json"),
path.resolve(moduleDir, "../../runtime/protocol/openphone-commands.json"),
];

export class OpenPhoneAdbTransport {
constructor(options = {}) {
this.adb = options.adb ?? process.env.ADB ?? "adb";
this.serial = options.serial ?? process.env.ANDROID_SERIAL ?? "";
this.dryRun = Boolean(options.dryRun ?? process.env.OPENPHONE_DRY_RUN);
this.allowStateful = options.allowStateful != null
? Boolean(options.allowStateful)
: truthySetting(process.env.OPENPHONE_ADB_ALLOW_STATEFUL);
this.timeoutMs = Number(options.timeoutMs ?? process.env.OPENPHONE_ADB_TIMEOUT_MS)
|| DEFAULT_ADB_TIMEOUT_MS;
this.confirmations = confirmationMap(options.commands ?? loadManifestCommands());
}

runtimeList() {
Expand Down Expand Up @@ -83,6 +98,10 @@ export class OpenPhoneAdbTransport {
if (this.dryRun) {
return { ok: true, dry_run: true, command, args };
}
const refusal = this.refuseStateful(command);
if (refusal) {
return refusal;
}
switch (command) {
case "openphone.screen.get":
case "canvas.snapshot":
Expand Down Expand Up @@ -118,6 +137,23 @@ export class OpenPhoneAdbTransport {
}
}

refuseStateful(command) {
const confirmation = this.confirmations.get(command) ?? "none";
if (confirmation === "none" || this.allowStateful) {
return null;
}
return {
ok: false,
error: {
code: "stateful_tool_refused",
message: `${command} changes device state (manifest confirmation: "${confirmation}") `
+ "and the ADB transport cannot ask the user for confirmation. "
+ "Set OPENPHONE_ADB_ALLOW_STATEFUL=1 (or pass allowStateful: true) to opt in, "
+ "or set OPENPHONE_DRY_RUN=1 to explore without touching the device.",
},
};
}

screenGet(args = {}) {
const activity = this.shell(["dumpsys", "window"], { allowFailure: true });
this.shell(["uiautomator", "dump", "/sdcard/window.xml"], { allowFailure: true });
Expand Down Expand Up @@ -263,6 +299,28 @@ export class OpenPhoneAdbTransport {
}
}

export function loadManifestCommands(candidates = MANIFEST_CANDIDATES) {
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
const manifest = JSON.parse(fs.readFileSync(candidate, "utf8"));
return manifest.commands ?? [];
}
}
throw new Error("openphone-commands.json manifest not found next to the ADB transport");
}

export function confirmationMap(commands) {
const out = new Map();
for (const command of commands) {
const confirmation = command.confirmation ?? "none";
out.set(command.name, confirmation);
for (const alias of command.aliases ?? []) {
out.set(alias, confirmation);
}
}
return out;
}

export function cleanRuntime(value) {
const clean = String(value ?? "").trim().toLowerCase();
if (clean === "phone" || clean === "local" || clean === "builtin") {
Expand Down
58 changes: 58 additions & 0 deletions integrations/cli/adb/openphone-adb-transport.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const DEFAULT_ADB_TIMEOUT_MS = 15000;

const moduleDir = path.dirname(fileURLToPath(import.meta.url));
// The transport ships in two layouts: packaged (adb/ next to runtime/) and
// repo source (integrations/adb/ two levels above runtime/).
const MANIFEST_CANDIDATES = [
path.resolve(moduleDir, "../runtime/protocol/openphone-commands.json"),
path.resolve(moduleDir, "../../runtime/protocol/openphone-commands.json"),
];

export class OpenPhoneAdbTransport {
constructor(options = {}) {
this.adb = options.adb ?? process.env.ADB ?? "adb";
this.serial = options.serial ?? process.env.ANDROID_SERIAL ?? "";
this.dryRun = Boolean(options.dryRun ?? process.env.OPENPHONE_DRY_RUN);
this.allowStateful = options.allowStateful != null
? Boolean(options.allowStateful)
: truthySetting(process.env.OPENPHONE_ADB_ALLOW_STATEFUL);
this.timeoutMs = Number(options.timeoutMs ?? process.env.OPENPHONE_ADB_TIMEOUT_MS)
|| DEFAULT_ADB_TIMEOUT_MS;
this.confirmations = confirmationMap(options.commands ?? loadManifestCommands());
}

runtimeList() {
Expand Down Expand Up @@ -83,6 +98,10 @@ export class OpenPhoneAdbTransport {
if (this.dryRun) {
return { ok: true, dry_run: true, command, args };
}
const refusal = this.refuseStateful(command);
if (refusal) {
return refusal;
}
switch (command) {
case "openphone.screen.get":
case "canvas.snapshot":
Expand Down Expand Up @@ -118,6 +137,23 @@ export class OpenPhoneAdbTransport {
}
}

refuseStateful(command) {
const confirmation = this.confirmations.get(command) ?? "none";
if (confirmation === "none" || this.allowStateful) {
return null;
}
return {
ok: false,
error: {
code: "stateful_tool_refused",
message: `${command} changes device state (manifest confirmation: "${confirmation}") `
+ "and the ADB transport cannot ask the user for confirmation. "
+ "Set OPENPHONE_ADB_ALLOW_STATEFUL=1 (or pass allowStateful: true) to opt in, "
+ "or set OPENPHONE_DRY_RUN=1 to explore without touching the device.",
},
};
}

screenGet(args = {}) {
const activity = this.shell(["dumpsys", "window"], { allowFailure: true });
this.shell(["uiautomator", "dump", "/sdcard/window.xml"], { allowFailure: true });
Expand Down Expand Up @@ -263,6 +299,28 @@ export class OpenPhoneAdbTransport {
}
}

export function loadManifestCommands(candidates = MANIFEST_CANDIDATES) {
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
const manifest = JSON.parse(fs.readFileSync(candidate, "utf8"));
return manifest.commands ?? [];
}
}
throw new Error("openphone-commands.json manifest not found next to the ADB transport");
}

export function confirmationMap(commands) {
const out = new Map();
for (const command of commands) {
const confirmation = command.confirmation ?? "none";
out.set(command.name, confirmation);
for (const alias of command.aliases ?? []) {
out.set(alias, confirmation);
}
}
return out;
}

export function cleanRuntime(value) {
const clean = String(value ?? "").trim().toLowerCase();
if (clean === "phone" || clean === "local" || clean === "builtin") {
Expand Down
16 changes: 14 additions & 2 deletions integrations/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,20 @@ Run:
node integrations/mcp-server/src/index.mjs
```

Set `ANDROID_SERIAL` to target a specific ADB device or emulator. Set
`OPENPHONE_DRY_RUN=1` for parser/protocol tests without ADB.
Set `ANDROID_SERIAL` to target a specific ADB device or emulator.

Safety defaults:

- `OPENPHONE_DRY_RUN=1` is the safe default for exploration: every tool call is
echoed back without touching ADB or the device at all.
- Without dry-run, the ADB transport still refuses state-changing tools (tap,
type, clipboard, open-url, and anything else the runtime manifest marks with
a `confirmation` requirement) because ADB has no on-device confirmation UI.
Read-only tools such as `openphone.screen.get` work normally.
- Set `OPENPHONE_ADB_ALLOW_STATEFUL=1` to opt in to real device driving for the
session. Programmatic users can pass `allowStateful: true` to
`OpenPhoneAdbTransport` instead. Only enable this when you trust every MCP
client connected to the server, since confirmation prompts are not enforced.

The same ADB transport works with the OpenPhone SDK phone emulator:

Expand Down
58 changes: 58 additions & 0 deletions integrations/mcp-server/adb/openphone-adb-transport.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const DEFAULT_ADB_TIMEOUT_MS = 15000;

const moduleDir = path.dirname(fileURLToPath(import.meta.url));
// The transport ships in two layouts: packaged (adb/ next to runtime/) and
// repo source (integrations/adb/ two levels above runtime/).
const MANIFEST_CANDIDATES = [
path.resolve(moduleDir, "../runtime/protocol/openphone-commands.json"),
path.resolve(moduleDir, "../../runtime/protocol/openphone-commands.json"),
];

export class OpenPhoneAdbTransport {
constructor(options = {}) {
this.adb = options.adb ?? process.env.ADB ?? "adb";
this.serial = options.serial ?? process.env.ANDROID_SERIAL ?? "";
this.dryRun = Boolean(options.dryRun ?? process.env.OPENPHONE_DRY_RUN);
this.allowStateful = options.allowStateful != null
? Boolean(options.allowStateful)
: truthySetting(process.env.OPENPHONE_ADB_ALLOW_STATEFUL);
this.timeoutMs = Number(options.timeoutMs ?? process.env.OPENPHONE_ADB_TIMEOUT_MS)
|| DEFAULT_ADB_TIMEOUT_MS;
this.confirmations = confirmationMap(options.commands ?? loadManifestCommands());
}

runtimeList() {
Expand Down Expand Up @@ -83,6 +98,10 @@ export class OpenPhoneAdbTransport {
if (this.dryRun) {
return { ok: true, dry_run: true, command, args };
}
const refusal = this.refuseStateful(command);
if (refusal) {
return refusal;
}
switch (command) {
case "openphone.screen.get":
case "canvas.snapshot":
Expand Down Expand Up @@ -118,6 +137,23 @@ export class OpenPhoneAdbTransport {
}
}

refuseStateful(command) {
const confirmation = this.confirmations.get(command) ?? "none";
if (confirmation === "none" || this.allowStateful) {
return null;
}
return {
ok: false,
error: {
code: "stateful_tool_refused",
message: `${command} changes device state (manifest confirmation: "${confirmation}") `
+ "and the ADB transport cannot ask the user for confirmation. "
+ "Set OPENPHONE_ADB_ALLOW_STATEFUL=1 (or pass allowStateful: true) to opt in, "
+ "or set OPENPHONE_DRY_RUN=1 to explore without touching the device.",
},
};
}

screenGet(args = {}) {
const activity = this.shell(["dumpsys", "window"], { allowFailure: true });
this.shell(["uiautomator", "dump", "/sdcard/window.xml"], { allowFailure: true });
Expand Down Expand Up @@ -263,6 +299,28 @@ export class OpenPhoneAdbTransport {
}
}

export function loadManifestCommands(candidates = MANIFEST_CANDIDATES) {
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
const manifest = JSON.parse(fs.readFileSync(candidate, "utf8"));
return manifest.commands ?? [];
}
}
throw new Error("openphone-commands.json manifest not found next to the ADB transport");
}

export function confirmationMap(commands) {
const out = new Map();
for (const command of commands) {
const confirmation = command.confirmation ?? "none";
out.set(command.name, confirmation);
for (const alias of command.aliases ?? []) {
out.set(alias, confirmation);
}
}
return out;
}

export function cleanRuntime(value) {
const clean = String(value ?? "").trim().toLowerCase();
if (clean === "phone" || clean === "local" || clean === "builtin") {
Expand Down
1 change: 1 addition & 0 deletions scripts/check-runtime-protocol.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ node --check "$root/runtime/protocol/openphone-runtime-tools.mjs"
node --check "$root/integrations/adb/openphone-adb-transport.mjs"
node --check "$root/integrations/cli/src/index.mjs"
node --check "$root/integrations/mcp-server/src/index.mjs"
node "$root/tests/integrations/adb-stateful-gating-contract.mjs"
node "$root/tests/integrations/openclaw-plugin-policy-contract.mjs"
node "$root/tests/integrations/runtime-cli-contract.mjs"
node "$root/tests/integrations/runtime-mcp-contract.mjs"
Expand Down
1 change: 1 addition & 0 deletions scripts/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ required=(
integrations/cli/package.json
integrations/cli/src/index.mjs
tests/README.md
tests/integrations/adb-stateful-gating-contract.mjs
tests/integrations/runtime-cli-contract.mjs
tests/integrations/runtime-mcp-contract.mjs
tests/integrations/openclaw-plugin-policy-contract.mjs
Expand Down
2 changes: 2 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This directory contains repo-level tests that are reusable across packages.

`tests/integrations/` covers developer-facing runtime integrations:

- `adb-stateful-gating-contract.mjs` checks that the ADB transport refuses
manifest-flagged stateful tools unless the operator opts in.
- `runtime-cli-contract.mjs` checks the manifest-backed CLI surface.
- `runtime-mcp-contract.mjs` checks the MCP JSON-RPC tool surface.
- `openclaw-plugin-policy-contract.mjs` checks the OpenClaw plugin policy
Expand Down
Loading
Loading