deepadb 1.0.9 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,22 +1,211 @@
1
1
  /**
2
- * File Tools — Push, pull, list, and manage files on the device.
2
+ * File Tools — Filesystem operations on the Android device.
3
+ *
4
+ * 18 tools covering file transfer, inspection, creation, deletion, search,
5
+ * content editing, and filesystem metadata. All path-accepting tools use
6
+ * shellEscape() for injection prevention.
7
+ *
8
+ * Safety model:
9
+ * - Hard-blocked paths: /, /dev, /proc, /sys — kernel virtual filesystems
10
+ * where writes can panic the kernel. Blocked for ALL destructive operations.
11
+ * - Depth-based recursive protection: recursive delete/chmod/chown refuse at
12
+ * depth ≤ 2 from root (blocks rm -rf /system, rm -rf /sdcard but allows
13
+ * /sdcard/project/build/).
14
+ * - Symlink resolution: realpath is resolved BEFORE depth checks to prevent
15
+ * symlink traversal bypasses (e.g., /sdcard/link -> /system).
16
+ * - Move source protection: refuse if source is at depth ≤ 1 from root.
17
+ * - Filesystem-aware warnings: detects erofs/squashfs (read-only), sdcardfs/FUSE
18
+ * (ignores chmod), vfat/FAT32 (no permissions, 4GB limit, 2s timestamps),
19
+ * tmpfs (volatile).
20
+ * - Root parameter: explicit opt-in for privileged operations (root=false default).
21
+ *
22
+ * Transfer & read: adb_push, adb_pull, adb_ls, adb_cat
23
+ * Content editing: adb_file_write, adb_file_replace
24
+ * Search: adb_find, adb_grep
25
+ * Metadata: adb_file_stat, adb_file_checksum, adb_file_fsinfo
26
+ * Creation/deletion: adb_mkdir, adb_rm, adb_file_touch
27
+ * Movement: adb_file_move, adb_file_copy
28
+ * Permissions/ownership: adb_file_chmod, adb_file_chown
3
29
  */
4
30
  import { z } from "zod";
5
31
  import { join } from "path";
6
32
  import { OutputProcessor } from "../middleware/output-processor.js";
7
33
  import { shellEscape } from "../middleware/sanitize.js";
34
+ // ── Constants ────────────────────────────────────────────────────────────────
35
+ /** Kernel virtual filesystems — writes can panic the kernel. */
36
+ const HARD_BLOCKED_PATHS = ["/dev", "/proc", "/sys"];
37
+ /** Filesystem types that are always read-only. */
38
+ const READ_ONLY_FS = ["erofs", "squashfs"];
39
+ /** Filesystem types with restricted behavior. */
40
+ const FS_WARNINGS = {
41
+ vfat: "FAT32: no Unix permissions, no symlinks, 4GB file size limit, 2-second timestamp resolution",
42
+ fuseblk: "FUSE block device: behavior depends on underlying driver",
43
+ sdcardfs: "Android storage layer: ignores chmod — file access controlled by package ownership",
44
+ fuse: "FUSE: Android scoped storage wrapper — chmod may be ignored",
45
+ tmpfs: "Volatile: memory-backed, contents lost on reboot",
46
+ };
47
+ /** Map filesystem magic numbers (from stat -f -c '%T') to readable names.
48
+ * Android's stat often returns hex magic instead of names. */
49
+ const FS_MAGIC_MAP = {
50
+ "0x65735546": "fuse",
51
+ "65735546": "fuse",
52
+ "0xef53": "ext4",
53
+ "ef53": "ext4",
54
+ "0xf2f52010": "f2fs",
55
+ "f2f52010": "f2fs",
56
+ "0xe0f5e1e2": "erofs",
57
+ "e0f5e1e2": "erofs",
58
+ "0x1021994": "tmpfs",
59
+ "1021994": "tmpfs",
60
+ "0x4d44": "vfat",
61
+ "4d44": "vfat",
62
+ "0x73717368": "squashfs",
63
+ "73717368": "squashfs",
64
+ "0x9fa0": "procfs",
65
+ "9fa0": "procfs",
66
+ "0x62656572": "sysfs",
67
+ "62656572": "sysfs",
68
+ };
69
+ // ── Shared Helpers ───────────────────────────────────────────────────────────
70
+ /** Get the depth of a path from root. "/" = 0, "/system" = 1, "/system/app" = 2. */
71
+ function pathDepth(p) {
72
+ const segments = p.replace(/\/+$/, "").split("/").filter(s => s.length > 0);
73
+ return segments.length;
74
+ }
75
+ /** Check if a path is hard-blocked (kernel virtual filesystems). */
76
+ function isHardBlocked(resolvedPath) {
77
+ const normalized = resolvedPath.replace(/\/+$/, "");
78
+ if (normalized === "" || normalized === "/") {
79
+ return "Refusing operation on root filesystem (/).";
80
+ }
81
+ for (const blocked of HARD_BLOCKED_PATHS) {
82
+ if (normalized === blocked || normalized.startsWith(blocked + "/")) {
83
+ return `Refusing operation on ${blocked} — kernel virtual filesystem where writes can cause system instability.`;
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ /** Resolve symlinks via realpath on the device. Returns the resolved path or original on failure. */
89
+ async function resolveRealPath(ctx, serial, path, root) {
90
+ const shell = root ? ctx.bridge.rootShell.bind(ctx.bridge) : ctx.bridge.shell.bind(ctx.bridge);
91
+ const result = await shell(`realpath '${shellEscape(path)}' 2>/dev/null || echo '${shellEscape(path)}'`, {
92
+ device: serial, timeout: 3000, ignoreExitCode: true,
93
+ });
94
+ return result.stdout.trim() || path;
95
+ }
96
+ /** Query available storage on the partition containing the given path.
97
+ * Falls back to parent directory if the path no longer exists (e.g., after deletion). */
98
+ async function getStorageInfo(ctx, serial, path, root) {
99
+ const shell = root ? ctx.bridge.rootShell.bind(ctx.bridge) : ctx.bridge.shell.bind(ctx.bridge);
100
+ // Try the path itself, then fall back to its parent directory
101
+ const escaped = shellEscape(path);
102
+ const parentDir = path.replace(/\/[^/]+\/?$/, "") || "/";
103
+ const parentEscaped = shellEscape(parentDir);
104
+ const result = await shell(`df -h '${escaped}' 2>/dev/null || df -h '${parentEscaped}' 2>/dev/null`, { device: serial, timeout: 5000, ignoreExitCode: true });
105
+ const lines = result.stdout.trim().split("\n");
106
+ const dataLine = lines[lines.length - 1] ?? "";
107
+ if (!dataLine)
108
+ return "unknown";
109
+ // df -h output: Filesystem Size Used Avail Use% Mounted_on
110
+ const fields = dataLine.split(/\s+/);
111
+ if (fields.length >= 4) {
112
+ return `${fields[3]} available (${fields[2]} used of ${fields[1]} on ${fields[5] ?? fields[0]})`;
113
+ }
114
+ return dataLine;
115
+ }
116
+ /** Detect filesystem type for a given path. Resolves hex magic numbers to names. */
117
+ async function detectFsType(ctx, serial, path, root) {
118
+ const shell = root ? ctx.bridge.rootShell.bind(ctx.bridge) : ctx.bridge.shell.bind(ctx.bridge);
119
+ const result = await shell(`stat -f -c '%T' '${shellEscape(path)}' 2>/dev/null`, {
120
+ device: serial, timeout: 3000, ignoreExitCode: true,
121
+ });
122
+ let fsType = result.stdout.trim().toLowerCase();
123
+ if (!fsType || fsType === "unknown") {
124
+ return { fsType: "unknown", warning: null, readOnly: false };
125
+ }
126
+ // Android's stat -f often returns hex magic numbers instead of names
127
+ fsType = FS_MAGIC_MAP[fsType] ?? fsType;
128
+ const readOnly = READ_ONLY_FS.some(ro => fsType.includes(ro));
129
+ const warning = readOnly
130
+ ? `Read-only filesystem (${fsType}) — this is a system partition. Remount with 'mount -o remount,rw <mountpoint>' (root required) to modify.`
131
+ : (FS_WARNINGS[fsType] ?? null);
132
+ return { fsType, warning, readOnly };
133
+ }
134
+ /** Format operation metrics for output. */
135
+ function formatMetrics(metrics) {
136
+ const lines = [];
137
+ lines.push(`Operation: ${metrics.operation}`);
138
+ lines.push(`Path: ${metrics.path}`);
139
+ for (const [key, val] of Object.entries(metrics.extras ?? {})) {
140
+ lines.push(`${key}: ${val}`);
141
+ }
142
+ lines.push(`Execution time: ${metrics.executionMs}ms`);
143
+ if (metrics.storage)
144
+ lines.push(`Available storage: ${metrics.storage}`);
145
+ if (metrics.fsWarning)
146
+ lines.push(`⚠ ${metrics.fsWarning}`);
147
+ return lines.join("\n");
148
+ }
149
+ /** Execute a shell command with optional root elevation. */
150
+ function execShell(ctx, root) {
151
+ return root ? ctx.bridge.rootShell.bind(ctx.bridge) : ctx.bridge.shell.bind(ctx.bridge);
152
+ }
153
+ /** Format a byte count as a human-readable string with appropriate unit. */
154
+ function formatBytes(n) {
155
+ if (n >= 1024 * 1024)
156
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
157
+ if (n >= 1024)
158
+ return `${(n / 1024).toFixed(1)} KB`;
159
+ return `${n} bytes`;
160
+ }
161
+ /** Escape a string for use as a sed fixed-string pattern (BRE).
162
+ * Escapes all regex metacharacters so the pattern matches literally,
163
+ * then closes/reopens the surrounding shell single-quote around any `'`
164
+ * so the final shell command remains well-formed. */
165
+ function sedEscapePattern(str) {
166
+ return str
167
+ .replace(/[[\].*^$\\\/&]/g, "\\$&")
168
+ .replace(/'/g, "'\\''");
169
+ }
170
+ /** Escape a string for use in a sed replacement string.
171
+ * Only & \ and the delimiter need escaping in the replacement, then
172
+ * close/reopen the surrounding shell single-quote around any `'`. */
173
+ function sedEscapeReplacement(str) {
174
+ return str
175
+ .replace(/[\\\/&]/g, "\\$&")
176
+ .replace(/'/g, "'\\''");
177
+ }
178
+ // ── Tool Registration ────────────────────────────────────────────────────────
8
179
  export function registerFileTools(ctx) {
9
- ctx.server.tool("adb_push", "Push a local file to the device filesystem", {
180
+ // ── Existing tools (transfer & read) ────────────────────────────────────
181
+ ctx.server.tool("adb_push", "Push a local file to the device filesystem. Pre-flight checks: hard-blocked kernel paths, filesystem type warnings, symlink resolution, and destination storage availability.", {
10
182
  localPath: z.string().describe("Local file path to push"),
11
183
  remotePath: z.string().describe("Destination path on device"),
12
184
  device: z.string().optional().describe("Device serial"),
13
185
  }, async ({ localPath, remotePath, device }) => {
186
+ const start = Date.now();
14
187
  try {
15
188
  const resolved = await ctx.deviceManager.resolveDevice(device);
189
+ const serial = resolved.serial;
190
+ // Pre-flight safety: resolve symlinks and check destination
191
+ const realDest = await resolveRealPath(ctx, serial, remotePath, false);
192
+ const blocked = isHardBlocked(realDest);
193
+ if (blocked)
194
+ return { content: [{ type: "text", text: blocked }], isError: true };
195
+ const fs = await detectFsType(ctx, serial, remotePath, false);
196
+ if (fs.readOnly)
197
+ return { content: [{ type: "text", text: fs.warning }], isError: true };
16
198
  const result = await ctx.bridge.exec(["push", localPath, remotePath], {
17
- device: resolved.serial, timeout: 120000,
199
+ device: serial, timeout: 120000,
18
200
  });
19
- return { content: [{ type: "text", text: result.stdout.trim() || "File pushed successfully." }] };
201
+ const storage = await getStorageInfo(ctx, serial, remotePath, false);
202
+ const output = result.stdout.trim() || "File pushed successfully.";
203
+ return {
204
+ content: [{
205
+ type: "text",
206
+ text: `${output}\nExecution time: ${Date.now() - start}ms\nAvailable storage: ${storage}${fs.warning ? "\n⚠ " + fs.warning : ""}`,
207
+ }],
208
+ };
20
209
  }
21
210
  catch (error) {
22
211
  return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
@@ -27,6 +216,7 @@ export function registerFileTools(ctx) {
27
216
  localPath: z.string().optional().describe("Local destination (defaults to temp dir)"),
28
217
  device: z.string().optional().describe("Device serial"),
29
218
  }, async ({ remotePath, localPath, device }) => {
219
+ const start = Date.now();
30
220
  try {
31
221
  const resolved = await ctx.deviceManager.resolveDevice(device);
32
222
  const fileName = remotePath.split("/").pop() ?? "pulled_file";
@@ -35,7 +225,7 @@ export function registerFileTools(ctx) {
35
225
  device: resolved.serial, timeout: 120000,
36
226
  });
37
227
  return {
38
- content: [{ type: "text", text: `${result.stdout.trim()}\nSaved to: ${dest}` }],
228
+ content: [{ type: "text", text: `${result.stdout.trim()}\nSaved to: ${dest}\nExecution time: ${Date.now() - start}ms` }],
39
229
  };
40
230
  }
41
231
  catch (error) {
@@ -47,11 +237,12 @@ export function registerFileTools(ctx) {
47
237
  device: z.string().optional().describe("Device serial"),
48
238
  details: z.boolean().optional().default(false).describe("Show detailed listing (ls -la)"),
49
239
  }, async ({ path, device, details }) => {
240
+ const start = Date.now();
50
241
  try {
51
242
  const resolved = await ctx.deviceManager.resolveDevice(device);
52
243
  const cmd = details ? `ls -la '${shellEscape(path)}'` : `ls '${shellEscape(path)}'`;
53
244
  const result = await ctx.bridge.shell(cmd, { device: resolved.serial });
54
- return { content: [{ type: "text", text: OutputProcessor.process(result.stdout) }] };
245
+ return { content: [{ type: "text", text: `${OutputProcessor.process(result.stdout)}\nExecution time: ${Date.now() - start}ms` }] };
55
246
  }
56
247
  catch (error) {
57
248
  return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
@@ -62,13 +253,878 @@ export function registerFileTools(ctx) {
62
253
  device: z.string().optional().describe("Device serial"),
63
254
  maxLines: z.number().min(1).max(10000).optional().describe("Maximum lines to return (1-10000)"),
64
255
  }, async ({ path, device, maxLines }) => {
256
+ const start = Date.now();
65
257
  try {
66
258
  const resolved = await ctx.deviceManager.resolveDevice(device);
67
259
  let cmd = `cat '${shellEscape(path)}'`;
68
260
  if (maxLines)
69
261
  cmd = `head -n ${maxLines} '${shellEscape(path)}'`;
70
262
  const result = await ctx.bridge.shell(cmd, { device: resolved.serial });
71
- return { content: [{ type: "text", text: OutputProcessor.process(result.stdout) }] };
263
+ return { content: [{ type: "text", text: `${OutputProcessor.process(result.stdout)}\nExecution time: ${Date.now() - start}ms` }] };
264
+ }
265
+ catch (error) {
266
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
267
+ }
268
+ });
269
+ // ── New tools (filesystem operations) ───────────────────────────────────
270
+ ctx.server.tool("adb_file_write", "Create or overwrite a text file on the device. Content is delivered via shell heredoc — suitable for config files, scripts, test fixtures, and small data files. For large or binary files, use adb_push instead. Shell buffer limits content to approximately 128KB.", {
271
+ path: z.string().describe("Destination file path on device"),
272
+ content: z.string().describe("File content to write"),
273
+ append: z.boolean().optional().default(false).describe("Append to existing file instead of overwriting"),
274
+ root: z.boolean().optional().default(false).describe("Use root shell for protected paths"),
275
+ device: z.string().optional().describe("Device serial"),
276
+ }, async ({ path: filePath, content, append, root, device }) => {
277
+ const start = Date.now();
278
+ try {
279
+ const resolved = await ctx.deviceManager.resolveDevice(device);
280
+ const serial = resolved.serial;
281
+ const shell = execShell(ctx, root);
282
+ // Resolve symlinks and check safety
283
+ const realPath = await resolveRealPath(ctx, serial, filePath, root);
284
+ const blocked = isHardBlocked(realPath);
285
+ if (blocked)
286
+ return { content: [{ type: "text", text: blocked }], isError: true };
287
+ // Filesystem type pre-flight
288
+ const fs = await detectFsType(ctx, serial, filePath, root);
289
+ if (fs.readOnly)
290
+ return { content: [{ type: "text", text: fs.warning }], isError: true };
291
+ // Warn if content approaches shell buffer limit
292
+ const contentBytes = Buffer.byteLength(content, "utf-8");
293
+ const BUFFER_WARN_THRESHOLD = 120000; // ~128KB shell limit
294
+ if (contentBytes > BUFFER_WARN_THRESHOLD) {
295
+ return {
296
+ content: [{ type: "text", text: `Content size (${(contentBytes / 1024).toFixed(1)} KB) approaches shell buffer limit (~128KB). Use adb_push for large file creation — write content to a local file first, then push it.` }],
297
+ isError: true,
298
+ };
299
+ }
300
+ // Generate a unique heredoc delimiter that won't appear in the content
301
+ let delimiter = "DEEPADB_EOF";
302
+ while (content.includes(delimiter)) {
303
+ delimiter = `DEEPADB_EOF_${Math.random().toString(36).substring(2, 8)}`;
304
+ }
305
+ const op = append ? ">>" : ">";
306
+ const cmd = `cat << '${delimiter}' ${op} '${shellEscape(filePath)}'\n${content}\n${delimiter}`;
307
+ const result = await shell(cmd, {
308
+ device: serial, timeout: 30000, ignoreExitCode: true,
309
+ });
310
+ if (result.stderr && result.stderr.includes("Read-only file system")) {
311
+ return { content: [{ type: "text", text: `Write failed: read-only filesystem. Path: ${filePath}\nUse root=true or remount the partition.` }], isError: true };
312
+ }
313
+ if (result.exitCode !== 0 && result.stderr) {
314
+ return { content: [{ type: "text", text: `Write failed: ${result.stderr.trim()}${!root ? "\nTip: try root=true for protected paths" : ""}` }], isError: true };
315
+ }
316
+ // Post-write verification
317
+ const verifyResult = await shell(`stat -c '%s' '${shellEscape(filePath)}' 2>/dev/null`, {
318
+ device: serial, timeout: 3000, ignoreExitCode: true,
319
+ });
320
+ const writtenSize = verifyResult.stdout.trim();
321
+ const storage = await getStorageInfo(ctx, serial, filePath, root);
322
+ return {
323
+ content: [{
324
+ type: "text",
325
+ text: formatMetrics({
326
+ operation: append ? "append" : "write",
327
+ path: filePath,
328
+ executionMs: Date.now() - start,
329
+ extras: {
330
+ "Written size": writtenSize ? `${writtenSize} bytes` : `~${contentBytes} bytes`,
331
+ "Content length": `${content.split("\n").length} lines`,
332
+ },
333
+ storage,
334
+ fsWarning: fs.warning,
335
+ }),
336
+ }],
337
+ };
338
+ }
339
+ catch (error) {
340
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
341
+ }
342
+ });
343
+ ctx.server.tool("adb_find", "Search for files on the device by name or pattern. Uses the find command with glob matching. Results are capped at maxResults to prevent unbounded output. Reports whether results were truncated.", {
344
+ searchPath: z.string().default("/sdcard").describe("Starting directory for search"),
345
+ name: z.string().describe("Filename pattern (glob: *.apk, config.*, *test*)"),
346
+ type: z.enum(["file", "directory", "any"]).optional().default("any").describe("Filter by type: file, directory, or any"),
347
+ maxDepth: z.number().min(1).max(50).optional().default(10).describe("Maximum directory depth to search (1-50, default 10)"),
348
+ maxResults: z.number().min(1).max(10000).optional().default(500).describe("Maximum results to return (1-10000, default 500)"),
349
+ root: z.boolean().optional().default(false).describe("Use root shell for system paths"),
350
+ device: z.string().optional().describe("Device serial"),
351
+ }, async ({ searchPath, name, type, maxDepth, maxResults, root, device }) => {
352
+ const start = Date.now();
353
+ try {
354
+ const resolved = await ctx.deviceManager.resolveDevice(device);
355
+ const serial = resolved.serial;
356
+ const shell = execShell(ctx, root);
357
+ let cmd = `find '${shellEscape(searchPath)}' -maxdepth ${maxDepth}`;
358
+ if (type === "file")
359
+ cmd += " -type f";
360
+ else if (type === "directory")
361
+ cmd += " -type d";
362
+ cmd += ` -name '${shellEscape(name)}'`;
363
+ // Request one extra to detect truncation
364
+ cmd += ` 2>/dev/null | head -n ${maxResults + 1}`;
365
+ const result = await shell(cmd, {
366
+ device: serial, timeout: 60000, ignoreExitCode: true,
367
+ });
368
+ const lines = result.stdout.trim().split("\n").filter(l => l.length > 0);
369
+ const truncated = lines.length > maxResults;
370
+ const results = truncated ? lines.slice(0, maxResults) : lines;
371
+ if (results.length === 0) {
372
+ return { content: [{ type: "text", text: `No files matching '${name}' found in ${searchPath} (depth ${maxDepth}).\nExecution time: ${Date.now() - start}ms` }] };
373
+ }
374
+ let output = results.join("\n");
375
+ output += `\n\n${results.length} result(s)`;
376
+ if (truncated)
377
+ output += ` (truncated at ${maxResults} — more results exist, increase maxResults or narrow the search)`;
378
+ output += `\nExecution time: ${Date.now() - start}ms`;
379
+ return { content: [{ type: "text", text: output }] };
380
+ }
381
+ catch (error) {
382
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
383
+ }
384
+ });
385
+ ctx.server.tool("adb_file_stat", "Get detailed file metadata: size, permissions, ownership, timestamps (access/modify/change), SELinux security context, and file type. Read-only operation.", {
386
+ path: z.string().describe("File or directory path on device"),
387
+ root: z.boolean().optional().default(false).describe("Use root shell for protected paths"),
388
+ device: z.string().optional().describe("Device serial"),
389
+ }, async ({ path: filePath, root, device }) => {
390
+ const start = Date.now();
391
+ try {
392
+ const resolved = await ctx.deviceManager.resolveDevice(device);
393
+ const serial = resolved.serial;
394
+ const shell = execShell(ctx, root);
395
+ const escaped = shellEscape(filePath);
396
+ // stat + ls -Z for SELinux context in a single command
397
+ const result = await shell(`stat '${escaped}' 2>&1 && echo '---SELINUX---' && ls -Zd '${escaped}' 2>/dev/null`, { device: serial, timeout: 10000, ignoreExitCode: true });
398
+ const output = result.stdout.trim();
399
+ if (output.includes("No such file or directory")) {
400
+ return { content: [{ type: "text", text: `File not found: ${filePath}` }], isError: true };
401
+ }
402
+ const parts = output.split("---SELINUX---");
403
+ let text = parts[0].trim();
404
+ if (parts[1]) {
405
+ const seMatch = parts[1].trim().match(/^(\S+)\s/);
406
+ if (seMatch)
407
+ text += `\nSELinux context: ${seMatch[1]}`;
408
+ }
409
+ text += `\nExecution time: ${Date.now() - start}ms`;
410
+ return { content: [{ type: "text", text }] };
411
+ }
412
+ catch (error) {
413
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
414
+ }
415
+ });
416
+ ctx.server.tool("adb_file_checksum", "Compute SHA-256 hash of a file on the device. Critical for firmware integrity verification, tamper detection, and comparing files across devices. Read-only operation. Reports file size alongside the hash.", {
417
+ path: z.string().describe("File path on device"),
418
+ algorithm: z.enum(["sha256", "sha1", "md5"]).optional().default("sha256").describe("Hash algorithm (default: sha256)"),
419
+ root: z.boolean().optional().default(false).describe("Use root shell for protected paths"),
420
+ device: z.string().optional().describe("Device serial"),
421
+ }, async ({ path: filePath, algorithm, root, device }) => {
422
+ const start = Date.now();
423
+ try {
424
+ const resolved = await ctx.deviceManager.resolveDevice(device);
425
+ const serial = resolved.serial;
426
+ const shell = execShell(ctx, root);
427
+ const escaped = shellEscape(filePath);
428
+ // Get size first for timeout estimation
429
+ const sizeResult = await shell(`stat -c '%s' '${escaped}' 2>/dev/null`, {
430
+ device: serial, timeout: 3000, ignoreExitCode: true,
431
+ });
432
+ const fileSize = parseInt(sizeResult.stdout.trim(), 10) || 0;
433
+ // ~100MB/s for sha256 on typical SoC; minimum 10s, maximum 5min
434
+ const hashTimeout = Math.max(10000, Math.min(300000, Math.ceil(fileSize / (100 * 1024 * 1024)) * 1000 + 10000));
435
+ const hashCmd = algorithm === "md5" ? "md5sum" : algorithm === "sha1" ? "sha1sum" : "sha256sum";
436
+ const result = await shell(`${hashCmd} '${escaped}' 2>&1`, {
437
+ device: serial, timeout: hashTimeout, ignoreExitCode: true,
438
+ });
439
+ const output = result.stdout.trim();
440
+ if (output.includes("No such file or directory")) {
441
+ return { content: [{ type: "text", text: `File not found: ${filePath}` }], isError: true };
442
+ }
443
+ const hashMatch = output.match(/^([a-f0-9]+)\s/);
444
+ if (!hashMatch) {
445
+ return { content: [{ type: "text", text: `Hash computation failed: ${output}` }], isError: true };
446
+ }
447
+ return {
448
+ content: [{
449
+ type: "text",
450
+ text: formatMetrics({
451
+ operation: `checksum (${algorithm})`,
452
+ path: filePath,
453
+ executionMs: Date.now() - start,
454
+ extras: {
455
+ "Hash": hashMatch[1],
456
+ "Algorithm": algorithm.toUpperCase(),
457
+ "File size": formatBytes(fileSize),
458
+ },
459
+ }),
460
+ }],
461
+ };
462
+ }
463
+ catch (error) {
464
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
465
+ }
466
+ });
467
+ ctx.server.tool("adb_mkdir", "Create a directory on the device. Supports -p flag for creating parent directories.", {
468
+ path: z.string().describe("Directory path to create"),
469
+ parents: z.boolean().optional().default(true).describe("Create parent directories as needed (-p flag, default true)"),
470
+ root: z.boolean().optional().default(false).describe("Use root shell for protected paths"),
471
+ device: z.string().optional().describe("Device serial"),
472
+ }, async ({ path: dirPath, parents, root, device }) => {
473
+ const start = Date.now();
474
+ try {
475
+ const resolved = await ctx.deviceManager.resolveDevice(device);
476
+ const serial = resolved.serial;
477
+ const shell = execShell(ctx, root);
478
+ const realPath = await resolveRealPath(ctx, serial, dirPath, root);
479
+ const blocked = isHardBlocked(realPath);
480
+ if (blocked)
481
+ return { content: [{ type: "text", text: blocked }], isError: true };
482
+ const fs = await detectFsType(ctx, serial, dirPath, root);
483
+ if (fs.readOnly)
484
+ return { content: [{ type: "text", text: fs.warning }], isError: true };
485
+ const pFlag = parents ? "-p " : "";
486
+ const result = await shell(`mkdir ${pFlag}'${shellEscape(dirPath)}' 2>&1`, {
487
+ device: serial, timeout: 10000, ignoreExitCode: true,
488
+ });
489
+ if (result.exitCode !== 0 && result.stdout.trim()) {
490
+ return { content: [{ type: "text", text: `mkdir failed: ${result.stdout.trim()}${!root ? "\nTip: try root=true for protected paths" : ""}` }], isError: true };
491
+ }
492
+ return {
493
+ content: [{
494
+ type: "text",
495
+ text: formatMetrics({
496
+ operation: "mkdir",
497
+ path: dirPath,
498
+ executionMs: Date.now() - start,
499
+ fsWarning: fs.warning,
500
+ }),
501
+ }],
502
+ };
503
+ }
504
+ catch (error) {
505
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
506
+ }
507
+ });
508
+ ctx.server.tool("adb_rm", "Delete a file or directory on the device. Recursive deletion uses depth-based protection: refuses at depth ≤ 2 from root (blocks rm -rf /system or /sdcard but allows /sdcard/project/build/). Symlinks are resolved before depth checks to prevent traversal bypasses. Hard-blocks /dev, /proc, /sys. For recursive deletes, reports a pre-flight file count.", {
509
+ path: z.string().describe("File or directory path to delete"),
510
+ recursive: z.boolean().optional().default(false).describe("Delete directory recursively (-rf). Depth-protected."),
511
+ root: z.boolean().optional().default(false).describe("Use root shell for protected paths"),
512
+ device: z.string().optional().describe("Device serial"),
513
+ }, async ({ path: targetPath, recursive, root, device }) => {
514
+ const start = Date.now();
515
+ try {
516
+ const resolved = await ctx.deviceManager.resolveDevice(device);
517
+ const serial = resolved.serial;
518
+ const shell = execShell(ctx, root);
519
+ // Resolve symlinks BEFORE depth check to prevent traversal bypass
520
+ const realPath = await resolveRealPath(ctx, serial, targetPath, root);
521
+ const blocked = isHardBlocked(realPath);
522
+ if (blocked)
523
+ return { content: [{ type: "text", text: blocked }], isError: true };
524
+ // Depth-based protection for recursive delete
525
+ if (recursive) {
526
+ const depth = pathDepth(realPath);
527
+ if (depth <= 2) {
528
+ return {
529
+ content: [{ type: "text", text: `Refusing recursive delete at depth ${depth} (${realPath}). Recursive deletion requires path depth > 2 from root to prevent accidental system/data loss. For individual file deletion in this path, omit recursive=true.` }],
530
+ isError: true,
531
+ };
532
+ }
533
+ }
534
+ const fs = await detectFsType(ctx, serial, targetPath, root);
535
+ if (fs.readOnly)
536
+ return { content: [{ type: "text", text: fs.warning }], isError: true };
537
+ // Pre-flight count for recursive delete
538
+ let itemCount = "1";
539
+ if (recursive) {
540
+ const countResult = await shell(`find '${shellEscape(targetPath)}' 2>/dev/null | wc -l`, { device: serial, timeout: 15000, ignoreExitCode: true });
541
+ itemCount = countResult.stdout.trim() || "unknown";
542
+ }
543
+ // Execute deletion
544
+ const rmFlag = recursive ? "-rf" : "-f";
545
+ const result = await shell(`rm ${rmFlag} '${shellEscape(targetPath)}' 2>&1`, {
546
+ device: serial, timeout: recursive ? 120000 : 10000, ignoreExitCode: true,
547
+ });
548
+ if (result.exitCode !== 0 && result.stdout.trim()) {
549
+ return { content: [{ type: "text", text: `Delete failed: ${result.stdout.trim()}${!root ? "\nTip: try root=true for protected paths" : ""}` }], isError: true };
550
+ }
551
+ const storage = await getStorageInfo(ctx, serial, targetPath, root);
552
+ return {
553
+ content: [{
554
+ type: "text",
555
+ text: formatMetrics({
556
+ operation: recursive ? "delete (recursive)" : "delete",
557
+ path: targetPath,
558
+ executionMs: Date.now() - start,
559
+ extras: {
560
+ "Resolved path": realPath !== targetPath ? realPath : "(direct)",
561
+ ...(recursive ? { "Items deleted": itemCount } : {}),
562
+ },
563
+ storage,
564
+ fsWarning: fs.warning,
565
+ }),
566
+ }],
567
+ };
568
+ }
569
+ catch (error) {
570
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
571
+ }
572
+ });
573
+ ctx.server.tool("adb_file_move", "Move or rename a file/directory on the device. Moving FROM a system path is destructive — source depth protection refuses if source is at depth ≤ 1 from root. Symlinks resolved before checks. Cross-filesystem moves (e.g., /data → /sdcard) do a copy+delete internally and can be slow for large files.", {
574
+ source: z.string().describe("Source file or directory path"),
575
+ destination: z.string().describe("Destination path"),
576
+ root: z.boolean().optional().default(false).describe("Use root shell for protected paths"),
577
+ device: z.string().optional().describe("Device serial"),
578
+ }, async ({ source, destination, root, device }) => {
579
+ const start = Date.now();
580
+ try {
581
+ const resolved = await ctx.deviceManager.resolveDevice(device);
582
+ const serial = resolved.serial;
583
+ const shell = execShell(ctx, root);
584
+ // Resolve symlinks for both source and destination
585
+ const realSource = await resolveRealPath(ctx, serial, source, root);
586
+ const realDest = await resolveRealPath(ctx, serial, destination, root);
587
+ // Hard-block check on both paths
588
+ const srcBlocked = isHardBlocked(realSource);
589
+ if (srcBlocked)
590
+ return { content: [{ type: "text", text: `Source: ${srcBlocked}` }], isError: true };
591
+ const destBlocked = isHardBlocked(realDest);
592
+ if (destBlocked)
593
+ return { content: [{ type: "text", text: `Destination: ${destBlocked}` }], isError: true };
594
+ // Source depth protection — mv from a top-level path removes it
595
+ const srcDepth = pathDepth(realSource);
596
+ if (srcDepth <= 1) {
597
+ return {
598
+ content: [{ type: "text", text: `Refusing move: source path ${realSource} is at depth ${srcDepth} (top-level system directory). Moving it would remove a critical directory.` }],
599
+ isError: true,
600
+ };
601
+ }
602
+ // Get source size for reporting
603
+ const sizeResult = await shell(`stat -c '%s' '${shellEscape(source)}' 2>/dev/null`, {
604
+ device: serial, timeout: 3000, ignoreExitCode: true,
605
+ });
606
+ const sourceSize = sizeResult.stdout.trim();
607
+ // Execute move
608
+ const result = await shell(`mv '${shellEscape(source)}' '${shellEscape(destination)}' 2>&1`, {
609
+ device: serial, timeout: 120000, ignoreExitCode: true,
610
+ });
611
+ if (result.exitCode !== 0 && result.stdout.trim()) {
612
+ return { content: [{ type: "text", text: `Move failed: ${result.stdout.trim()}${!root ? "\nTip: try root=true for protected paths" : ""}` }], isError: true };
613
+ }
614
+ // Post-verify: destination exists
615
+ const verifyResult = await shell(`test -e '${shellEscape(destination)}' && echo EXISTS`, {
616
+ device: serial, timeout: 3000, ignoreExitCode: true,
617
+ });
618
+ const verified = verifyResult.stdout.includes("EXISTS");
619
+ const storage = await getStorageInfo(ctx, serial, destination, root);
620
+ return {
621
+ content: [{
622
+ type: "text",
623
+ text: formatMetrics({
624
+ operation: "move",
625
+ path: `${source} → ${destination}`,
626
+ executionMs: Date.now() - start,
627
+ extras: {
628
+ ...(sourceSize ? { "Size": `${sourceSize} bytes` } : {}),
629
+ "Verified": verified ? "destination exists" : "⚠ could not verify destination",
630
+ },
631
+ storage,
632
+ }),
633
+ }],
634
+ };
635
+ }
636
+ catch (error) {
637
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
638
+ }
639
+ });
640
+ ctx.server.tool("adb_file_copy", "Copy a file or directory on the device. Pre-flight checks source size against destination available space. Post-verifies the copy by comparing file sizes. For recursive directory copies, use -r flag.", {
641
+ source: z.string().describe("Source file or directory path"),
642
+ destination: z.string().describe("Destination path"),
643
+ recursive: z.boolean().optional().default(false).describe("Copy directories recursively (-r)"),
644
+ root: z.boolean().optional().default(false).describe("Use root shell for protected paths"),
645
+ device: z.string().optional().describe("Device serial"),
646
+ }, async ({ source, destination, recursive, root, device }) => {
647
+ const start = Date.now();
648
+ try {
649
+ const resolved = await ctx.deviceManager.resolveDevice(device);
650
+ const serial = resolved.serial;
651
+ const shell = execShell(ctx, root);
652
+ const realDest = await resolveRealPath(ctx, serial, destination, root);
653
+ const destBlocked = isHardBlocked(realDest);
654
+ if (destBlocked)
655
+ return { content: [{ type: "text", text: `Destination: ${destBlocked}` }], isError: true };
656
+ const fs = await detectFsType(ctx, serial, destination, root);
657
+ if (fs.readOnly)
658
+ return { content: [{ type: "text", text: fs.warning }], isError: true };
659
+ // Pre-flight: source size and destination available space
660
+ const preResult = await shell(`stat -c '%s' '${shellEscape(source)}' 2>/dev/null && echo '---DF---' && df '${shellEscape(destination)}' 2>/dev/null | tail -1`, { device: serial, timeout: 5000, ignoreExitCode: true });
661
+ const preParts = preResult.stdout.split("---DF---");
662
+ const sourceBytes = parseInt(preParts[0]?.trim() ?? "", 10) || 0;
663
+ // Execute copy
664
+ const rFlag = recursive ? "-r " : "";
665
+ const result = await shell(`cp ${rFlag}'${shellEscape(source)}' '${shellEscape(destination)}' 2>&1`, {
666
+ device: serial, timeout: 300000, ignoreExitCode: true,
667
+ });
668
+ if (result.exitCode !== 0 && result.stdout.trim()) {
669
+ return { content: [{ type: "text", text: `Copy failed: ${result.stdout.trim()}${!root ? "\nTip: try root=true for protected paths" : ""}` }], isError: true };
670
+ }
671
+ // Post-verify: compare sizes
672
+ const destSizeResult = await shell(`stat -c '%s' '${shellEscape(destination)}' 2>/dev/null`, {
673
+ device: serial, timeout: 3000, ignoreExitCode: true,
674
+ });
675
+ const destBytes = parseInt(destSizeResult.stdout.trim(), 10) || 0;
676
+ const sizeMatch = sourceBytes > 0 && destBytes > 0 ? (sourceBytes === destBytes ? "✓ match" : `⚠ mismatch (source: ${sourceBytes}, dest: ${destBytes})`) : "skipped";
677
+ const storage = await getStorageInfo(ctx, serial, destination, root);
678
+ return {
679
+ content: [{
680
+ type: "text",
681
+ text: formatMetrics({
682
+ operation: recursive ? "copy (recursive)" : "copy",
683
+ path: `${source} → ${destination}`,
684
+ executionMs: Date.now() - start,
685
+ extras: {
686
+ "Copied size": formatBytes(sourceBytes),
687
+ "Size verification": sizeMatch,
688
+ },
689
+ storage,
690
+ fsWarning: fs.warning,
691
+ }),
692
+ }],
693
+ };
694
+ }
695
+ catch (error) {
696
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
697
+ }
698
+ });
699
+ ctx.server.tool("adb_file_chmod", "Change file permissions on the device. Mode must be a valid octal string (e.g., '755', '644', '600'). Recursive mode uses depth-based protection: refuses at depth ≤ 2 from root. Note: sdcardfs/FUSE ignores Unix permissions — file access on /sdcard is controlled by Android's package ownership model.", {
700
+ path: z.string().describe("File or directory path"),
701
+ mode: z.string().regex(/^[0-7]{3,4}$/).describe("Octal permission mode (e.g., '755', '644', '0600')"),
702
+ recursive: z.boolean().optional().default(false).describe("Apply recursively to directory contents. Depth-protected."),
703
+ root: z.boolean().optional().default(false).describe("Use root shell (typically required for chmod)"),
704
+ device: z.string().optional().describe("Device serial"),
705
+ }, async ({ path: filePath, mode, recursive, root, device }) => {
706
+ const start = Date.now();
707
+ try {
708
+ const resolved = await ctx.deviceManager.resolveDevice(device);
709
+ const serial = resolved.serial;
710
+ const shell = execShell(ctx, root);
711
+ const realPath = await resolveRealPath(ctx, serial, filePath, root);
712
+ const blocked = isHardBlocked(realPath);
713
+ if (blocked)
714
+ return { content: [{ type: "text", text: blocked }], isError: true };
715
+ // Depth-based protection for recursive chmod
716
+ if (recursive) {
717
+ const depth = pathDepth(realPath);
718
+ if (depth <= 2) {
719
+ return {
720
+ content: [{ type: "text", text: `Refusing recursive chmod at depth ${depth} (${realPath}). Recursive permission changes require path depth > 2 from root.` }],
721
+ isError: true,
722
+ };
723
+ }
724
+ }
725
+ const fs = await detectFsType(ctx, serial, filePath, root);
726
+ if (fs.readOnly)
727
+ return { content: [{ type: "text", text: fs.warning }], isError: true };
728
+ const rFlag = recursive ? "-R " : "";
729
+ const result = await shell(`chmod ${rFlag}${mode} '${shellEscape(filePath)}' 2>&1`, {
730
+ device: serial, timeout: recursive ? 60000 : 10000, ignoreExitCode: true,
731
+ });
732
+ if (result.exitCode !== 0 && result.stdout.trim()) {
733
+ return { content: [{ type: "text", text: `chmod failed: ${result.stdout.trim()}${!root ? "\nTip: try root=true (chmod typically requires root)" : ""}` }], isError: true };
734
+ }
735
+ // Verify resulting permissions
736
+ const verifyResult = await shell(`stat -c '%a %A' '${shellEscape(filePath)}' 2>/dev/null`, {
737
+ device: serial, timeout: 3000, ignoreExitCode: true,
738
+ });
739
+ const resulting = verifyResult.stdout.trim();
740
+ return {
741
+ content: [{
742
+ type: "text",
743
+ text: formatMetrics({
744
+ operation: recursive ? "chmod (recursive)" : "chmod",
745
+ path: filePath,
746
+ executionMs: Date.now() - start,
747
+ extras: {
748
+ "Mode set": mode,
749
+ "Resulting permissions": resulting || "(could not verify)",
750
+ },
751
+ fsWarning: fs.warning,
752
+ }),
753
+ }],
754
+ };
755
+ }
756
+ catch (error) {
757
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
758
+ }
759
+ });
760
+ ctx.server.tool("adb_file_touch", "Create an empty file or update timestamps. Three modes: create (touch non-existent path), update (set atime/mtime to now), or set explicit timestamp. On FAT32, timestamps have 2-second resolution. On tmpfs, timestamps are volatile.", {
761
+ path: z.string().describe("File path on device"),
762
+ timestamp: z.string().optional().describe("Explicit timestamp in 'YYYY-MM-DD HH:MM:SS' format. Omit to use current time."),
763
+ root: z.boolean().optional().default(false).describe("Use root shell for protected paths"),
764
+ device: z.string().optional().describe("Device serial"),
765
+ }, async ({ path: filePath, timestamp, root, device }) => {
766
+ const start = Date.now();
767
+ try {
768
+ const resolved = await ctx.deviceManager.resolveDevice(device);
769
+ const serial = resolved.serial;
770
+ const shell = execShell(ctx, root);
771
+ const realPath = await resolveRealPath(ctx, serial, filePath, root);
772
+ const blocked = isHardBlocked(realPath);
773
+ if (blocked)
774
+ return { content: [{ type: "text", text: blocked }], isError: true };
775
+ const fs = await detectFsType(ctx, serial, filePath, root);
776
+ if (fs.readOnly)
777
+ return { content: [{ type: "text", text: fs.warning }], isError: true };
778
+ // Check if file exists before touch (to report create vs update)
779
+ const existResult = await shell(`test -e '${shellEscape(filePath)}' && echo EXISTS`, {
780
+ device: serial, timeout: 3000, ignoreExitCode: true,
781
+ });
782
+ const existed = existResult.stdout.includes("EXISTS");
783
+ // Build touch command
784
+ let cmd;
785
+ if (timestamp) {
786
+ // Convert YYYY-MM-DD HH:MM:SS to touch -t format: YYYYMMDDhhmm.ss
787
+ const tsMatch = timestamp.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$/);
788
+ if (!tsMatch) {
789
+ return { content: [{ type: "text", text: `Invalid timestamp format: "${timestamp}". Expected: YYYY-MM-DD HH:MM:SS` }], isError: true };
790
+ }
791
+ const touchTs = `${tsMatch[1]}${tsMatch[2]}${tsMatch[3]}${tsMatch[4]}${tsMatch[5]}.${tsMatch[6]}`;
792
+ cmd = `touch -t ${touchTs} '${shellEscape(filePath)}' 2>&1`;
793
+ }
794
+ else {
795
+ cmd = `touch '${shellEscape(filePath)}' 2>&1`;
796
+ }
797
+ const result = await shell(cmd, {
798
+ device: serial, timeout: 10000, ignoreExitCode: true,
799
+ });
800
+ if (result.exitCode !== 0 && result.stdout.trim()) {
801
+ return { content: [{ type: "text", text: `Touch failed: ${result.stdout.trim()}${!root ? "\nTip: try root=true for protected paths" : ""}` }], isError: true };
802
+ }
803
+ // Verify resulting timestamps
804
+ const statResult = await shell(`stat -c 'Access: %x\nModify: %y\nChange: %z' '${shellEscape(filePath)}' 2>/dev/null`, { device: serial, timeout: 3000, ignoreExitCode: true });
805
+ const timestamps = statResult.stdout.trim();
806
+ const mode = !existed ? "created" : timestamp ? "timestamp set" : "updated";
807
+ return {
808
+ content: [{
809
+ type: "text",
810
+ text: formatMetrics({
811
+ operation: `touch (${mode})`,
812
+ path: filePath,
813
+ executionMs: Date.now() - start,
814
+ extras: {
815
+ ...(timestamp ? { "Requested timestamp": timestamp } : {}),
816
+ ...(timestamps ? { "Resulting timestamps": timestamps } : {}),
817
+ },
818
+ fsWarning: fs.warning,
819
+ }),
820
+ }],
821
+ };
822
+ }
823
+ catch (error) {
824
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
825
+ }
826
+ });
827
+ ctx.server.tool("adb_file_fsinfo", "Report filesystem details for any path: filesystem type, mount point, mount options, capacity, usage, read-only status, permission support, symlink support, max file size, timestamp resolution, encryption status, and SELinux context. Essential for understanding what operations are possible before attempting them.", {
828
+ path: z.string().describe("File or directory path to inspect"),
829
+ root: z.boolean().optional().default(false).describe("Use root shell for system paths"),
830
+ device: z.string().optional().describe("Device serial"),
831
+ }, async ({ path: filePath, root, device }) => {
832
+ const start = Date.now();
833
+ try {
834
+ const resolved = await ctx.deviceManager.resolveDevice(device);
835
+ const serial = resolved.serial;
836
+ const shell = execShell(ctx, root);
837
+ const escaped = shellEscape(filePath);
838
+ // Parallel reads: stat -f, df, full mount table, SELinux context.
839
+ // The mount table is read whole (small) and filtered in TypeScript
840
+ // so we don't interpolate df output into a device-side grep pattern.
841
+ const [statfR, dfR, mountAllR, seR] = await Promise.allSettled([
842
+ shell(`stat -f -c 'Type: %T\nBlock size: %S\nTotal blocks: %b\nFree blocks: %f\nAvailable blocks: %a\nTotal inodes: %c\nFree inodes: %d' '${escaped}' 2>/dev/null`, {
843
+ device: serial, timeout: 5000, ignoreExitCode: true,
844
+ }),
845
+ shell(`df -h '${escaped}' 2>/dev/null | tail -1`, {
846
+ device: serial, timeout: 5000, ignoreExitCode: true,
847
+ }),
848
+ shell(`mount 2>/dev/null`, {
849
+ device: serial, timeout: 5000, ignoreExitCode: true,
850
+ }),
851
+ shell(`ls -Zd '${escaped}' 2>/dev/null`, {
852
+ device: serial, timeout: 3000, ignoreExitCode: true,
853
+ }),
854
+ ]);
855
+ const statfText = statfR.status === "fulfilled" ? statfR.value.stdout.trim() : "";
856
+ const dfText = dfR.status === "fulfilled" ? dfR.value.stdout.trim() : "";
857
+ const mountAllText = mountAllR.status === "fulfilled" ? mountAllR.value.stdout : "";
858
+ const seText = seR.status === "fulfilled" ? seR.value.stdout.trim() : "";
859
+ // Extract the mount point from df output (last whitespace-separated field),
860
+ // then find the matching mount table entry in TypeScript — no shell interpolation.
861
+ const dfFieldsAll = dfText.split(/\s+/);
862
+ const mountPoint = dfFieldsAll[dfFieldsAll.length - 1] ?? "";
863
+ const mountText = mountPoint
864
+ ? mountAllText.split("\n").find(l => l.includes(` on ${mountPoint} type `)) ?? ""
865
+ : "";
866
+ const sections = [];
867
+ sections.push(`=== Filesystem Info: ${filePath} ===\n`);
868
+ // Parse filesystem type (resolve hex magic to name)
869
+ const fsTypeMatch = statfText.match(/Type:\s*(\S+)/);
870
+ const rawFsType = fsTypeMatch ? fsTypeMatch[1].toLowerCase() : "unknown";
871
+ const fsType = FS_MAGIC_MAP[rawFsType] ?? rawFsType;
872
+ sections.push(`Filesystem type: ${fsType}${rawFsType !== fsType ? ` (${rawFsType})` : ""}`);
873
+ // Parse mount info
874
+ if (mountText) {
875
+ const mountMatch = mountText.match(/^(\S+)\s+on\s+(\S+)\s+type\s+(\S+)\s+\(([^)]+)\)/);
876
+ if (mountMatch) {
877
+ sections.push(`Mount point: ${mountMatch[2]}`);
878
+ sections.push(`Mount device: ${mountMatch[1]}`);
879
+ sections.push(`Mount type: ${mountMatch[3]}`);
880
+ const opts = mountMatch[4];
881
+ sections.push(`Mount options: ${opts}`);
882
+ sections.push(`Read-only: ${opts.split(",").some(o => o.trim() === "ro") ? "yes" : "no"}`);
883
+ }
884
+ }
885
+ // Parse df output
886
+ if (dfText) {
887
+ const dfFields = dfText.split(/\s+/);
888
+ if (dfFields.length >= 4) {
889
+ sections.push(`\nCapacity: ${dfFields[1]}`);
890
+ sections.push(`Used: ${dfFields[2]} (${dfFields[4] ?? "?"})`);
891
+ sections.push(`Available: ${dfFields[3]}`);
892
+ }
893
+ }
894
+ // Parse inode info from stat -f
895
+ const inodeTotal = statfText.match(/Total inodes:\s*(\d+)/);
896
+ const inodeFree = statfText.match(/Free inodes:\s*(\d+)/);
897
+ if (inodeTotal && inodeFree) {
898
+ const total = parseInt(inodeTotal[1], 10);
899
+ const free = parseInt(inodeFree[1], 10);
900
+ const usedPct = total > 0 ? (((total - free) / total) * 100).toFixed(1) : "?";
901
+ sections.push(`Inode usage: ${usedPct}% (${free} free of ${total})`);
902
+ }
903
+ // Filesystem behavior analysis
904
+ const fsLower = fsType.toLowerCase();
905
+ sections.push("\n── Capabilities ──");
906
+ const isReadOnly = READ_ONLY_FS.some(ro => fsLower.includes(ro));
907
+ const isFat = fsLower.includes("vfat") || fsLower.includes("fat");
908
+ const isFuse = fsLower.includes("fuse") || fsLower.includes("sdcardfs");
909
+ sections.push(`Supports Unix permissions: ${isFat ? "no (FAT32)" : isFuse ? "ignored (Android storage layer)" : "yes"}`);
910
+ sections.push(`Supports symlinks: ${isFat ? "no" : "yes"}`);
911
+ sections.push(`Timestamp resolution: ${isFat ? "2 seconds (FAT32 limitation)" : "nanosecond"}`);
912
+ if (isFat) {
913
+ sections.push(`Max file size: 4 GB (FAT32 limitation)`);
914
+ }
915
+ else if (fsLower.includes("f2fs")) {
916
+ sections.push(`Max file size: 3.94 TB (f2fs limit)`);
917
+ }
918
+ else if (fsLower.includes("ext4") || fsLower.includes("ext2/ext3")) {
919
+ sections.push(`Max file size: 16 TB (ext4 limit)`);
920
+ }
921
+ if (fsLower.includes("tmpfs")) {
922
+ sections.push(`Volatile: yes — contents lost on reboot`);
923
+ }
924
+ // SELinux context
925
+ if (seText) {
926
+ const seMatch = seText.match(/^(\S+)\s/);
927
+ if (seMatch)
928
+ sections.push(`\nSELinux context: ${seMatch[1]}`);
929
+ }
930
+ // Warnings
931
+ const warning = FS_WARNINGS[fsLower];
932
+ if (isReadOnly)
933
+ sections.push(`\n⚠ Read-only filesystem (${fsType})`);
934
+ else if (warning)
935
+ sections.push(`\n⚠ ${warning}`);
936
+ sections.push(`\nExecution time: ${Date.now() - start}ms`);
937
+ return { content: [{ type: "text", text: sections.join("\n") }] };
938
+ }
939
+ catch (error) {
940
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
941
+ }
942
+ });
943
+ ctx.server.tool("adb_file_chown", "Change file ownership on the device. Requires root. Supports both numeric UID:GID (e.g., '10150:10150') and symbolic user:group (e.g., 'system:system'). Recursive mode uses depth-based protection: refuses at depth ≤ 2 from root. Note: sdcardfs/FAT32 ignore ownership changes.", {
944
+ path: z.string().describe("File or directory path"),
945
+ owner: z.string().regex(/^[a-zA-Z0-9_]+:[a-zA-Z0-9_]+$/).describe("Owner in user:group format (e.g., '0:0', 'system:system', '10150:10150')"),
946
+ recursive: z.boolean().optional().default(false).describe("Apply recursively to directory contents. Depth-protected."),
947
+ device: z.string().optional().describe("Device serial"),
948
+ }, async ({ path: filePath, owner, recursive, device }) => {
949
+ const start = Date.now();
950
+ try {
951
+ const resolved = await ctx.deviceManager.resolveDevice(device);
952
+ const serial = resolved.serial;
953
+ const shell = execShell(ctx, true); // chown always requires root
954
+ const realPath = await resolveRealPath(ctx, serial, filePath, true);
955
+ const blocked = isHardBlocked(realPath);
956
+ if (blocked)
957
+ return { content: [{ type: "text", text: blocked }], isError: true };
958
+ // Depth-based protection for recursive chown
959
+ if (recursive) {
960
+ const depth = pathDepth(realPath);
961
+ if (depth <= 2) {
962
+ return {
963
+ content: [{ type: "text", text: `Refusing recursive chown at depth ${depth} (${realPath}). Recursive ownership changes require path depth > 2 from root.` }],
964
+ isError: true,
965
+ };
966
+ }
967
+ }
968
+ const fs = await detectFsType(ctx, serial, filePath, true);
969
+ if (fs.readOnly)
970
+ return { content: [{ type: "text", text: fs.warning }], isError: true };
971
+ const rFlag = recursive ? "-R " : "";
972
+ const result = await shell(`chown ${rFlag}${owner} '${shellEscape(filePath)}' 2>&1`, {
973
+ device: serial, timeout: recursive ? 60000 : 10000, ignoreExitCode: true,
974
+ });
975
+ if (result.exitCode !== 0 && result.stdout.trim()) {
976
+ return { content: [{ type: "text", text: `chown failed: ${result.stdout.trim()}` }], isError: true };
977
+ }
978
+ // Verify resulting ownership
979
+ const verifyResult = await shell(`stat -c '%U:%G (uid=%u gid=%g)' '${shellEscape(filePath)}' 2>/dev/null`, {
980
+ device: serial, timeout: 3000, ignoreExitCode: true,
981
+ });
982
+ const resulting = verifyResult.stdout.trim();
983
+ return {
984
+ content: [{
985
+ type: "text",
986
+ text: formatMetrics({
987
+ operation: recursive ? "chown (recursive)" : "chown",
988
+ path: filePath,
989
+ executionMs: Date.now() - start,
990
+ extras: {
991
+ "Owner set": owner,
992
+ "Resulting ownership": resulting || "(could not verify)",
993
+ },
994
+ fsWarning: fs.warning,
995
+ }),
996
+ }],
997
+ };
998
+ }
999
+ catch (error) {
1000
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
1001
+ }
1002
+ });
1003
+ ctx.server.tool("adb_grep", "Search file contents on the device by text pattern. Uses fixed-string matching by default (no regex injection risk). Supports recursive directory search with depth control and result capping. For filename searches, use adb_find instead.", {
1004
+ pattern: z.string().describe("Text pattern to search for"),
1005
+ path: z.string().describe("File or directory path to search"),
1006
+ recursive: z.boolean().optional().default(false).describe("Search directories recursively"),
1007
+ fixedString: z.boolean().optional().default(true).describe("Use fixed-string matching (default: true, safer). Set false for regex."),
1008
+ ignoreCase: z.boolean().optional().default(true).describe("Case-insensitive search (default: true)"),
1009
+ maxResults: z.number().min(1).max(10000).optional().default(500).describe("Maximum matching lines to return (1-10000, default 500)"),
1010
+ maxDepth: z.number().min(1).max(50).optional().default(10).describe("Maximum directory depth for recursive search (1-50, default 10)"),
1011
+ filesWithMatches: z.boolean().optional().default(false).describe("Show only filenames that contain matches, not the matching lines"),
1012
+ root: z.boolean().optional().default(false).describe("Use root shell for protected paths"),
1013
+ device: z.string().optional().describe("Device serial"),
1014
+ }, async ({ pattern, path: searchPath, recursive, fixedString, ignoreCase, maxResults, maxDepth, filesWithMatches, root, device }) => {
1015
+ const start = Date.now();
1016
+ try {
1017
+ const resolved = await ctx.deviceManager.resolveDevice(device);
1018
+ const serial = resolved.serial;
1019
+ const shell = execShell(ctx, root);
1020
+ const escaped = shellEscape(searchPath);
1021
+ const patternEscaped = shellEscape(pattern);
1022
+ let cmd;
1023
+ if (recursive) {
1024
+ // Use find + grep for recursive with depth control
1025
+ const grepFlags = [fixedString ? "-F" : "", ignoreCase ? "-i" : "", "-n", filesWithMatches ? "-l" : ""].filter(f => f).join(" ");
1026
+ cmd = `find '${escaped}' -maxdepth ${maxDepth} -type f 2>/dev/null | xargs grep ${grepFlags} '${patternEscaped}' 2>/dev/null | head -n ${maxResults + 1}`;
1027
+ }
1028
+ else {
1029
+ const grepFlags = [fixedString ? "-F" : "", ignoreCase ? "-i" : "", "-n", filesWithMatches ? "-l" : ""].filter(f => f).join(" ");
1030
+ cmd = `grep ${grepFlags} '${patternEscaped}' '${escaped}' 2>/dev/null | head -n ${maxResults + 1}`;
1031
+ }
1032
+ const result = await shell(cmd, {
1033
+ device: serial, timeout: 60000, ignoreExitCode: true,
1034
+ });
1035
+ const lines = result.stdout.trim().split("\n").filter(l => l.length > 0);
1036
+ const truncated = lines.length > maxResults;
1037
+ const results = truncated ? lines.slice(0, maxResults) : lines;
1038
+ if (results.length === 0) {
1039
+ return { content: [{ type: "text", text: `No matches for '${pattern}' in ${searchPath}${recursive ? " (recursive)" : ""}.\nExecution time: ${Date.now() - start}ms` }] };
1040
+ }
1041
+ let output = results.join("\n");
1042
+ output += `\n\n${results.length} match(es)`;
1043
+ if (truncated)
1044
+ output += ` (truncated at ${maxResults} — more results exist)`;
1045
+ output += `\nExecution time: ${Date.now() - start}ms`;
1046
+ return { content: [{ type: "text", text: output }] };
1047
+ }
1048
+ catch (error) {
1049
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
1050
+ }
1051
+ });
1052
+ ctx.server.tool("adb_file_replace", "Find and replace text in a file on the device. Uses sed internally with proper escaping — exposes a safe interface without requiring sed syntax knowledge. Supports global replacement, line-targeted edits, and optional backup creation. Reports match count.", {
1053
+ path: z.string().describe("File path on device"),
1054
+ find: z.string().refine(s => !/[\r\n]/.test(s), "find must not contain newlines (sed treats newlines as script-command separators)").describe("Text to find (literal string, not regex)"),
1055
+ replace: z.string().refine(s => !/[\r\n]/.test(s), "replace must not contain newlines (sed treats newlines as script-command separators)").describe("Replacement text"),
1056
+ global: z.boolean().optional().default(true).describe("Replace all occurrences (default: true). Set false for first-only."),
1057
+ lineNumber: z.number().min(1).optional().describe("Restrict replacement to a specific line number"),
1058
+ backup: z.boolean().optional().default(false).describe("Create a .bak backup before modifying"),
1059
+ root: z.boolean().optional().default(false).describe("Use root shell for protected paths"),
1060
+ device: z.string().optional().describe("Device serial"),
1061
+ }, async ({ path: filePath, find, replace, global: globalReplace, lineNumber, backup, root, device }) => {
1062
+ const start = Date.now();
1063
+ try {
1064
+ const resolved = await ctx.deviceManager.resolveDevice(device);
1065
+ const serial = resolved.serial;
1066
+ const shell = execShell(ctx, root);
1067
+ const escaped = shellEscape(filePath);
1068
+ const realPath = await resolveRealPath(ctx, serial, filePath, root);
1069
+ const blocked = isHardBlocked(realPath);
1070
+ if (blocked)
1071
+ return { content: [{ type: "text", text: blocked }], isError: true };
1072
+ const fs = await detectFsType(ctx, serial, filePath, root);
1073
+ if (fs.readOnly)
1074
+ return { content: [{ type: "text", text: fs.warning }], isError: true };
1075
+ // Count matches before replacement
1076
+ const sedFind = sedEscapePattern(find);
1077
+ const countCmd = lineNumber
1078
+ ? `sed -n '${lineNumber}p' '${escaped}' | grep -cF '${shellEscape(find)}' 2>/dev/null`
1079
+ : `grep -cF '${shellEscape(find)}' '${escaped}' 2>/dev/null`;
1080
+ const countResult = await shell(countCmd, {
1081
+ device: serial, timeout: 10000, ignoreExitCode: true,
1082
+ });
1083
+ const matchCount = parseInt(countResult.stdout.trim(), 10) || 0;
1084
+ if (matchCount === 0) {
1085
+ return { content: [{ type: "text", text: `No matches found for "${find}" in ${filePath}${lineNumber ? ` (line ${lineNumber})` : ""}.\nExecution time: ${Date.now() - start}ms` }] };
1086
+ }
1087
+ // Create backup if requested
1088
+ if (backup) {
1089
+ await shell(`cp '${escaped}' '${escaped}.bak' 2>&1`, {
1090
+ device: serial, timeout: 10000, ignoreExitCode: true,
1091
+ });
1092
+ }
1093
+ // Build sed command
1094
+ const sedReplace = sedEscapeReplacement(replace);
1095
+ const gFlag = globalReplace ? "g" : "";
1096
+ let sedCmd;
1097
+ if (lineNumber) {
1098
+ sedCmd = `sed -i '${lineNumber}s/${sedFind}/${sedReplace}/${gFlag}' '${escaped}' 2>&1`;
1099
+ }
1100
+ else {
1101
+ sedCmd = `sed -i 's/${sedFind}/${sedReplace}/${gFlag}' '${escaped}' 2>&1`;
1102
+ }
1103
+ const result = await shell(sedCmd, {
1104
+ device: serial, timeout: 30000, ignoreExitCode: true,
1105
+ });
1106
+ if (result.exitCode !== 0 && result.stdout.trim()) {
1107
+ return { content: [{ type: "text", text: `Replace failed: ${result.stdout.trim()}${!root ? "\nTip: try root=true for protected paths" : ""}` }], isError: true };
1108
+ }
1109
+ return {
1110
+ content: [{
1111
+ type: "text",
1112
+ text: formatMetrics({
1113
+ operation: "replace",
1114
+ path: filePath,
1115
+ executionMs: Date.now() - start,
1116
+ extras: {
1117
+ "Find": find.length > 80 ? find.substring(0, 80) + "..." : find,
1118
+ "Replace": replace.length > 80 ? replace.substring(0, 80) + "..." : replace,
1119
+ "Lines with matches": String(matchCount),
1120
+ "Mode": globalReplace ? "global (all occurrences)" : "first occurrence only",
1121
+ ...(lineNumber ? { "Line": String(lineNumber) } : {}),
1122
+ ...(backup ? { "Backup": `${filePath}.bak` } : {}),
1123
+ },
1124
+ fsWarning: fs.warning,
1125
+ }),
1126
+ }],
1127
+ };
72
1128
  }
73
1129
  catch (error) {
74
1130
  return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };