diff --git a/integrations/adb/openphone-adb-transport.mjs b/integrations/adb/openphone-adb-transport.mjs index ca8b667..8ffd295 100644 --- a/integrations/adb/openphone-adb-transport.mjs +++ b/integrations/adb/openphone-adb-transport.mjs @@ -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() { @@ -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": @@ -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 }); @@ -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") { diff --git a/integrations/cli/adb/openphone-adb-transport.mjs b/integrations/cli/adb/openphone-adb-transport.mjs index ca8b667..8ffd295 100644 --- a/integrations/cli/adb/openphone-adb-transport.mjs +++ b/integrations/cli/adb/openphone-adb-transport.mjs @@ -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() { @@ -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": @@ -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 }); @@ -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") { diff --git a/integrations/mcp-server/README.md b/integrations/mcp-server/README.md index 422efae..c5f1808 100644 --- a/integrations/mcp-server/README.md +++ b/integrations/mcp-server/README.md @@ -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: diff --git a/integrations/mcp-server/adb/openphone-adb-transport.mjs b/integrations/mcp-server/adb/openphone-adb-transport.mjs index ca8b667..8ffd295 100644 --- a/integrations/mcp-server/adb/openphone-adb-transport.mjs +++ b/integrations/mcp-server/adb/openphone-adb-transport.mjs @@ -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() { @@ -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": @@ -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 }); @@ -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") { diff --git a/scripts/check-runtime-protocol.sh b/scripts/check-runtime-protocol.sh index 1d6b1f8..caea979 100755 --- a/scripts/check-runtime-protocol.sh +++ b/scripts/check-runtime-protocol.sh @@ -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" diff --git a/scripts/check.sh b/scripts/check.sh index 5cbbb15..99e8a2e 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -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 diff --git a/tests/README.md b/tests/README.md index 6660341..29bf33a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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 diff --git a/tests/integrations/adb-stateful-gating-contract.mjs b/tests/integrations/adb-stateful-gating-contract.mjs new file mode 100644 index 0000000..8f38b93 --- /dev/null +++ b/tests/integrations/adb-stateful-gating-contract.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { + OpenPhoneAdbTransport, + confirmationMap, + loadManifestCommands, +} from "../../integrations/adb/openphone-adb-transport.mjs"; + +delete process.env.OPENPHONE_DRY_RUN; +delete process.env.OPENPHONE_ADB_ALLOW_STATEFUL; + +class RecordingTransport extends OpenPhoneAdbTransport { + constructor(options = {}) { + super(options); + this.shellCalls = []; + } + + shell(args) { + this.shellCalls.push(args); + return ""; + } + + exec(args) { + this.shellCalls.push(args); + return Buffer.from(""); + } +} + +// The manifest is the source of truth for confirmation gating. +{ + const confirmations = confirmationMap(loadManifestCommands()); + assert.equal(confirmations.get("openphone.screen.get"), "none"); + assert.equal(confirmations.get("canvas.snapshot"), "none"); + assert.equal(confirmations.get("openphone.ui.tap"), "ask_before_action"); + assert.equal(confirmations.get("openphone.messages.send"), "always"); +} + +// State-changing tools are refused by default; ADB is never touched. +{ + const transport = new RecordingTransport(); + for (const name of [ + "openphone.ui.tap", + "openphone.ui.type_text", + "openphone.clipboard.set", + "openphone.url.open", + "openphone.app.open", + "openphone.messages.send", + ]) { + const result = transport.invoke(name, {}); + assert.equal(result.ok, false, `${name} must be refused by default`); + assert.equal(result.error.code, "stateful_tool_refused"); + assert.match(result.error.message, /OPENPHONE_ADB_ALLOW_STATEFUL/u); + assert.match(result.error.message, /OPENPHONE_DRY_RUN/u); + } + assert.deepEqual(transport.shellCalls, []); +} + +// Read-only tools still execute without any opt-in. +{ + const transport = new RecordingTransport(); + const result = transport.invoke("openphone.apps.search", { query: "" }); + assert.equal(result.ok, true); + assert.ok(transport.shellCalls.length > 0); +} + +// The allowStateful constructor option opts in programmatically. +{ + const transport = new RecordingTransport({ allowStateful: true }); + const result = transport.invoke("openphone.ui.tap", { x: 10, y: 20 }); + assert.deepEqual(result, { ok: true, x: 10, y: 20 }); + assert.deepEqual(transport.shellCalls, [["input", "tap", "10", "20"]]); +} + +// OPENPHONE_ADB_ALLOW_STATEFUL=1 (or true) opts in per-session. +{ + process.env.OPENPHONE_ADB_ALLOW_STATEFUL = "1"; + try { + const transport = new RecordingTransport(); + const result = transport.invoke("openphone.ui.type_text", { text: "hi" }); + assert.equal(result.ok, true); + assert.deepEqual(transport.shellCalls, [["input", "text", "hi"]]); + } finally { + delete process.env.OPENPHONE_ADB_ALLOW_STATEFUL; + } +} + +// An explicit allowStateful option overrides the environment. +{ + process.env.OPENPHONE_ADB_ALLOW_STATEFUL = "1"; + try { + const transport = new RecordingTransport({ allowStateful: false }); + const result = transport.invoke("openphone.ui.tap", { x: 1, y: 2 }); + assert.equal(result.ok, false); + assert.equal(result.error.code, "stateful_tool_refused"); + assert.deepEqual(transport.shellCalls, []); + } finally { + delete process.env.OPENPHONE_ADB_ALLOW_STATEFUL; + } +} + +// Dry-run keeps precedence: nothing executes, even when stateful is allowed. +{ + const transport = new RecordingTransport({ dryRun: true, allowStateful: true }); + const result = transport.invoke("openphone.ui.tap", { x: 3, y: 4 }); + assert.deepEqual(result, { + ok: true, + dry_run: true, + command: "openphone.ui.tap", + args: { x: 3, y: 4 }, + }); + assert.deepEqual(transport.shellCalls, []); +} + +// Dry-run also covers stateful tools without opt-in (safe exploration). +{ + const transport = new RecordingTransport({ dryRun: true }); + const result = transport.invoke("openphone.clipboard.set", { text: "x" }); + assert.equal(result.dry_run, true); + assert.deepEqual(transport.shellCalls, []); +}