qwen-code/packages/mobile-mcp/src/utils.ts
顾盼 23c6c7032a
feat(mobile-mcp): vendor mobile-mcp with opt-in 0-1000 relative coordinates (#6235)
* Squashed 'packages/mobile-mcp/' content from commit c5d7d27fd

git-subtree-dir: packages/mobile-mcp
git-subtree-split: c5d7d27fd61e4762e15ae4b1c68b6c011be88bb7

* feat(mobile-mcp): vendor mobile-mcp with opt-in 0-1000 relative coordinates

Fork mobile-next/mobile-mcp (v0.0.61) into packages/mobile-mcp/ via git
subtree, renamed to @qwen-code/mobile-mcp with the following additions:

Relative coordinate shim (src/coord-norm.ts):
- MOBILE_MCP_COORDINATE_SPACE=1 enables 0-1000 normalized coordinates
- MOBILE_MCP_COORDINATE_SCALE configurable (default 1000, 999 for mobile_use)
- Input denormalization for click/double_tap/long_press/swipe
- Output normalization for list_elements and get_screen_size
- Tool description rewriting when enabled
- Default off = zero behavior change

Android enhancements:
- mobile_install_app: -r/-g/-d/-t install flags (Android only)
- mobile_ui_dump: full UIAutomator XML hierarchy dump
- mobile_adb_pull / mobile_adb_push: file transfer via ADB

Infrastructure:
- cd-mobile-mcp.yml: npm publish workflow (tag mobile-mcp-v*)
- scripts/sync-from-upstream.sh: git subtree pull for upstream sync
- .vendored-from / .vendored-patches.md: vendoring metadata
- Upstream telemetry disabled by default
- eslint.config.js: exclude packages/mobile-mcp from root lint

* chore(mobile-mcp): update package-lock.json for workspace dependencies

* fix(mobile-mcp): quote all YAML strings to pass yamllint

* fix(mobile-mcp): fix cd workflow yaml to pass both yamllint and actionlint

* fix(mobile-mcp): address review findings on our additions

- ensureScreenSize: log warning instead of silent failure (#4)
- invalidateScreenSize on orientation change (#5)
- adb_push: path.posix.resolve to prevent /sdcard/ traversal (#6)
- adb_pull: readOnlyHint → destructiveHint (writes local file) (#9)
- adb_push: remove validateOutputPath on read-source local_path (#11)
- normalizeElementResult: log error instead of bare catch (#16)
- rewriteDescription: remove dead duplicate regex (#17)
- cd workflow: add test step between build and publish (#19)

* fix(mobile-mcp): update server.json identity and fix package.json main entrypoint

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-07-03 08:50:27 +00:00

88 lines
2.3 KiB
TypeScript

import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { ActionableError } from "./robot";
export function validatePackageName(packageName: string): void {
if (!/^[a-zA-Z0-9._]+$/.test(packageName)) {
throw new ActionableError(`Invalid package name: "${packageName}"`);
}
}
export function validateLocale(locale: string): void {
if (!/^[a-zA-Z0-9,\- ]+$/.test(locale)) {
throw new ActionableError(`Invalid locale: "${locale}"`);
}
}
function getAllowedRoots(): string[] {
const roots = [
os.tmpdir(),
process.cwd(),
];
// macOS /tmp is a symlink to /private/tmp, add both to be safe
if (process.platform === "darwin") {
roots.push("/tmp");
roots.push("/private/tmp");
}
return roots.map(r => path.resolve(r));
}
function isPathUnderRoot(filePath: string, root: string): boolean {
const relative = path.relative(root, filePath);
if (relative === "") {
return false;
}
if (path.isAbsolute(relative)) {
return false;
}
if (relative.startsWith("..")) {
return false;
}
return true;
}
export function validateFileExtension(filePath: string, allowedExtensions: string[], toolName: string): void {
const ext = path.extname(filePath).toLowerCase();
if (!allowedExtensions.includes(ext)) {
throw new ActionableError(`${toolName} requires a ${allowedExtensions.join(", ")} file extension, got: "${ext || "(none)"}"`);
}
}
function resolveWithSymlinks(filePath: string): string {
const resolved = path.resolve(filePath);
const dir = path.dirname(resolved);
const filename = path.basename(resolved);
try {
return path.join(fs.realpathSync(dir), filename);
} catch {
return resolved;
}
}
export function validateOutputPath(filePath: string): void {
const resolved = resolveWithSymlinks(filePath);
const allowedRoots = getAllowedRoots();
const isWindows = process.platform === "win32";
const isAllowed = allowedRoots.some(root => {
if (isWindows) {
return isPathUnderRoot(resolved.toLowerCase(), root.toLowerCase());
}
return isPathUnderRoot(resolved, root);
});
if (!isAllowed) {
const dir = path.dirname(resolved);
throw new ActionableError(
`"${dir}" is not in the list of allowed directories. Allowed directories include the current directory and the temp directory on this host.`
);
}
}